Skip to content

Changelog

Unreleased

v4.44.0 — 2026-09-27

Fixed

  • setup-ar-emulator.sh --seed-snapshot refuses to save a snapshot without ARCore (#2749). A failed ARCore install was swallowed by an install_arcore && log AND-list, which errexit ignores, so the seed saved an ARCore-less qa-clean snapshot and exited 0. The seed now re-reads the package list and exits 1 when com.google.ar.core is missing, and a failed install is logged as a warning on every run.
  • The playground's iOS snippets now compile against SceneViewSwift (#3192). All fourteen used APIs that do not exist (ModelNode("…"), DirectionalLightNode, .scaleToFit, .onTapAR, .onFaceUpdate). They now load models with ModelNode.load, light with LightNode.directional, place AR content with onTapOnPlane and AnchorNode.world, and the "copy prompt" iOS block says the same thing. sceneview.js reported 4.18.0 in its header and in SceneView.version; both now read the current version and sync-versions.sh keeps them there. The structured-data FAQ no longer advertises the removed list_node_types MCP tool, and the GPT system prompt's version block reads the current release and Kotlin 2.4.20.
  • Demo — Open with SceneView: an STL, OBJ or PLY shared as a nameless application/octet-stream now opens instead of being refused. The staged file is recognised from its own bytes with the SDK's StlLoader.isStl / ObjLoader.isObj / PlyLoader.isPly, as GLB, glTF and 3MF already were, and a name with no model extension gets the one the bytes proved, so the real-size question still appears for these unit-less formats (#3490).
  • The README and docs format tables now say what Apple actually opens (#3655). The README matrix claimed glTF / GLB on Apple, which RealityKit does not read and SceneViewSwift's ModelFormat excludes on purpose, and denied STL, OBJ, PLY and 3MF, which Apple has opened since #3513. The README, the Model formats page and the iOS section of the Platforms page now agree with ModelFormat.swift: USDZ / Reality / USD through RealityKit, STL / OBJ / PLY through ModelIO, 3MF through SceneViewSwift's own parser, no glTF (convert with tools/convert-usdz.sh).
  • Demo app: the AR Pose coordinate readout uses the glass chrome tokens (#3683). The pill over the camera feed hardcoded Color(0xCC161B22) with a comment naming a SurfaceDim token that has since moved twice. It now takes SceneViewTokens.Glass — the chrome-scrim fill, on-glass text and the over-media-edge boundary — like the rest of the over-media chrome.
  • Shared links to /open/, /preview/, /embed/ and /web-demo/ now unfurl (#3688). The four pages most often pasted into a chat or a ticket had no Open Graph or Twitter metadata, so Slack, Discord and GitHub showed a bare URL. They now carry the same block as the marketing pages (title, description, 1200×630 og-image.png, summary_large_image). /open/, /preview/ and /embed/ deliberately omit og:url: they are addressed by query string, and a bare canonical URL would make crawlers unfurl the empty page instead of the shared model. geometry-demo.html pointed its og:image at an SVG favicon, which crawlers do not render; it now uses the PNG card.
  • Website text now reads at 4.5:1 or better in light mode (#3689). The hero's "Try the demo:" label used a surface token on the blue gradient. It is now white at the hero description's 0.9, which gives 5.5:1 on both gradient stops. "Stable" and "Fully supported" labels use a darker green in light mode (#166534), which holds 5.6:1 or better on their tinted chips. --color-outline is #56688f in light mode, so the playground sidebar headers, counts and line numbers that use it as text pass on every surface tier. Captions and API signatures no longer dim the muted token with opacity. Code annotations in the playground are #b45309. The "v4" badges in the developer-tools section pointed at an undefined --accent, so they rendered white on white; they now use the primary colour. A DOM audit of seven pages found 85 failing text nodes in light mode before this change and 0 after. Dark mode went from 7 to 0.
  • A web viewer below the fold now holds a finished frame of its model instead of an empty canvas or a black silhouette (#3690). The render loop parks while a canvas is off-screen (#2508), and gltfio decodes a model's textures asynchronously after the geometry is in the scene. On /web.html the demo loads off-screen, so the loop parked before drawing the model at all, or right after drawing it untextured. A reader who scrolled down was fine, because the loop resumed. A full-page capture, a print or any other off-screen read of the canvas was not. The viewer now draws one frame while parked: when the textures finish decoding, when the IBL lands and after a resize. It retries on the next animation frames if Filament skips that frame. The loop itself still stays parked off-screen.
  • Demo — Materials: leaving Inspect a few seconds after dragging the camera no longer cuts to the hand-over pose. The framing the user left rides the flight back to the wall and eases away across it, so the first frame of the flight out shows the last frame of Inspect (#3692).
  • Demo — accessibility: the shared LabeledSlider no longer erases its track's semantics. TalkBack now announces every demo slider as an adjustable slider (swipe up / down works), a disabled one as disabled, and still reads the value once, in the unit on screen (#3721).
  • Two views rendering one shared Filament Scene both wake on node changes under FrameRatePolicy.OnDemand (#3723). The scene → view registry kept one entry per scene, so the second SceneView (or an ARSceneView next to a SceneView) to compose silently overwrote the first: a node change woke only the second view while the first stayed frozen, and when either view left composition it removed the shared entry, so the surviving view stopped waking entirely. Each view now registers alongside the others, a node change wakes all of them, and a view leaving composition removes only its own entry. The registry still holds scenes weakly, so a destroyed scene never retains a view.
  • Demo app: a selected dock toggle is legible over bright scenes (#3727). A selected item used to tint its icon and caption pale blue on the dock's glass, which over the near-white Contact Shadow studio fell to about 3:1. It now sits on a filled dock-selected pill (the dark-scheme primary, with a dark foreground) that reads at 7.3:1 on any scene, in every demo. The Contact Shadow dock caption "Shadows" is now a string resource.
  • Demo app: Contact Shadow "Reset demo" sends the camera home and keeps your Bounce motion choice (#3728). The reset used to leave the camera wherever it had been orbited and silently turn Bounce motion back on. The orbit is now rebuilt at its authored home on reset and eased there through the demo's continuity layer (the Model Viewer's recenter pattern, a cut in QA mode), and Bounce motion stays as the user set it. The reset lives in a small state holder covered by ContactShadowDemoResetTest.
  • Demo — Wall placement: the rotate buttons in the adjustments sheet now read "Rotate clockwise" / "Rotate counter-clockwise", which is what they do — wall content spins about the wall's normal, never left or right. The floor feature comparison keeps "Rotate left / right" on strings of its own (#3741).
  • The TerrainAnchorNode / RooftopAnchorNode samples now compile and resolve once instead of every frame (#3908). The KDoc sample passed earth = to resolve(), which takes session, and started a new billed Geospatial request on every frame until the first one succeeded. The KDoc and llms.txt samples now keep the returned future in remembered state (the guard that stops a second request), cancel it on dispose, hop to the main thread in the callback, destroy a node that resolves after the screen was left, and hand the node to the TerrainAnchorNode(node = …) / RooftopAnchorNode(node = …) composables. Both llms.txt snippets are compiled by the snippets check.
  • TV demo: picking a model now shows a loading state — a progress ring and the model's name, sized for ten feet — until the model's first frame is on screen, instead of a black stage for up to 13 s. The previous model is no longer left on screen under the new model's name while the next one loads. (#3926)
  • A minor release in which no demo changed no longer fails DemoFreshnessTest (#3927). The test required at least one demo to carry a "New" / "Updated" marker for the build's version, so every version-bump PR where no demo PR had declared updatedIn went red inside the release run, after the QA gate. "Nothing is fresh" is now a legal state: the Showcase draws no chip and the What's-new sheet drops its "to try" section, as it already did. The upper bound stays — no more than a third of the grid may be marked.
  • Leaving a scene while its model is still loading no longer freezes the app (#3981). Disposing rememberModelInstance or rememberModelLoader mid-load called gltfio's asyncCancelLoad on the main thread, which joins the running texture decoders and then waits for the whole GPU backlog — an ANR on slow devices. The model is now claimed at once and freed from a main-looper poll once its load has settled (decoding done and backend idle, or no progress for 10 s), the loader's native resources wait for those destroys, and the engine's deferred destroy waits for them in turn instead of forcing them.
  • A render-on-demand scene no longer parks on a single frame when the GPU is slow (#3982). The settle window after a change closed on the first presented frame past its 500 ms deadline, and while Filament links a model's materials that first frame is often the only one presented in the window. The scene then froze on it, and the demo's "Still loading…" card, which waits for a second frame, stayed up over a finished model (Model Viewer) or a black viewport (2D in 3D, Scene to MP4) until a touch woke the loop. The window now also owes at least two presented frames after each change, and Model Viewer asks for the frame its cover still needs once the model has loaded.
  • Demo app: no more grey bands around inset scenes (#3983). Double Pendulum, Geometry Primitives, Animation & Physics, Contact Shadow Preview, 2D in 3D and Scene to MP4 shrink their viewport to keep it clear of their controls. The space above and below it showed the window background, light grey under the scrims in both themes. It is now painted with the dark stage colour, so the controls sit on one continuous stage.
  • Demo app: the status bar stays readable over the dark 3D stage in light theme (#3984). Opening Model Viewer, Materials, Lighting and most other demos from the Showcase left dark status-bar icons on the dark stage, so the clock and battery disappeared. The Showcase and the demo each saved, forced and restored the icon colour, and the screen that left last restored the wrong one. Screens now only ask for light icons, and one place at the root applies the result, so navigation order no longer matters. In dark theme, the online gallery's "View in 3D" viewer also gets light icons instead of dark ones.
  • Demo app: floating controls fade out under an open sheet, like the dock (#3985). Model Viewer's "Surprise me" pill stayed visible through the Lighting and Settings sheets, right on their sliders, where it read as a live button inside the sheet. Every demo's bottom overlay now fades with the dock while a glass sheet is open and comes back when it closes.
  • AutoPlacementScene no longer draws its coaching glyph over the "Couldn't start AR" card (#3986). Both are centred, so when ARCore could not start a session the phone-sweep disc kept covering the card's explanation and most of its Try again button. The coaching overlay now stays silent while ARSceneView shows its availability card. The demo app's AR Placement, AR View and feature-comparison screens get the same fix.
  • Demo app: AR Placement's streamed models show their own picture (#3987). The six "Streamed" cards showed the thumbnail of their bundled stand-in, which is a different model: Coffee Mug showed the Olive Dish, Picture Frame showed the Soldier. Each streamed card now shows its own Sketchfab thumbnail, or the generic AR glyph when there is no API key or no network. The card title also no longer shares the "Streamed" caption's grey, so the caption reads as a caption.
  • Demo app: the Point & Ask cards are readable in dark theme again (#3988). The "Gemini Nano isn't available" card, the failure card and the answer card drew near-black text on a dark card: their translucent surface is not an exact scheme colour, so Material could not pick a content colour for it. The card now sets onSurface explicitly.
  • Demo app: Rerun Debug no longer stacks its preview tile on the "Couldn't start AR" card (#3989). When AR cannot start, the 3D preview tile covered the right half of the card's title and the "No computer connected" card sat above it. While that card explains the failure, it is now the only card on screen; the tile and the connection card come back as soon as a session starts.
  • Demo app: Geospatial Anchors offers a way forward when a permission is denied (#3990). Answering "Don't allow" left a line of text on a white screen, with Back as the only exit. The screen is now the dark stage with a card that says which permission is off and why the demo needs it, and one button: Allow while Android can still ask, Open settings once it cannot. Coming back from Settings starts the demo. Plane Renderer V2 and every other demo whose AR session fails also keep the dark stage instead of turning white.
  • Demo app: AR View runs edge to edge after Start AR Camera (#3991). The session hides the status bar, but the tab host still padded it down by the display cutout, so a band of window background ran across the top of the camera: white in light theme, near-black in dark. The host now drops its insets during a live session, and the AR screen's own controls keep clear of the cutout.
  • Demo app: What's new lists only changes you can see in the app (#3992). The sheet used to show every line of the project changelog, including SDK, iOS, release-tooling and QA notes. It now shows only the entries written for demo users (the ones that open with "Demo app:"), without that prefix. The newest block is titled "New in this update" instead of "Included in this preview build".

Docs

  • Node.rotation and Node.worldRotation document their Euler conventions (#3745). rotation is ZYX and reads back what was written. The worldRotation getter is YXZ with the yaw sign negated, even on a node with no parent, while its setter is ZYX. A pinning test records this; behaviour is unchanged because major version 4 is frozen. Compare or copy orientations through quaternion / worldQuaternion.

v4.43.0 — 2026-09-27

Added

  • The demo app opens on a live dusk flight (#3948). The home screen's hero is no longer a still with a turntable behind a card: a low-poly valley slides under the camera at dusk — a periodic flat-shaded heightfield generated on the fly, one warm sun low on the horizon with an emissive disc that bloom and a lens flare bleed from, height fog in the sky's colour, the sunset HDR as image-based light, temporal anti-aliasing, and the Damaged Helmet riding front-right of the camera, turning slowly. The camera flies a lazy S-curve and banks into it; tilting the phone steers the gaze a few degrees. It is all Filament through SceneView (one MeshNode, one SphereNode, one ModelNode) drawn full-bleed under the header and the featured band, from the top edge of the display, with the featured band's first page a transparent window onto it. Nothing composes before the app's first frame is presented; isLowRamDevice gets the same flight at a quarter of the triangles with the Performance preset and no HDR, bloom, fog or TAA; reduced motion holds the opening frame. Two new tokens (hero-sky-*, home-hero-stage-bleed) are documented in DESIGN.md.
  • Demo app: Rerun Debug now draws the AR session on the phone, in 3D, with no computer attached (#3950). A live picture-in-picture under the status card shows what ARCore understood of the room; a tap, or the dock's 3D view, opens it full-screen. A second SceneView on the demo's engine draws the camera's path as a glowing trail with the live frustum at its head, the feature points as a point cloud (the ones seen right now in amber), the planes as tinted polygons (floors blue, walls violet), and every anchor with its placed model — over a floor grid, from a free camera: drag to orbit, pinch to zoom, double-tap or Recenter to hand the framing back. Chips toggle each layer and count what it holds; a timeline scrubs the session and plays it back up to Live. Streaming to the Rerun viewer on a computer is unchanged.

Fixed

  • Scrolling the demo home down and back up no longer reloads the hero, with its loading state and flash (#3949). The 3D hero was a child of the featured band's LazyVerticalGrid item, so leaving the viewport disposed the Filament engine, the model and the animation state, and coming back rebuilt all of it behind a still. The stage is now composed once per screen, as a layer under the grid, and the band's page is a transparent window over it: neither the grid nor the featured pager can dispose it. Off screen the stage's own Lifecycle drops below RESUMED, SceneView parks its loop on the last frame and the flight clock stops; on the way back the very same frame is already there, then the next one — same camera, same time, no loader, no fade. The featured pager's page is hoisted to the screen for the same reason, so it no longer resets to the first page after a scroll.

v4.42.0 — 2026-09-26

Added

  • Flutter: AR tap-to-place with touch manipulation, on Android and iOS (#3780). ARSceneView(placeOnTap: ModelNode(...)) anchors a model wherever the user taps a detected surface, in one line. For finer control, ARSceneView.onPlaneTap delivers an ARHitResult and SceneViewController.placeModel(hit, model) anchors the model there and returns a PlacedModel handle for removePlacedModel. Placed models can be dragged along planes with one finger, rotated with a two-finger twist and scaled with a pinch (0.25x–4x); each gesture can be switched off per model. The model's scale is its largest dimension in metres, and its modelPath can be a URL or a Dart asset key. Android uses ARCore anchors; iOS uses ARKit anchors with RealityKit entity gestures.
  • Flutter (Android): loadModel and initialModels load https:// URLs. The bridge called the asset-only rememberModelInstance overload, so a remote .glb (the demo Viewer and AR tabs use Khronos URLs) silently rendered nothing; it now goes through the URL-capable overload, which still takes the same fast path for plain asset paths.
  • The TV demo now has a real Android TV / Google TV launcher banner. The home row used to show an empty dark card. It now shows the SceneView cube and wordmark on the brand gradient, the same look as the Play feature graphic, and the leanback activity declares the banner too.

Changed

  • Demo app: the AR Recording demo now shows what it records, and replays it in place (#3831). Four mode chips became two steps in the dock, Record and Recordings. While recording, a live card shows the clock, the growing file size, and the frames, distance, surfaces and placements being captured. When a take stops, its first frame, length, size and placements replace the shutter. The Recordings list shows a thumbnail and the contents of each file; tapping one replays it inside the demo with the placed models restored, then sums the take up, including why the camera lost its place when it did. Share, Save to Downloads and Delete live in each row's menu. The settings sheet says what a recording keeps. Every placed model now gets its own instance, so every tap shows one, in both this demo and Rerun Debug.
  • Demo app: Rerun Debug no longer opens on an error-like banner (#3831). A status card says in one sentence what the demo does and turns green, with the count of events sent, once a computer is connected. The connection steps are numbered in the settings sheet, and Save appears only when it can work. The events-per-second figure, which never left zero, now counts.
  • Demo app update prompt (#3916). On Android the Play update offer finally shows: it never appeared because a stalled-update check held the update manager's re-entrancy guard on every resume. It is now a Material 3 snackbar ("Update available · Update", then "Update ready · Restart") above the navigation bar; closing it snoozes the offer for 24 hours. On iOS the App Store prompt is a bottom toast that fits an iPhone SE at every text size, instead of a top banner whose Update button did not fit.
  • Demo apps: renamed "SceneView Demo — SDK samples" and positioned as the SDK showcase. The Play Store title and the App Store name become "SceneView Demo — SDK samples"; the short description, iOS subtitle, keywords, promotional text and full descriptions now describe a set of live SDK samples for developers rather than a consumer 3D viewer. The Android home header reads "SceneView Demo", and the "Open with" label is "Open in SceneView Demo". The "Open in AR" link from the MCP viewer and view.html now names the app it opens, SceneView: 3D & AR Viewer. The App Store name and subtitle are synced from the new name.txt and subtitle.txt on each minor release.
  • Demo cold start: the ~88 refused frames after the home hero loads are the emulator, not the helmet (#3938). Measured with Filament fences on emulator-5554, release build: the first GPU submission of every guest process pays about 1.55 s in the emulator's GLES-over-Metal translator, whatever that submission is (the IBL prefilter with an HDR environment, frame 1 with a KTX one), and it moves with the moment the engine is created. The ubershader compile, the 80 MB of helmet textures, post-processing and the runtime IBL prefilter each fit in about 100 ms there. tools/measure-demo-cold-start.sh now documents how to read its numbers on the emulator; nothing changes in the SDK or the app.
  • Demo app: faster first frame and no blank home hero on cold start (#3940). The home hero's 3D stack is now composed one frame after the app's first frame instead of inside it. On the emulator with a release build, time to first frame went from a median of 233 ms to 146 ms (p90 from 254 ms to 151 ms). The hero also keeps its still image until the helmet's first textured frame is actually presented. Before, the card showed empty and black for about 1.7 s while the GPU finished its first draw. tools/measure-demo-cold-start.sh measures both points.
  • Demo apps: the home-screen label is now "SDK Demo" on Android and iOS. "SceneView Demo" was cut to "SceneView …" by the Pixel launcher, which read as the consumer app SceneView: 3D & AR Viewer. "SDK Demo" fits in full and says what the app is. The in-app header ("SceneView Demo") and the store names ("SceneView Demo — SDK samples") are unchanged.

Fixed

  • Node.onMove(detector, e, worldPosition) and Node.onScale(detector, e, scaleFactor) overrides now actually run during a gesture (#3739). Both are documented, open extension points meant for subclasses to intercept and clamp a drag or pinch — but the internal gesture delegate called its own copy of these functions instead of dispatching through the node, so an override was silently skipped on every real touch. onRotate was unaffected and is unchanged. The sceneview-core applyScaleGesture() helper is no longer used internally — scale clamping has lived in evaluateScaleEdit() since #3699 — and is now @Deprecated (kept for binary compatibility; major version 4 is frozen).
  • Android demo: the Model Viewer's Models sheet shows what it opens (#3828). Every model thumbnail is now a render of the exact bundled GLB through the app's own renderer, on a transparent background that sits on the card fill of both themes; the image-generated ones drew other objects (a green toy soldier for the Soldier). The Soldier also faces the camera when opened, where the viewer used to show its back. The sheet opens on a "Scenes" row — "Park Scene" and "Scene Gallery", each a card with a capture of the scene and one line saying what it is — above the single models, which get the same card and a one-line description each. "Surprise me" lives only in the floating pill over the scene, no longer a second time in the sheet. The sheet lists every bundled model from all three sections (the Park offered the helmet alone, the Gallery had none), and the Multi-Model Scene is titled "Park Scene", the name its card uses.
  • Android demo: on a cold start, status-bar icons switch to light as soon as a demo appears, instead of staying dark over its dark stage while the system splash fades out (#3899). The demo app now uses the androidx SplashScreen API (androidx.core:core-splashscreen): a Theme.SceneViewDemo.Starting launch theme that paints the same splash as before (launcher icon on the surface colour, light and dark), and installSplashScreen() in MainActivity with an exit listener that removes the splash on the app's first frame. On Android 13 and later, the dark-on-dark window shrinks from over a second on a slow start to the system's own two-frame icon-tint transition. Android 12 and 12L keep the system fade.
  • Demo app: the Park scene opens on the whole scene (#3923). The Multi-Model "Park" section used to fill the frame with its tallest model, about 2.1 m away. With a Sketchfab key the camera opened inside the trees; without one, two lanterns filled the screen and the shiba and the soldier were out of view. The camera is now fitted to the formation's bounding box, computed from the slot layout before any model loads, so every model is in view on phones, tablets and in landscape and nothing crosses the near plane. It orbits around the centre of the formation and does not move once the first frame is shown.
  • Flutter: the Android plugin no longer crashes a plain FlutterActivity at launch (#3928). A plain FlutterActivity provides no ViewTreeLifecycleOwner, so the plugin's ComposeView threw ViewTreeLifecycleOwner not found from FlutterView as soon as it was measured. When the host has no lifecycle and saved-state owners, each platform view now brings its own. They are tied to the platform view: resumed from creation, destroyed on dispose() after the Filament engine is torn down. Hosts that have owners, such as FlutterFragmentActivity or any ComponentActivity, keep driving the scene with their own lifecycle. No host app change is needed.
  • Flutter: on Android, setEnvironment now loads Dart asset keys such as 'environments/studio_small.hdr' (#3928). Flutter packs these assets under flutter_assets/, and the plugin used to open the key as-is, which threw FileNotFoundException. Paths that are not Flutter assets, such as native assets, files or URLs, are passed through unchanged.
  • The camera no longer jumps when a model loads (#3931).
  • Camera swaps glide: a cameraManipulator swapped at runtime now glides for 0.6 s from the pose on screen instead of cutting to its own pose.
  • rememberCameraManipulator(orbitRadius = …) follows new values: it is keyed on orbitRadius and targetPosition, so a radius derived from a measured model is applied instead of being ignored.
  • No more huge time steps: the first frame after an idle pause no longer hands the camera the device uptime, and a stalled frame no longer spends an ease in one step. A smooth = true transform issued after an idle period glides again instead of snapping.
  • Auto-centre glides: when a model is added next to one already on screen, the re-centre glides for 0.4 s instead of jumping.
  • Model Viewer demo: the entrance flight no longer skips or restarts, and the camera follows the chrome insets smoothly.
  • Release: the npm publish check now waits up to 5 minutes for the registry to list a new version, instead of 100 seconds. With npm Trusted Publishing, sceneview-mcp and @sceneview-sdk/react-native 4.41.0 took about two minutes to appear, so the release showed a failure even though both versions were published.
  • Flutter: in a plain FlutterActivity, the Android AR session now pauses and resumes with the app (#3934). The platform view's own lifecycle, added in #3930 for hosts that have none, now follows the host activity's pause, stop and resume. The ARCore session no longer keeps running in the background. FlutterFragmentActivity and other ComponentActivity hosts are unchanged.
  • Flutter: on Android, loadModel now loads Dart asset keys such as 'models/helmet.glb' (#3934). Flutter stores these assets under flutter_assets/. The key used to be opened as-is, so no model appeared and nothing was logged. URLs, files and native assets are passed through unchanged.
  • Swapping away from a glTF model that contains cameras no longer crashes (#3937). When rememberModelInstance destroyed the model before its ModelNode, the node's camera children threw IllegalStateException: Entity … does not have a Camera component. In the demo this happened on Model Viewer, Toy Car, then any other model. CameraNode.destroy() now releases the camera by entity and is a no-op when the component is already gone or the node is already destroyed.
  • Android demo: the Play update check no longer holds up the first frame. onResume used to create Play's update manager and ask it for update info on the main thread, which binds to the Play Store; under load that parked the main thread for up to 1.5 s before the home screen appeared. The round-trip now runs on a background thread, starts only after the first frame is drawn, and is skipped for 30 minutes after Play answers "nothing to do". The update snackbar behaves as before.
  • A SceneView on a TextureView (surfaceType = SurfaceType.TextureSurface) no longer freezes after it is resized. Filament rebuilds the swap chain on every TextureView resize and hands back the same surface. SceneView created the new swap chain before destroying the old one, so Android refused the second connection to the window (BufferQueueProducer: already connected, then EGL_BAD_ALLOC) and the view stayed on its last frame. The old swap chain is now destroyed first. This happens on every Android device, not only the emulator. SurfaceType.Surface was not affected by resizes.
  • Demo (Android): tapping Update in the in-app update prompt now shows the download. Before, the snackbar disappeared on tap and nothing appeared on screen until the "Update ready · Restart" prompt, so the tap looked like it had done nothing. The snackbar now stays up and changes in place. It shows "Waiting for Google Play…" while Play's consent screen is open and the download is queued. Then it shows "Downloading update…", with a progress ring and a percentage once Play reports the download size. A download that fails now shows "Update failed" with a Retry button, instead of ending silently. Cancelling Play's consent screen brings back "Update available". Also fixed: a second tap on Update after a cancel no longer reuses the update request Play had already used, which Play refuses. Debug builds can run the whole flow on an emulator with --es update_qa available|cancel|fail.

v4.41.0 — 2026-09-26

Added

  • Website: the home page's "Community & adoption" section shows the number of public GitHub repositories that declare a SceneView dependency and names the teams whose own public code or posts show it, each with a link to that evidence, plus the source and date of every figure. The new SceneView for companies page answers what an evaluating team checks first: license, what the SDK sends over the network, platform maturity, release cadence, versioning, vulnerability reporting and third-party notices.
  • MCP: the view_3d_model viewer offers "Open in AR on your phone" (#3918). For an https model, the inline viewer shows a button that opens the model in AR Model Viewer through https://ar.sceneview.dev/open?url=…: the app when it is installed, the store page otherwise. On a desktop-sized window it also shows a QR code of that link to scan with a phone. The code is drawn in the browser, and no QR service sees the link. The viewer now follows the host's light or dark theme.

Changed

  • Docs no longer call SceneView "the #1 open-source 3D & AR SDK". The claim could not be backed by a source, so it is gone from the docs site metadata, structured data, llms-full.txt and the assistant rule files. The stale "v4.0 Preview" page (still in "design phase" wording) is removed from the docs.
  • parity-manifest.yml now records whether each iOS demo screen matches its Android counterpart (#3907). Before, the manifest only said whether an Android demo id had any iOS destination. Each of the 48 Android rows now also carries its category, the Android and iOS files, one contract line shared by both platforms, a parity status (identical, diverges, missing, fake, broken, not-feasible-on-ios) and one evidence line. Two new lists sit after the rows: iosOnly: for the two iOS scenes with no Android card, and shell: for the app frame (tabs, demo chrome, deep links). Every iOS scene appears exactly once. The rows are the base of the iOS rewrite plan tracked in #3907. check-demo-id-parity.sh does not read the new fields, and its verdict is unchanged.
  • io.github.sceneview:sceneview now ships its Google Play SDK Console verification token (META-INF/io/github/sceneview/sceneview/verification.properties). It lets the maintainers claim the SDK in Play SDK Console, where apps built on SceneView can then receive SDK crash and policy insights. It is a plain Java resource with no runtime effect.

Fixed

  • ViewNode.destroy() now releases its Surface/SurfaceTexture (#3734). They used to be leaked on every destroy — hosting a Composable or an Android View in a ViewNode and destroying it repeatedly (e.g. recomposition, a recycled list of 3D cards) built up native Surface/SurfaceTexture handles with nothing to reclaim them. The release now runs through the same frame-deferred queue as the Texture/Stream (#874), strictly after the Stream reading from them has actually been destroyed, so it can never free them out from under a still-live Stream.
  • iOS demo: every Showcase card now shows a picture of its demo instead of a grey tile with an icon (#3786). Fourteen cards, from Scene Gallery to AR Lighting, had no preview image. Each one now shows a real capture of its own demo, taken on the iOS Simulator and cropped to the card.
  • Demo app: the Geospatial Anchors screen now shows one loader, keeps its status on screen, and every dropped anchor is visible (#3832). The camera start shows one loader. After that, a single status card stays at the bottom of the screen. It shows whether your location is found, how accurate it is (a meter plus "±2.4 m position · ±6° heading"), whether Street View coverage is available, and a hint such as "go outside and pan across buildings" while accuracy is low. The card replaces the second "Starting motion tracking" pill and the readout that was only in Settings. The Drop button is now full width inside the card. An anchor is placed 4 m ahead of the camera (12 m for Rooftop) and faces you, instead of being placed under your own feet. Each anchor gets its own model and a pin above it. A pin also marks the spot while the anchor resolves, so a drop is never invisible. If an anchor lands at street level below you, the card says how far away and how far below it is. A failed drop keeps a red pin, and the card explains why. Terrain, Rooftop and Clear are in the dock. Clear, and leaving the screen, cancel any resolve still in progress.
  • A CylinderNode (or CubeNode, SphereNode, ConeNode, TorusNode, CapsuleNode, PlaneNode) resized after construction with a single materialInstance could render as a single triangle instead of the whole shape (#3855). These constructors merge every raw primitive (sides, caps, faces) into one Filament primitive so a single material covers the whole shape, but resizing (updateGeometry) always re-derived the geometry's raw, un-merged primitive offsets — walking past the one primitive slot Filament actually had and leaving everything but the first, smallest primitive undrawn. The merged mapping is now kept after a resize.
  • Web: the viewer background now shows the exact color you set, so an embed can match its page (#3879). setBackgroundColor went through the tone mapper with the scene, so white rendered beige (#FFFFFF came out as (230, 225, 219)). The background is now painted after tone mapping: #FFFFFF, #EEF0F3 and #0E1218 come out as the same values. Alpha is now optional, and a value below 1 lets the page behind the canvas show through. The default background looks the same as before (#333443). Pages that tuned their input color to compensate for the tone mapper will now see that color as given.
  • sceneview-web — fitToModels(margin) centres the model where it is drawn and keeps the requested margin through the automatic re-framing after a load, and the near/far clip planes now follow the model size, so a 2 cm part no longer disappears behind the near plane (#3880, #3747).
  • iOS demo: the environment chips of the Explore model viewer are readable in light mode again (#3882). Since the iOS 26 Liquid Glass chrome, the viewer's glass panel followed a light system theme and turned near-white under the white chip labels. The panel and the loading card now stay dark glass in both themes, like every other chrome over the 3D stage, and the selected chip is the primary fill under an on-primary label instead of a hardcoded blue.
  • Android demo, Animation & Physics: the clip card and a subject switch now read correctly while a model loads (#3883). In dark mode the clip card now uses the surface-container card colour, so it stands out from the dark loading cover instead of blending into it. Switching subject in Settings now clears the previous model and shows the loading scrim until the new one is ready, instead of leaving the old model on screen with no indicator.
  • Android demo: every "View in 3D" preview opens fully, orbits on any drag and shares the studio look (#3884). The wall TV, feature-comparison helmet and cloud-anchor lantern previews now go through one PlacementPreviewSheet. It opens fully expanded, so the whole stage and "Close preview" are on screen. Its own drag is turned off, because the sheet used to take every vertical drag that started on the stage. The subject is shown in the studio_2k.hdr room and framed by fitOrbitRadius, where the helmet and lantern used to sit on a black stage. The wall TV is also no longer selectable in the preview: SceneView sends a touch on an editable node to that node and never to the camera, so a horizontal drag that started on the TV did nothing.
  • Leaving a 3D scene right after it opens no longer risks an "app not responding" dialog while its environment lighting is released (#3885). rememberEnvironmentLoader released the lighting pre-processing state on the main thread, and that release waited for all the GPU work still queued: the new scene's shaders and the lighting passes of an HDR environment. That work takes several seconds on a slow GPU. The state is now released once the GPU has finished, right away when it is idle. It is always released before the engine is destroyed. An environment loader that never processed an HDR file no longer creates that state just to destroy it.
  • Engine.safeDestroy() no longer destroys the engine twice when a deferred engine teardown is still pending. The pending teardowns it runs first can include the engine's own deferred destroy, which already frees it; safeDestroy() now stops there, and a second call on an already destroyed engine is a no-op.
  • rememberModelInstance documents what it returns while a location switch is loading (#3900). The @return of both overloads said "null while loading", but produceState retains its last value across key changes, so on a switch the previous ModelInstance is returned until the new one is ready and a null-based loading state never fires. The KDoc, llms.txt and the nodes doc now say so, and point to the key(location) { … } workaround used by the Animation & Physics sample. No behaviour change.
  • Licensing and security metadata corrected. The sceneview-web npm package now ships the LICENSE and NOTICE files, as the Apache License 2.0 requires. NOTICE now credits the code and assets derived from Google's Sceneform and ARCore samples, the MIT/ISC-licensed Earcut, Delaunator and twolinks ports (whose full license texts are now in the file headers), states that mcp/ is MIT-licensed, and adds a trademark notice. Asset credits are corrected: Damaged Helmet is now credited to theblueturtle_ (original, CC-BY-NC-4.0) and ctxwing (rebuild, CC-BY-4.0) under CC-BY-NC-4.0, and Lantern is CC0-1.0. Vulnerabilities are now reported privately through GitHub Security Advisories instead of a dead email address, the supported-versions table covers 4.x, and the code of conduct and the CocoaPods podspecs no longer point at dead email addresses.
  • iOS: ARSceneView states its camera background on the view and reports a session that never delivers a frame, instead of leaving the host on a black stage (#3912). The view is now created with cameraMode: .ar and an explicit .cameraFeed() background rather than the process default. A run that gets no ARFrame within 4 s is run once more with a tracking reset; if that gets none either, the view emits ARSessionEvent.failed(ARSceneViewError.noCameraFrames) and the host shows its error state with a retry. The iOS demo maps it to "Camera couldn't start." with "Try again".

v4.40.0 — 2026-09-26

Added

  • Website: open any 3D model from a link at sceneview.github.io/view (#3845). /view?url=<https .glb, .gltf or .3mf> renders the model in the browser with orbit and auto-framing, then offers AR: Scene Viewer on Android, Quick Look on iOS when &usdz= is given, a QR code on desktop, plus "Open in AR Model Viewer" on Android. The file loads straight from its host (which must allow CORS); nothing is uploaded. A .gltf with external buffers and textures is packed into one GLB before loading, and the site's sceneview.js now frames models on portrait screens and scales its near plane, so small parts are no longer clipped.
  • AutoPlacementScene now shows an animated AR coaching overlay by default (coaching = true): a phone-and-surface glyph for starting the camera, scanning a floor or wall, the surface-found beat, limited tracking and looking back. rememberArGuidanceState(placement) exposes the same cue (ArGuidanceCue) and an isCoaching flag so apps can hide their own chrome while the overlay speaks; ARCoachingOverlay can also be used on its own. Motion tokens and the glyph are documented in DESIGN.md; reduced motion is honoured.
  • Placed models now grow in with a short entrance and shrink out on tracking loss, inside the SDK.
  • iOS: AutoPlacementScene(controller:coaching:) turns Apple's ARCoachingOverlayView back on (vertical goal for walls). ARPlacementController.isCoachingActive mirrors Android's isCoaching, and the overlay's Start Over resets the session and re-arms one placement.
  • Fixed: the first untracked frames of a session no longer report a false tracking loss on Android and iOS (AutoPlacementState.hasCameraFrame).
  • Semantic AR haptics, opt-in (#3848). ARHapticFeedback(state) on Android and .arHapticFeedback(controller) on iOS play one haptic per placement moment: placed, selected, snapped to 100 %, scale limit, invalid move, tracking lost (only after tracking was established), recovered, and help cards. SceneViewHaptic.play(ARHapticEvent) plays one directly, and iOS adds prepare(for:).
  • Pinch snaps to 100 % with an elastic rebound (#3848). Within ±4 % of the real-world size, the pinch lands on exactly 100 % with a short damped rebound, on both platforms.

Changed

  • Android and iOS wall demos now place one TV automatically on the first usable vertical plane, without a floor prerequisite, seam guide, placement tap or default D-pad. Both use the shared placement lifecycle, contact-preserving gestures, a 0.3 m preview and sheet-only accessible adjustments. Android forwards ARCore replay datasets.
  • Adds directWallPose, alignment-aware UsableSurfacePolicy.accept, procedural AutoPlacementNode, and accessible AutoPlacementState transforms. Existing manual wall helpers and enum semantics are unchanged. Wall shadow limitations are documented; the demos no longer use a procedural shadow blob.
  • Every placement-bearing demo shares the automatic-placement controller (#3778). Depth Occlusion, People Occlusion, Image Stabilization, Cloud Anchor and Measure drop their bespoke tap-to-place code on Android; Depth Occlusion, People Occlusion and the Recorder do the same on iOS. Placement happens by itself on the first usable surface, the occlusion comparisons preview the same bundled 0.3 m helmet, and toggling a renderer feature keeps the session, the anchor and the placed subject instead of re-placing them. Host, Record and Resolve stay explicit actions; Measure commits its visible centre candidate with one bottom Add point. An unsupported comparison is refused with an honest explanation before any control — or any camera — appears.
  • One iOS placement entry. ar-instant-placement and placement-scene are removed from the Xcode project, the demo collator, the AR and Showcase tabs, the deep-link registry, the parity manifest and every Maestro flow, with no stub left behind: ar-placement is the single placement screen. ar-record-playback becomes a real screen instead of a deep-link placeholder (ar-recording stays an alias) and explains its ARKit + screen-recording requirement up front.
  • Android demo: loading states say what is happening, and their ellipsis moves (#3825). "Surprise me" no longer sits on a static "Finding…": the pill and the Models sheet follow the roll step by step ("Searching Sketchfab…", "Downloading name (3.2 MB)…" with a determinate ring once the size is known, "Opening name from the cache…", "Decoding the model…", "Loading textures…"), and the button stays busy until the new model's textures are in. The Park scene counts its models through fetch and decode, the Gallery names the model it is fetching or decoding, and Cloud Anchors says what hosting and resolving actually do. Every loading line's trailing ellipsis breathes (motion-narration in DESIGN.md, static under reduced motion). Geospatial Anchors no longer shows "Starting camera…" and "Initializing camera…" at once: the camera scrim narrates the camera start, then one pill narrates motion tracking.
  • Demo app: a swipe on the Showcase hero now turns the page (#3829). The featured hero showed three page dots but a horizontal drag rotated the live helmet instead, so the dots promised a gesture the card did not honour. The swipe now belongs to the pager, as on the Play Store and App Store Today carousels; the helmet keeps its slow turntable (stopped under reduced motion) and orbiting stays in the Model Viewer the card opens. The active dot follows the visible card.
  • Demo app: the Showcase is five sections instead of nine, and seven cards stop reusing the helmet render (#3836). Demos are grouped by what they let you do — View in 3D, Create & record, Place in AR, Understand the world, Developer tools — instead of by engine subsystem. Materials, Debug Overlay, Camera & Gestures, Lighting, Lighting Lab, Secondary Camera and Scene to MP4 (caught mid-recording) now show their own result, cropped from real captures, instead of the same helmet render.
  • iOS CI now builds SceneViewSwift and the iOS sample demo with Xcode 27 as a non-blocking canary (#3856). Two new ios.yml jobs run on GitHub's xcode-27 preview image: one runs the package's iOS build, visionOS build and unit tests, the other builds and tests the SwiftUI demo app and compiles its device-only AR code. Xcode 27 and iOS 27 SDK breakage now shows up before any release build moves. Release builds still use Xcode 26.
  • Demo app: Material 3 Expressive loading, button groups and haptics (#3857). Scene and model loading shows the morphing LoadingIndicator, and gallery downloads show a wavy determinate bar. The Explore sources and the Materials and Lighting modes are connected button groups whose buttons change shape when pressed. Toggles and sliders give light haptic ticks.
  • Demo — predictive back on the gallery viewer. The back gesture now follows the finger: the live model shrinks toward its gallery thumbnail and cross-fades into it, then closes on release or springs back on cancel. The transform runs in a graphics layer, so the TextureView scene keeps its size and camera aspect during the gesture (#3861).
  • iOS demo: native Liquid Glass on iOS 26 (#3862).
  • The floating chrome over the 3D stage uses the system glassEffect and keeps its 1 pt border so it stays visible over dark scenes. This covers the dock, the option strip, the title pill and the circle buttons.
  • The dock accent is a primary-tinted .glassProminent button.
  • The settings and "coming soon" sheets are glass on their partial detents, so the scene stays visible behind them.
  • The tab bar uses the Tab API and minimizes on scroll down.
  • Dock, option-strip, accent and Reset haptics use SwiftUI .sensoryFeedback.
  • Below iOS 26 the demo keeps the material stack and themed sheets.
  • Release APKs attach to GitHub releases again (#3878). tag-release.sh now dispatches build-apks.yml on the release tag the same way it already dispatches release.yml, play-store.yml and app-store.yml, and build-apks.yml's upload steps run on that dispatch instead of only on a tag push. No release since v4.34.0 had shipped with attached APKs. ios.yml's concurrency group also switched to the PR-number-keyed rule ci.yml uses, so pushes to main no longer cancel each other's run.

Fixed

  • iOS demo: the About tab has the support card Android has (#3676). One secondary-container card at radius-lg, right under the identity block, with "Donate on Open Collective" as the primary action and GitHub Sponsors as the secondary one. It has no amounts, no tiers and no third link. It replaces the primary-filled "Star on GitHub" capsule, which was a second emphasised surface on a screen that DESIGN.md allows only one, and which linked to the same place as the GitHub row.
  • Fixed a JNI global reference table overflow crash on animated geometry (Lines & Paths demo, #3715). Every VertexBuffer.setBufferAt re-upload built its vertex streams from heap FloatBuffers, which Filament's JNI layer pins with two global references each (the buffer and its backing float[]) until the driver's async copy completes. On a render thread lagging behind a loaded GPU, an animated Tube re-uploading several streams every frame could pile up references faster than they were released and hit ART's 51 200-entry cap. Vertex and index uploads now go through fresh direct buffers instead, which need only one reference each.
  • Android demo app: in dark theme, the Settings sheet (and every other bottom sheet in the app) no longer shows a pale system scrim over the navigation bar, and its content no longer runs under the navigation bar's buttons or gesture pill. Fixed centrally in a shared DemoModalBottomSheet wrapper used by all twelve sheets in the app.
  • The Damaged Helmet stands upright in AR, as it does in the Model Viewer (#3735). A placement correction from #1477 read the helmet's +90° X root rotation as a leftover and undid it with −90° X. That rotation is the file's own Z-up to Y-up conversion, so the helmet was placed on its back, visor to the ceiling, in AR only: "View in AR" put a different pose in the room than the one on screen. Android and iOS both drop the correction. The Android placement seam (DemoMath.placementRotationFor) can now only return a yaw, and the unused PlacementSpec.rotationOverride is removed. A JVM test reads the bundled GLB and checks that AR and the Model Viewer give the helmet the same pose.
  • The App Store archive of the iOS demo failed since 4.39.0: ARExperienceContainer used the iOS-only .navigationBar toolbar placement and UIAccessibility in code also compiled for macOS. Both are now guarded (#3768 follow-up).
  • iOS demo: Showcase card titles and subtitles wrap instead of being cut off (#3786). Each card capped both lines at one line, so "Collision & Hit Test", "Geometry Primitives", "AR Placement Reticle" and most subtitles ended in an ellipsis at the default text size. They now wrap without a line cap, as Android's DemoMediaCard does, and every card in a row stretches to the row's height so the grid stays aligned.
  • iOS demo: Collision & Hit Test shows five distinct, lit shapes that fit in portrait (#3787). The cubes and spheres were unlit, all the same blue, and nearly touching in one row at x = ±0.6, so they read as a single flat block cut off at both screen edges. They now follow Android's two-row zig-zag with visible gaps, use lit PBR in the brand ramp (#005BC1, #6446CD, #A4C1FF, #D2A8FF), and a picked shape turns info orange, grows 15 % and gets a metallic sheen. The hint pill counts the picks ("2 of 5 picked"), like Android's card.
  • iOS demo: shape names are readable, and Image Planes shows real pictures (#3788). Geometry Primitives lays its five shapes out in two rows, so each name can be set almost twice as large, in one light neutral instead of the shape's own colour on the dark stage. Custom Mesh labels are twice their former size. Image Planes hangs six of the app's bundled model portraits, loaded with ImageNode.load, instead of plain coloured squares. Reflection Probes and Shape Extrude drop 3D captions that fell off-screen or vanished against a bright sky, and say the same thing in the hint pill. The hints of all five demos now describe what is on screen instead of naming the API to call; Reflection Probes' middle cube, which sat inside the sphere, moves to a row beneath it.
  • SceneViewSwift: ImageNode.load no longer crashes when called from a SwiftUI .task (#3788). Both overloads are now @MainActor, like ModelNode.load. They used to resume after loading the texture on a background executor and build the plane mesh there, which RealityKit rejects with a main-queue assertion.
  • iOS demo: Explore opens on a catalog whose models render in 3D (#3789).
  • Poly Haven models now open in the viewer, textured. The demo downloads Poly Haven's USD export, a .usdc file plus its textures. It then patches the file so RealityKit uses its preview materials instead of a MaterialX graph it renders flat black, and finds textures that the newer exports reference by absolute path.
  • Without a Sketchfab key, Explore now opens on Poly Haven. With a key, it still opens on Sketchfab.
  • Poly Haven feeds and search leave out models heavier than 64 MB, so no listed model fails after a long download.
  • Icosa is hidden on iOS until SceneViewSwift can load glTF (#3655). Before, every Icosa model ended on a disabled "3D preview coming soon" button. The Android demo keeps Icosa.
  • A Trending or Recent carousel that fails to load, or loads nothing, now keeps its heading and says so, with a Try again button when it failed. Before, it disappeared.
  • iOS demo: two unreadable labels now meet WCAG AA in both themes (#3790). On a device without ARKit world tracking, the AR View tab no longer shows a disabled button at half opacity: "AR not supported on this device" is a status line in on-surface on surface-container-high (15.3:1 light, 13.5:1 dark), with a danger glyph, as on Android. The AR camera button uses primary with on-primary, so its label no longer turns white on light blue in dark mode (1.8:1). The Explore source chips take the home filter chips' chip-* tokens, so the selected source reads at 17.1:1 in light and 10.5:1 in dark instead of 1.8:1.
  • iOS demo: the AR View tab title no longer scrolls under the status bar (#3791). "AR Experiences" is now the tab's navigation title, as on the About tab: it collapses into the inline bar when the list scrolls, so the clock and the Dynamic Island never draw over it. The hero shrinks to Android's compact icon-and-tagline row, and the page sits on the surface token in dark mode instead of system black.
  • Android demo: dragging the Lighting and Lighting Lab camera can no longer push the eye below the floor (#3794). Both demos' orbit already clamped the pitch against Filament's pole-singularity default (clampOrbitEyePitch, #2487), but that bound only guards the literal orbit pole — nothing stopped a steep upward drag from sinking the eye under the floor plane and leaving the screen an empty grey-blue fill. HeroOrbitCameraManipulator now accepts an explicit maxPolarDegrees, and LightingStage.maxOrbitPolarDegrees(radius) derives it geometrically so the eye stays a fixed clearance above the floor at any orbit radius. Azimuth is untouched.
  • Android demo: Picking & Collision now publishes "Scene ready" in QA mode (#3795). The demo held its loading cover up until 18 SceneView frames had been submitted — enough for the floating Compose card to actually draw into its texture — and relied on the card's own auto-rotate to keep those frames flowing under render-on-demand. DemoSettings.qaMode freezes that rotation for deterministic screenshots, so nothing else invalidated the scene: the render loop settled and parked well short of 18 frames, the loading signal never latched, and the "Still loading…" card stayed up indefinitely. The warmup now explicitly requests a frame every tick until the scene is ready, the same renderInvalidator escape hatch the Materials and Model Viewer demos use.
  • Fixed invisible status-bar icons over a dark 3D scene while a bottom sheet is open, light theme (Model Viewer's Lighting and Settings sheets, #3796). ModalBottomSheet opens in its own dialog window, so it derived the status-bar icon appearance from its own (light) theme rather than from what is actually behind the status bar — every demo viewport is a full-bleed dark 3D/AR stage in both themes. In the light theme this requested dark icons, which then sat on the still-dark scene and disappeared. DemoModalBottomSheet now mirrors the host Activity window's own status-bar appearance flag onto the dialog instead of recomputing it from the sheet's theme; the navigation bar, which does sit directly behind the sheet's own themed surface, keeps following the sheet's darkness.
  • Fixed environment names cut to one word and a scroll hint missing in Model Viewer's Lighting sheet (#3797). Environment captions rendered on a single line, so "Chinese Garden" showed as "Chinese", both "Studio" and "Studio Warm" showed as "Studio", and "Outdoor Cloudy" showed as "Outdoor" — two tiles could carry the same caption. The row's viewport also used a plain fillMaxWidth(), which by chance landed a few dp past the last whole tile on this sheet's width, so nothing ever looked cut off even though the row scrolls. Captions now wrap to two lines, and the viewport width is computed from the available width to always end exactly half a tile past the last whole one, so the next environment is always visibly cut — the same scroll hint Sketchfab and Polycam use.
  • Android demo: the Geometry Primitives cluster is centred again, and a drag orbits it in place instead of sliding it across the frame (#3798). The four primitives are authored at z = GeometryLayout.TARGET_Z, but SceneView's default autoCenterContent = true re-centres them onto the world origin — the camera's targetPosition was still set to the authored z, so the orbit pivot sat 1.5 m behind where the cluster actually renders. A drag then orbited the eye around that distant pivot instead of the subject, swinging the cluster across the frame. targetPosition now tracks the origin the content is actually centred on.
  • Opening a 3D scene and leaving it straight away no longer freezes the app (#3799). Two main-thread waits could last as long as the GPU needed to prepare a new scene's shaders, which is several seconds on a slow GPU, and pressing Back during that time led to an "app not responding" dialog. rememberRenderer now destroys its renderer once that work has finished instead of waiting for it on the main thread (right away when there is nothing pending, as before). In the demo app, the loading cover now checks once per frame whether the first frames are drawn instead of blocking the UI until they are.
  • Android: leaving, resizing or recreating a 3D view no longer freezes the app (#3799). Surface detach, surface resize and engine teardown used to wait on the main thread, with no time limit, for Filament to finish all its queued work. When a scene had only just appeared, that included compiling its shaders, which can take several seconds, so the app went ANR. Detach now waits at most 1 s and resize at most 250 ms. rememberEngine destroys the engine as soon as the queued work is done, without blocking the UI thread.
  • Android demo: What's new sheet subtitles no longer cut off mid-sentence (#3800). The "In review" row subtitle was hard-clipped to one line — "Place objects on a floor or mount a" instead of the full sentence — with no ellipsis to signal it was cut. It now wraps to two lines, matching the section's card subtitles, with an ellipsis as a safety net for anything longer still.
  • Android demo: the Animation & Physics clip card no longer covers the back button while the character loads (#3801).
  • Every demo's top overlay now sits below the back button and title pill from the first frame. It used to wait one frame for the row's measured height, and a screen that blocks on a model load kept that first frame on screen for seconds.
  • While the character loads, the card reads "Animation clip · Loading…" with an indeterminate bar instead of "No animation clip available". That message is kept for a model that really has no clip.
  • Switching the subject in Settings now animates the new character. The previous subject's cleanup used to clear the new node right after it was created, leaving it frozen in its rest pose with no clip. While the new subject loads, the screen also stops reading the previous subject's already-released animator.
  • Android demo: Materials and Contact Shadow Preview labels no longer overlap after an orbit drag (#3802). Both demos frame a flat wall of captioned subjects for a roughly head-on view; dragging the camera towards broadside collapsed the on-screen gap between neighbours faster than a fixed-width or fixed-position caption could follow, so Materials' sphere captions overlapped in adjacent pairs and Contact Shadow Preview's "Shadow" / "No shadow" labels merged into "NShadow". Orbit stays completely free in both demos; instead, each caption now fades out smoothly as the camera turns away from front-on (Materials) or hides once the turn would start overlapping its neighbour (Contact Shadow Preview), and reappears as soon as the camera comes back.
  • iOS demo: the About identity block sits flat on the page and uses the launcher icon (#3808). The glass card around the mark, name, version and tagline is gone, so the support card is again the one emphasised surface of the screen. The mark is the app icon at 80 pt with radius-xl corners, the same picture in light and dark, instead of an SF Symbol cube on a gradient tile. The version reads as plain text instead of a material pill. This matches the Android screen (#3564).
  • iOS demo: the AR Lighting card subtitle was API jargon and broke mid-word (#3809). "Compare .mainLight / .fillLight modifier presets" named Swift modifiers instead of describing the scene, and wrapped mid-word once card subtitles started wrapping (#3786). It now reads "Key and fill light presets on one model".
  • Fixed a possible malformed frame (an orphan slider thumb with no track, label or value) and a reproducible ANR in the Animation & Physics demo's Settings sheet, right around model load (#3810). The clip-chip row and the "Scrub pose" / "Blend with a second clip" sliders were raw if blocks inside a column already wrapped in animateContentSize(); animationNames and duration flip from empty/zero to populated in the very recomposition the model finishes loading in, often while the sheet's own open animation is still running. A raw if inserts a fully laid-out block in one frame, so animateContentSize's lookahead pass (sized for the shorter, pre-load column) and the actual pass (sized for the new one) can disagree for a frame. These blocks now use AnimatedVisibility(enter = fadeIn(), exit = fadeOut()), the pattern already used elsewhere in the demo app for the same kind of conditional child, so they compose at their final size from their first frame instead of racing the parent's resize.
  • iOS: testSearchReturnsResults hit the live Sketchfab API and could fail unrelated PRs on a transient TLS error (#3811). The test now stubs the transport with a URLProtocol and a recorded search-response fixture, so it stays hermetic and keeps its parsing/mapping coverage with no network or API key involved. The live round-trip moved to a separate testLiveSearchReturnsResults, opt-in behind SKETCHFAB_LIVE_INTEGRATION_TEST=1 and never set in CI.
  • Android demo: Animation & Physics reworked so every control does something you can see (#3820, #3826). Physics is now a tray of rubber, steel and foam balls. Each material bounces, weighs and rolls differently, with mass-weighted contacts. The material chips, Drop, Tilt and Reset sit on the scene instead of inside the settings sheet. Reset restores the opening shot without rebuilding the scene, so it can no longer flash black. Dropped balls start inside the frame, and the rails hold them at any height, so a ball stays visible after it lands. The camera frames the tray in the band above the controls and can no longer orbit under it. The streamed vases and barrels, which rode invisible sphere colliders, are gone from this screen (the assets stay in the catalogue). Animation opens on the bundled fox, grounded and framed from its measured bounds in a garden environment, with a slow turntable. The soldier is still available. Double Pendulum opens centred on its hinge, with the whole swing in frame, and Release is now on the scene.
  • Android demo: Model Viewer's Recenter, camera-distance slider and "Spin scene" all work correctly (#3821). Recenter and the slider wrote to camera state the OnDemand render loop cannot see on its own — the same class of bug as #3718 — so nothing redrew until an unrelated gesture forced a frame; both now call RenderInvalidator.requestRender(). The slider's displayed value used to read the raw, unclamped distance while the thumb clamped into the valid window, so the two could visibly disagree; both now read the same clamped value. "Spin scene" counter-translated the model's pivot with the wrong sign, so the model visibly swam off-centre as it spun instead of turning cleanly in place.
  • Android demo: back steps out of Model Viewer one level at a time (#3822). Pressing back from "Park scene" (Multi-Model Scene), a swapped-in bundled model such as "Soldier", or "Scene Gallery" used to land directly on the Showcase home instead of the previous view. Model Viewer's Single/Multi/Gallery mode — and, within Single mode, whichever bundled model is currently shown — lives in local Compose state, not on the Android back stack, so the raw exit callback handed down from the nav graph skipped past it. Back now closes any open sheet, then returns to the single-model view (reverting to the default model if a different one was picked), and only exits the demo from there.
  • Android demo: the wall placement flow now tells you what to do about a plain wall (#3823). A flat, textureless wall gives ARCore's vertical-plane search too few feature points to converge, so it can time out without ever finding a surface — the same 10 s "No surface found" card the floor flow shows, but with a tip ("Try a brighter, textured area.") that has nothing to do with a wall. The wall flow's card now points at the wall-floor edge or a wall with a frame or poster instead. Also fixed: the wall preview TV's size was labelled "0.3 m longest dimension" as if it were a real product spec; the label now only says "Preview size", matching how every other unmeasured demo object is labelled.
  • Android demo: the settings sheet no longer hides the scene you are tweaking (#3827). It is now a non-modal sheet that rests at about a third of the window instead of half or full screen, with no dimming scrim, so the scene above it stays visible and touchable while you drag a slider or tap "Release". Drag up for the rest; a close button replaces tapping outside. The sheet and the Model Viewer's Lighting sheet use a translucent glass-sheet fill (surface-container at 88 % light / 90 % dark, solved for text contrast over dark, mid and white scenes). The dock fades out while either sheet is open, so it no longer shows through the glass.
  • AR Placement demo: real thumbnails in the model tray, and a tap-to-reset scale read-out (#3830). The "Pick a model" tray showed a generic cube glyph for every streamed item instead of its real thumbnail, and pinching a model away from its real-world size left no way back to 100% once the fingers lifted. The tray now resolves thumbnails from the resolved asset path instead of gating on the source type, and the scale read-out persists briefly after a pinch and can be tapped to reset the model to its real-world size.
  • Collaborative AR demo no longer shows two black viewports (#3833). Both panes render through a non-AR SceneView (this demo is a single-device simulation by design, not two AR camera sessions) but never passed it a lit environment, so they fell back to SceneView's default neutral IBL + solid black skybox — the same class of bug as #2110 — leaving the metallic shared object with nothing to reflect and the pane reading as broken. Both panes now use the shared rememberModelDemoEnvironment, Bob's pane renders from a different camera pose than Alice's so the two panes read as two distinct viewers, and a "Synced with Bob" pulse now marks each state change instead of a static line that was easy to miss.
  • Android demo, Cloud Anchors: the room-mapping meter no longer appears to lose progress, and its final state reads as success (#3834). Session.estimateFeatureMapQualityForHosting is noisy frame to frame, and the meter tracked it raw, so it could drop from "Good enough" back to "Mapping" mid-scan even though the room was, if anything, better mapped than before. The displayed quality now only ever equals or beats the best reading seen since the anchor was placed (RoomQuality.advancedBy, JVM-tested). "Well mapped" also gets its own success colour instead of sharing the same lavender accent as every other state, so reaching it reads as done rather than as more of the same.
  • Android demo: pill contents, dock ends and on-scene action rows are centred (#3835). GlassSurface now centres its content, so a glass pill raised to the 48 dp touch target (the Model Viewer's "Surprise me" / "Finding…" pill) no longer pins its icon and label to the top 36 dp. The dock's "View in AR" accent is a 40 dp disc in a 48 dp touch target, 12 dp from the dock edge on every side, matching the air at the leading end instead of touching the rounded cap. SceneActionBar centres its buttons under the centred status pill or card, which fixes "Host · Restart" (Cloud Anchors), "Drop here" (Geospatial Anchors) and every other demo using it.
  • Release: the npm publish jobs can use npm Trusted Publishing (#3302). Every npm publish since 4.32.0 failed on the NPM_TOKEN secret (first E404, then EOTP after the 2026-08-26 rotation, because the token does not bypass 2FA), so sceneview-web and @sceneview-sdk/react-native stayed at 4.31.0 and sceneview-mcp at 4.0.16. The three publish jobs in release.yml, and mcp-publish.yml, now request a GitHub OIDC token (id-token: write) and run npm 11.20.0, so they publish without a token once each package lists sceneview/sceneview + release.yml as its trusted publisher on npmjs.com. NODE_AUTH_TOKEN stays as the fallback npm uses when the OIDC exchange is refused.
  • sceneview-mcp: the telemetry endpoint is documented as live, and the last paid-tier wording is gone. The mcp-tools-lab in the telemetry worker's hostname is the Cloudflare account's workers.dev subdomain, not the GitHub org deleted on 2026-08-23; the worker still answers and ingests, so the endpoint is kept and telemetry.ts now says why. The --http startup line reads "remote tool surface" instead of "free tools only", PRIVACY.md no longer points at a "Telemetry (Free Tier)" section that does not exist, and the bot-score comment speaks of usage analytics.
  • Android medium() is stronger than light() and respects Touch feedback (#3848). Presets try View.performHapticFeedback first, then primitive compositions and predefined effects. Vibrations carry touch attributes, so with Touch feedback off nothing vibrates. The docs now say the library declares VIBRATE.
  • Wall placement demo: "View in 3D" shows the TV instead of a black square (#3864). The near-black TV sat 2.8 m away on SceneView's default black backdrop, lit only by the neutral light, so it read as a tiny black rectangle on black. The preview now places the TV in a bright studio room that also lights it, and frames it to fill the preview. A spinner shows while the room loads.
  • Destroying a model while its textures are still decoding no longer crashes (#3868). ModelLoader.destroyModel freed the model without cancelling gltfio's pending texture load, so the next frame's updateLoad() wrote into freed textures (native SIGSEGV / use-after-free abort). rememberModelInstance hit it whenever its key changed right after a load. destroyModel now cancels that model's load first, the next load still completes, and loadModelInstance releases source data on the main thread.

Tests

  • Pull requests stop waiting on test legs that cannot see their change (#3838). render-tests.yml now gates each leg on its own paths. The 24-minute web Playwright job runs only on PRs that touch the web demo; on main and at night the same suite already runs in device-qa.yml's blocking web leg. A web-only PR no longer boots the Android emulator, and on main the Android and iOS legs run only when their platform changed. ios.yml runs the SceneViewSwift package and the iOS sample demo as two parallel jobs instead of back to back. Over the measured week, PRs that trigger render tests went green in 25 minutes median; the same PRs without the skipped legs go green in about 8.
  • CI stops running work that a change cannot affect (#3873). A new commit on a pull request now cancels the previous run of that PR. Before, the stale run went to the end: 52 runs over two weeks. On main, device-qa.yml runs its 25-minute web leg only when the web demo changed; it used to run on every push. The website redeploys only when its sources change, not on every changelog fragment. build-apks.yml no longer rebuilds on each merge the demo APKs that CI already builds. iOS CI no longer runs for sceneview-core changes, which no Swift target links. Together these save about 20 runner-minutes per merged PR, and required checks are unchanged.

Docs

  • The README, llms.txt, module READMEs and website now lead with the AI-first SDK message (#3839). The root README says what SceneView is and who it is for, then gives one minimal snippet per platform. Snippets that did not compile or called missing APIs are fixed: the SwiftUI .contentID for async loads, the two-argument onTapOnPlane, and SceneView.startAR on the web. Stale versions, counts, sizes and links across the public surfaces are corrected. The Maven Central POM_DESCRIPTION is ASCII-only, so it no longer publishes as mojibake.
  • Removed an obsolete internal note about sponsor tiers from branding/.
  • "Runs on": platform coverage backed by real captures, on the README and the website home page. One row per platform with its status, install link, a link to open it, and a capture: Android (Maven Central, Google Play), iOS (Swift Package, App Store) and Web (npm, live web demo). A platform joins only once a capture backs it. The same pass fixes stale platform texts: Desktop is no longer described as a wireframe placeholder (SceneViewer renders through Filament via filament-kmp since v4.33) in ROADMAP.md, docs/platforms.md, samples/MULTIPLATFORM.md and the playground's desktop snippet. The home page Web card says Alpha, not Stable. Flutter and React Native link to pub.dev and npm, and the playground's Flutter and React Native snippets use the published APIs. The /go/ pages no longer say "coming soon" for two store apps that are live, and no longer send desktop visitors to a release page with no downloads. The "Web Demo" card shows a real browser capture instead of an Android screenshot.

v4.39.0 — 2026-09-22

Added

  • ARSceneView.onSessionError { error, arView in }. Additive modifier mirroring onSessionStarted, called on ARKit session failures (camera permission denial included) and on unsupported configurations, so a dead AR screen can explain itself. The console print stays as the fallback.
  • iOS: ARSessionConfiguration and an observable AR session lifecycle (#3768). ARSceneView(configuration:) takes the whole session ask as one Equatable value — mode, plane detection, image database, environment texturing, scene reconstruction, frame semantics — and re-runs ARKit only when that value changes between renders, re-applying it after an interruption. unmetRequirement() answers before the view is mounted (.worldTracking, .faceTracking, .lidar, .frameSemantics); an unsupported ask runs no session and reports ARSceneViewError.unsupported, never a lesser fallback. New modifiers onSessionEvent, onSessionStateChange, onTrackingStateChange and the .arSessionObserver(_:) environment hook expose .started, .firstFrame, tracking changes, interruptions and failures. Every existing initializer and modifier keeps its signature.
  • iOS demo: one AR entry container (#3768). ARExperienceContainer wraps every AR route — AR tab, featured cards, Samples catalog, deep links, the file viewer's View in AR — and owns device capability, camera permission, the "Starting camera…" state and the "Camera couldn't start." retry, with the AR overlay tokens of DESIGN.md. Denied, restricted and unsupported never mount a camera view.
  • Add Android AutoPlacementScene, AutoPlacementState, and grounded AutoPlacementModel, plus iOS AutoPlacementScene and ARPlacementController. Automatic placement consumes one request on the first usable tracked surface, preserves plane and anchor identity, rejects stale asset loads, and exposes tracking recovery and surface-constrained manipulation without a plane grid or reticle. Existing manual-placement APIs keep their behavior.
  • Converge the iOS AR tab, placement catalogue routes, viewer handoff and convenience showcase on one automatic-placement experience, with explicit reset, loading errors, authored-unit preservation for opened files, accessible adjustment controls and a 0.3 m preview for bundled models. Android and iOS use the bundled Toy Car as their shared default.

Changed

  • FogNode is deprecated. RealityKit has no depth-based fog and this node only draws a large translucent sphere: nothing attenuates with distance and the sphere is picked up by automatic content framing. It keeps compiling and behaving for all of 4.x.
  • Android demo — the model is placed on the first surface automatically, no cursor, no tap (#3766). Every canonical placement route (ARPlacementDemo, the AR View tab, the chooser, viewer-to-AR) now stands the armed model on the first usable surface the moment one exists — a tracked, upward horizontal plane inside its polygon, 0.25–3 m away, found by the ray through the viewport centre or, failing that, the nearest visible plane centre. The reticle, the plane grid, the "Tap to place" copy, the instant-mode chooser and the snap-to-plane toggle are gone; a tap on empty space does nothing. One request is consumed by exactly one placement, a tracking interruption never creates a second anchor and never resets to scanning, and an asset that finishes downloading after the user changed model or left the camera is refused rather than placed. Drag keeps the yaw and stays on a usable surface; pinch and twist share a pivot at the model's base; the read-out says "Preview size" unless the size was measured. After 10 s without a surface the screen says "No surface found." with View in 3D / Keep scanning; a placement that cannot re-track within 10 s offers Scan again. Haptics: medium() on placement, selection() on a model swap, warning() once on tracking loss. App-side only — no public SDK API change.
  • iOS: the LiDAR mesh is no longer switched on implicitly (#3768). ARSessionConfiguration.sceneReconstruction defaults to .none; a screen that needs the mesh asks for .mesh or .meshWithClassification and gets an explicit .lidar requirement on a device that lacks it. The AR tab's default placement path no longer fills detected planes.

Fixed

  • PlacementScene can be told about the chrome the host draws over it (#3735). PlacementScene(coaching = true) builds the plane-discovery guide for you, and it called it with no bottomClearance — so the coaching pill anchored one 16 dp gutter off the safe area, underneath whatever dock, toolbar or call-to-action the host had parked down there. The guide has accepted that measurement since #3503 and the app's other AR host has passed it since #3712; the one screen that used the batteries-included entry point was the one with no way to say it. New coachingBottomClearance: Dp parameter, forwarded verbatim to PlaneDiscoveryGuide(bottomClearance = …). To migrate: if you use PlacementScene(coaching = true) and draw your own bottom chrome, pass coachingBottomClearance = <your chrome's height> + <your gutter>; if your camera view is bare, pass nothing and nothing changes.
  • Android demo — the placement demo's coaching pill clears the dock (#3735). PlacementSceneDemo sat the pill inside the dock band on every device, because it is a single PlacementScene call and the parameter above did not exist. It now reads LocalDemoChromeBottomInset — the scaffold's measured dock band, the same source and the same + Space.md arithmetic the tap-to-place screen already used — so both AR screens in the app now anchor their coaching to the same measured line.
  • Android demo — a two-finger twist turns a placed model on the floor instead of tipping it over (#3735). NodeGestureDelegate builds the twist as a rotation about Y and applies it with node.quaternion *= delta — a right-multiplication, so the delta is expressed in the node's own frame. The demo then broke that precondition: the per-asset placement correction that stands the Z-up Khronos helmet upright (Rotation(x = -90f), #1477) was set on the very node the twist edited, which lays that node's local Y flat onto (0, 0, -1). Twisting the helmet pitched it forward rather than turning it. The editable rotation now lives on a bare pivot node that only ever holds a pure yaw, with the model — correction and all — as a non-editable child underneath it; the gesture reaches the pivot through the delegate's existing parent bubbling, movement still reaches the anchor, and pinch stays on the model where the real-world-size percentage is defined. No SDK or public API change: every other bundled model has an identity correction and behaves exactly as before.
  • …and it is fixed in one place, not one screen (#3735). The same pattern — an editable ModelNode carrying a placementRotationFor correction — was also in the Point & Ask demo, latent only because every prop it ships happens to need no correction, so it would have come back the day one did. Both screens now build their placed model through one shared PivotedModelNode, and neither writes a node flag of its own: the flags and the rest rotations come from PlacementHierarchy, a pure object with unit tests pointed at it. The two occlusion demos also carry the helmet correction and are deliberately left alone — their nodes are not editable, so no twist can reach them; they would inherit the fix by going through the same composable on the day they become editable.
  • iOS AR sessions keep their configuration across an interruption. People occlusion, mesh classification and extra plane alignments survive a background → foreground cycle instead of being reset to a stock world-tracking configuration, and ARKit relocalizes into the existing map rather than restarting tracking.
  • The SDK tap recognizer no longer shadows your own. ARSceneView installs its tap recognizer only while an onTapOnPlane callback exists, so a host recognizer on the same view gets the tap.
  • Face tracking fails visibly instead of switching cameras. On a device without a TrueDepth camera, ARSceneView(faceTracking: true) reports ARSceneViewError.faceTrackingUnsupported instead of silently running a rear-camera world-tracking session, and face sessions no longer show the horizontal-plane coaching overlay.
  • The AR exposure post-process can no longer drop frames. A failed filter now passes the unprocessed frame through instead of leaving the destination texture unwritten (a black flicker or frozen black screen), and its CIContext is built once per view instead of once per frame. The parameter is documented for what it is: rendered-frame brightness, not capture exposure.
  • showPlaneOverlay and showCoachingOverlay are reactive. Toggling them from SwiftUI now takes effect on the next render, so hosts no longer need to rekey the view — which restarted the AR session and dropped everything placed in it.
  • ModelNode.scaleToUnits respects the model's existing scale. The correction is multiplied into the current scale instead of replacing it, so assets with authored root scaling come out the requested size, and normalising twice is a no-op. Non-finite or non-positive target sizes are rejected instead of collapsing or mirroring the model.
  • PhysicsNode.applyImpulse applies an impulse. It used addForce, whose effect scaled with frame duration; it now delivers the requested N·s via applyLinearImpulse(_:relativeTo:).
  • VideoNode.load("sample") finds the video. An extensionless name is tried against mp4, mov and m4v in the bundle and on disk; a missing resource says so instead of pointing a player at a nonexistent path, and a failed AVPlayerItem is reported instead of showing a silent black plane.
  • Reference images are tracked by anchor identifier. A target that leaves and re-enters the camera is detected again, and two prints of the same image get two anchors.
  • The iOS demo app no longer darkens the camera feed behind its AR chrome. Every AR screen drew a 160 pt black band across the top of the live feed and a 220 pt one across the bottom. The chrome now grounds each control individually over the camera, so the frame you pointed the phone at stays as bright as it really is.
  • Models placed in AR rest on the surface instead of sinking into it. Every placement bottom-aligns after scaling and casts a grounding shadow, in all the placement demos — not just the opened-file path.
  • A model can no longer appear after you clear the scene or leave it. Placement loads are cancelled and their results discarded, and a cancelled asset request no longer arms a bundled stand-in nobody asked for.
  • Leaving the AR plane demo really ends its session. A retain cycle between the session-delegate forwarder and the AR view kept the camera and plane detection running after the screen was gone.
  • Changing an AR lighting or placement preset no longer restarts the session. Both screens rebuilt the whole ARSceneView on a toggle, losing tracking, the anchor and the loaded model. The lighting demo's custom key light is also aimed at the subject now, instead of carrying no direction at all.
  • Screen recording stops when you leave the demo, and Save to Photos is honest. ReplayKit kept capturing past the screen; saving to Photos moves the file out of the app, and the Share / Save controls now reflect that instead of offering a file that is gone.
  • The video texture demo plays its video. The bundled clip was requested without its file extension, so nothing was ever found and the plane rendered black.
  • The image-tracking demo shows the image it actually tracks, at the physical width it is registered at, with a way to share it to a printer or a second screen — and "Tracking" now follows the anchor frame by frame instead of latching on the first detection.
  • Deep-linked demos can be closed. A demo opened from sceneview://demo/<id> now gets the same Close control and swipe-to-dismiss as one opened from the catalogue.
  • Four demos are named after what they do. "Body Tracker — real-time 91-joint skeleton tracking" reads a single body anchor and is now "Body anchor tracking"; "Augmented Faces" tracks a face anchor with no morphable mesh and is now "Face anchor accessories"; "Texture Streaming" swaps material presets and is now "Material presets"; "Instant Placement" placed through the ordinary raycast and is now "Estimated-plane placement". Deep links are unchanged.
  • The AR screens read in daylight. The "AR requires a physical device" stage, the image-tracking target card and the material-preset controls all drew light-grey text on a near-white ground in light mode; they now use the AR overlay tokens (dark ground, white text) that the rest of the AR chrome already uses.
  • The image-tracking target is a real QR code. The bundled target was a diagonal checkerboard with a single finder pattern — unscannable, and nothing like the "QR code target" the screen promised. It is now a genuine QR code that resolves to the demo's own deep link.
  • A deep link to a removed demo says so. sceneview://demo/fog claimed the demo was "not available in the iOS app yet — open it on Android", and pointed at a Samples tab this app does not have. It now says the demo was removed and offers a way back to the catalogue.
  • The video texture demo's play control sits in the dock instead of a floating circle jammed into the home-indicator corner.
  • The estimated-plane demo no longer hides its own subject. The ARKit coaching overlay covered the camera until a plane converged — which is exactly the window this demo exists to show.
  • iOS demo: the placeholder for the removed Fog demo no longer claims it is gone on every platform — Android still has it.
  • iOS: the AR session delegate is installed before session.run (#3768). The first tracking state and the first frame were reported to nobody when the session started faster than SwiftUI's first update.
  • iOS: cameraExposure no longer overwrites a host-owned post-process (#3768). The view installs its CIColorControls pass once per value and removes only what it installed; with nil it never touches renderCallbacks.postProcess.
  • iOS: the plane overlay rendered as an opaque white slab (#3768). An alpha baked into the material colour combined with an opacity of 1.0 lost its transparency; the overlay now blends at 12 %.
  • The iOS demo's Explore tab never spins forever. Each online feed now gives up after 15 s, and when every feed of the selected catalog fails the tab says "Couldn't reach " with a Try again button above the bundled models, instead of a heading spinner that outlived the app.
  • Seven full-screen demos got their back button. Collision, Debug Overlay, Lighting, PBR Materials, Occlusion Material, Scene Gallery and Material Presets drew their own chrome with no way out; they now use the shared demo scaffold — back button, title pill, dock — like every other demo.
  • Legends and hints read in both themes. Captions that floated bare over the 3D scene (shape, physics, reflection probes, placement reticle preview, double pendulum, collision, materials, scene gallery) are one glass hint component inside the scaffold's scrim, and the light-only panels that turned white in dark mode (lighting, materials, scene gallery, material presets) are gone.
  • AR banners clear the Dynamic Island. The status banners of movable-light, wall-placement, multi-model and ar-cloud-anchor sat under the status bar; they live in the scaffold's bottom cluster now, and the asset-source pill sits in the identity row beside the title.
  • Occlusion Material actually occludes. The invisible plane sat inside the sphere and its material punched black holes through the skybox; it now cuts the sphere's lower third with the skybox off.
  • Depth Collider no longer fakes AR on the simulator. The static floor drawn inside the studio skybox is gone; without a camera the demo shows the same "AR requires a physical device" stage as the other AR demos.
  • Web device-QA — the Models-tab render assert no longer depends on where the model happens to be pointing (#3772). The blocking web leg of the 4.39.0 release gate failed twice on card #11 of the Models tab ("Retro Piano") with canvas appears blank — luminance variance 50.0 <= 64, with nothing changed under samples/web-demo/ or sceneview-web/ since the green 4.38.0 gate. Nothing had regressed: assertRendered sampled a 200 px crop at the canvas centre while the viewer kept auto-rotating, so the assert measured whichever face of the model was turned towards the camera — and the piano's flat back panel fills that crop for part of every sweep. Measured across a full rotation on a software-rasterised context, the centre variance of that model ranges 40 → 1368, the middle card ("Animated Dragon") floors at 63 against a threshold of 64, and "Cyberpunk Character" sits at 0.2 the whole time. The Models-tab assert now samples the full canvas — the region assertRendered already documents for scenes the centre crop misframes — whose floor across every curated model is 387. The 64 threshold is unchanged, here and everywhere else: a genuinely blank canvas still scores under ~20 and still fails.

Removed

  • The Fog demo is gone from the iOS sample app. RealityKit has no depth-based fog, and the screen approximated it with a translucent volume parked in front of the camera — a look-alike, and one the "height fog" tag promised outright. A feature that cannot be done faithfully on a platform is removed rather than faked. sceneview://demo/fog lands on the honest placeholder.

Tests

  • The clearance hand-off is pinned by a differential Robolectric test. PlacementSceneCoachingClearanceTest asserts that omitting the parameter leaves the pill exactly where passing the guide's own 16 dp gutter does (the claim that makes the addition free for existing hosts), and that naming an 80 dp band lifts the pill by exactly the difference — so that double-counting the term, or dropping it, both fail. It composes PlaneDiscoveryGuide rather than PlacementScene, which needs Filament and an ARCore session and cannot be composed on the JVM; the demo screen itself is checked on device.
  • The twist is pinned by pure-JVM tests, in matched pairs (#3735). PlacementRotation expresses the rotation a twist applies in the anchor's frame (current ∘ rest⁻¹) and asserts it is a pure yaw — an asset-agnostic invariant that never needs to know which axis a model was authored up. The same assertions run against a function reproducing the pre-fix composition order, and fail there: three of them, reporting 30° of pitch where the contract wants 0°. Alongside the algebra, three tests pin the split of roles on PlacementHierarchy — the object the composable builds both nodes from — the sharpest being asset-agnostic: whichever of the two nodes accepts rotation must be standing upright at rest. Putting the correction back on the node the twist turns fails it with 90° of tilt. What no JVM test can reach, and what the tests now say so in as many words, is that Filament really builds the two nodes in that relationship: a Filament node needs an engine, so that half is the device pass.
  • The placement decision is pinned on the JVM (#3766). AutoPlacementControllerTest (26) covers exactly one placement across 100 frames and repeated background taps, tracking loss that cannot create another anchor, stale asset tickets refused after a dismissal or a new selection, both 10 s timeouts at exactly 10 s, and every phase transition; UsableSurfaceTest (16) covers the surface predicates, the distance band edges, the fallback ranking and the projection maths. Fourteen Roborazzi goldens photograph scanning, placed, no-surface, tracking-lost and the pinch read-out in light and dark, each asserting the 16 dp dock clearance on the frame it captures. The Maestro ar-view-live flow waits for "Move slowly to find a surface" and has no tap-to-place step.
  • ExploreFeedLoadTests pins the feed deadline and the "unreachable" rule — a stalled feed is abandoned at the deadline, one answering feed is never reported as unreachable, and a rejected key keeps its own banner.

Docs

  • The surface the assistants read knows about it too (#3735). A parameter that only exists in the KDoc is a parameter no generated code will ever pass. llms.txt carries the new signature, a paragraph on when to pass it, and — on the PlaneDiscoveryGuide section, where the gutter is actually spent — the bottomClearance argument it had never shown; the agents/ tap-to-place recipe used to turn coaching = true on with nothing else, which is the defect this issue reports, and now names the band it must clear. Both are compiled by :snippets-check, so the documented call is checked against the real signature rather than proof-read. gpt/knowledge-*.md is generated from llms.txt by tools/generate-gpt-knowledge.js and is regenerated here.

v4.38.0 — 2026-09-19

Added

  • CI now reports when the Android and iOS demo catalogues drift apart (#3650). A new advisory demo-parity job compares both platforms' live demo ids against parity-manifest.yml and annotates the PR with each disagreement — a demo missing from the ledger, a row whose iosStatus no longer matches what iOS actually resolves, a retired id still carrying a row, a section tally that has drifted. It never fails a build: the gap between the two catalogues is the normal state of a port in progress, so the job warns and stays green. check-demo-id-parity.sh is restored from the agent harness deleted in #3244, and parity-manifest.yml no longer claims to be guarded by a CI job that did not exist.
  • The MCP server installs as a Gemini CLI extension and packs as an MCP Bundle (#3725). gemini-extension.json at the repository root lets gemini extensions install https://github.com/sceneview/sceneview register npx -y sceneview-mcp with nothing to paste — the light alternative, an mcpServers block in ~/.gemini/settings.json, is documented next to it because the install clones the whole monorepo. mcp/manifest.json describes the same server in the MCP Bundle format (spec 0.3) for desktop hosts that install from a .mcpb rather than a command line, with the packing sequence — including the npm ci --omit=dev --ignore-scripts step the bundle needs to carry its own node_modules — written down in mcp/README.md.

Changed

  • A SceneView no longer renders every vsync — render-on-demand is the default (#3108). Compose's frame clock does not idle on its own, so a screen showing a static model kept the GPU drawing an identical frame 60 or 120 times a second; on devices whose Choreographer keeps ticking a visually static UI that is battery drain and thermal throttling with nothing to show for it. The new frameRatePolicy parameter defaults to FrameRatePolicy.OnDemand(): the library tracks what makes the picture change — a touch in flight, a camera manipulator still coasting or easing, a playing animation, a smooth transform, a VideoNode or ViewNode whose surface just received a frame, a sorting SplatNode, an unfinished async load, an active surfaceMirrorer, a pending auto-center/auto-fit, a node added or moved, a surface resize, a lifecycle resume — holds the display's full cadence while any of it lasts, then draws a short tail of settle frames and parks. Parked means suspended on the snapshot, not polling: an idle scene schedules no GPU frame and no periodic CPU wake-up, and the apply that publishes the next change resumes the loop directly, so a touch still reaches the screen on the very next vsync. The scene also votes its cadence to the display (Surface.setFrameRate, API 30+, seamless-only on API 31+): the panel maximum while something moves, no preference at rest, so a variable-refresh-rate display can drop to its idle mode.
  • FrameRatePolicy.Continuous() and the maxFps cap cover the cases the default does not. The type asks two independent questions — when may a frame be drawn (the mode) and how fast at most (maxFps) — so the cap is an optional argument of both modes rather than a third case: OnDemand(maxFps = 30) is render-on-demand that never exceeds 30 fps when it does wake, Continuous(maxFps = 30) is a steady 30 fps. Continuous() is the previous behaviour verbatim — a frame every vsync, display-max vote held throughout — for a scene driven by something the library cannot see (an external simulation writing into Filament each frame, a custom Renderer hook, a texture updated off-thread). maxFps presents no faster than that many frames per second and votes for it (never above what the panel can do), for a deliberate cadence such as a 30 fps product turntable on a 120 Hz panel. A cap can only be met on a whole number of vsyncs, so the requested period is rounded up to whole vsyncs of the real display: maxFps = 90 on a 120 Hz panel runs at 60, because 90 is not reachable there and 120 would break the promise. maxFps is either null — the display's own cadence, the default — or strictly positive: OnDemand(0) throws at construction.
  • ARSceneView does not take the parameter, and its loop never parks — a live camera feed is never idle, so there is no idle frame to skip. It gained the other half of the mechanism instead: the GPU submit is now skipped on a vsync where ARCore hands back a duplicate Frame.timestamp and nothing in the virtual scene changed. ARCore returns the same image twice whenever the loop outruns the camera, and without reprojection that is a duplicate pose drawn twice; the scene term is what keeps a node animation or a material swap that lands between two camera images from waiting for the sensor — plainly visible once the sensor drops to 30 fps in low light on a 90 or 120 Hz panel. session.update() still runs every vsync, so tracking, anchors and plane detection are unaffected.
  • A custom CameraManipulator gets one new member to implement, isFrameActive, defaulted to false so existing manipulators still compile. Motion itself needs no declaration — the loop compares the transform it already obtained against the previous frame's, so a fling, a pinch and any manipulator that keeps moving are covered with no second getTransform() call. What it answers for is a manipulator that is waiting: an ease in flight, or a turntable counting down to take the camera back a few seconds after the last gesture. That countdown advances from update(), which only runs while the loop runs, so a manipulator that does not declare it would be parked before its own deadline and never resume. getTransform() is now documented as being called exactly once per tick, by the caller that draws its result.
  • The one case the default cannot see is a mutation made below the library's bookkeeping, and the migration guide lists them exhaustively rather than by example: a Filament MaterialInstance parameter, a light property written through LightManager, a Skybox or IndirectLight assigned onto the Filament Scene, morph weights and bone transforms through RenderableManager, an external Stream pushing frames, and runtime View options on a View you own. Nothing in the scene graph changed, so nothing invalidates. Two escape hatches ship with it: Node.requestRender() for a node you already hold, and rememberRenderInvalidator() + the new renderInvalidator parameter for anywhere else — including before a PixelCopy or screenshot, where you request the frame and then wait for your next onFrame, because the request is fire-and-forget. Call it on the main thread. An invalidator that receives a request before it is attached replays it, so the escape hatch does not depend on composition order.
  • Invalidation now comes from what changed, never from the fact that a recomposition happened. The composable used to end with a blanket SideEffect { requestRender() }, which made every recomposition of the host screen a render request — so a screen that recomposed for an unrelated reason, a fps counter above all, held the scene at full cadence and could never settle. Every Filament write the library makes is now its own push source, including the ones the blanket request had been covering for: Node visibility, RenderableNode geometry and per-primitive material swaps, and every SceneScope DSL block that used to re-apply itself on each recomposition (light colour/intensity/direction, contact shadows, ImageNode and BillboardNode bitmaps — which also re-uploaded their texture every time — TextNode, SplatNode, fog, dynamic sky). DebugStats follows the same rule the other way round: it holds plain fields instead of six per-frame snapshot writes, and DebugOverlay reads them on its own 250 ms tick, where it now reports idle instead of the last frame rate it happened to measure.
  • "Still loading?" is now answered by whether a load was started, not by a progress fraction. Filament's ResourceLoader.asyncGetLoadProgress() returns 0 — not 1 — for a loader that was never asked to load anything, so the progress < 1f term of the active fold read "still loading" for the lifetime of every scene built from geometry and materials rather than from a glTF file: procedural scenes, scenes whose models are already resident, and two of the SDK's own demo screens. Those scenes re-armed the settle budget on every vsync, held full cadence and voted the panel maximum, with a correct picture on screen and nothing to see in a profiler but a redraw of the same frame. ModelLoader.isLoading is the honest form and the one the render loop and ARSceneView now read; ModelLoader.progress keeps its meaning and its KDoc now says what it is not.
  • A paused view withdraws its frame-rate vote. A SceneView that is paused but still attached never reaches surface teardown, so its last vote — the panel maximum, if it paused mid-gesture — used to stand for as long as the app was backgrounded.
  • Android demo — tap-to-place no longer paints theme colours over the camera (#3503). The ar-placement demo and the AR View tab each floated two controls of their own on the camera feed: a back disc (surface at 85 %) and a PlacementModelBar — a primaryContainer extended FAB plus a secondaryContainer reset disc. They were the only surfaces in the app that put a theme colour over a background that has no theme, so what they contrasted against was whatever the room happened to be. Both are deleted. The two surfaces now exit through DemoScaffold's back arrow and act through its dock — Models · Clear · Settings, the same items, in the same order, with the same words, in the same place as every other demo in the app.
  • iOS demo: one DemoScaffold for every demo screen (#3697). The scene, glass back button, title, accessory slot, floating dock and settings sheet now come from a single SwiftUI scaffold that handles the bottom safe area once; the bottom-of-screen dimensions are written in pt in DESIGN.md. Glass chrome gains a ceiling and a 24 % border so it keeps a visible edge over bright media (container vs ground 1.01:1 → ≥ 1.43:1), and the settings sheet rests on the measured height of its controls instead of cutting through them. TextDemo and BillboardDemo are rewritten on it.
  • iOS demo: Image Planes, Geometry Primitives, Custom Mesh and Lines & Paths rebuilt on DemoScaffold (#3705). The four oldest demo screens had no back button or title and a caption that measured 1.3:1 over the studio skybox; each is now a single scaffold call around its SceneView (398 lines → 190), with content at the origin framed by the SDK, a readable hint (14:1) and the shared bottom dock 42 pt above the screen edge. Image Planes loses the backdrop that shrank the gallery to a third of the width, and its arc is symmetric.

Fixed

  • A transform write that changes nothing no longer asks for a frame. A per-frame producer keeps writing after its motion has settled — a physics step re-pushes the resting position of every body, an animation sampler re-pushes the last keyframe — and the node pushed that byte-identical matrix to Filament and notified anyway, so a settled scene never parked. Node now compares the composed matrix against the one it last pushed and returns early when they are equal; a node that really moves is unaffected, down to a micrometre. What the node last pushed is only Filament's state as long as nothing writes the entity behind its back, so every writer that does — a glTF animation applying its keyframes to a ModelNode's sub-nodes, CameraNode.lookAt / modelTransform writing through the Filament Camera — drops that mirror first; the guard can therefore never swallow a write re-asserting a pose the entity has since left (a sub-node frozen on its last keyframe, a camera stuck where lookAt put it). Application code writing TransformManager.setTransform() on a node's entity itself has the same duty, and the new public Node.invalidateTransformCache() is how it discharges it.
  • A ViewNode's hosted view is redrawn into its texture when it changes. The layout is the only place the Android view hierarchy is copied into the node's surface, and a hardware-accelerated child that invalidates only re-records its own display list — the copy never happened again and the quad kept the pixels of its first draw, a stale label that no amount of recomposition could refresh. The layout now marks itself dirty when a descendant invalidates, so the copy happens once per real change to the hosted view: current picture, and still a scene that parks when the view stops drawing.
  • PlaneDiscoveryGuide's message pill now respects window insets, and takes its clearance from the host (#3503). It was anchored 40 dp off the raw window edge with no inset handling of any kind, so on a modern device it sat partly under the gesture bar, and under any bottom chrome the host drew. It now applies safeDrawing and accepts bottomClearance — the room the host's own dock, toolbar or call-to-action takes, which the guide cannot measure for itself. Its side margin moves 24 dp → 16 dp, the Material gutter, so it lines up with the host's grid instead of sitting 8 dp inside it. To migrate: if you host the guide over your own bottom chrome, pass bottomClearance = <your chrome's height> + <your gutter> and delete whatever padding you were using to lift the pill; if you host it over a bare camera, pass nothing.
  • Android demo — one bottom anchor on the AR placement screen, instead of four disagreeing ones (#3503). The coaching line, the plane-discovery pill, the dock and the snackbar each measured from a different edge with a different gutter — 0 dp, 24 dp, 16 dp and 16 dp of side margin, and one of them ignored the navigation bar entirely. Everything the screen says now stacks above one measured line: the bottom of the safe area, plus the dock the scaffold parks there (published as LocalDemoChromeBottomInset, the mirror of the existing top inset), plus one 16 dp gutter. The coaching line is the child nearest the dock, so the transient pills above it grow upward and it no longer hops when you pinch. The scrim under the dock goes 60 % → 68 % black, which takes the dock's 11 sp captions from 4.20:1 to 5.37:1 over a white scene — the top scrim stays at 60 %, because the text it carries lands on the wash directly and already passes. (The dock's captions do not: they sit on the dock's own white-14 % glass fill, which lifts the ground back up before the caption lands on it. That step is what the old "the scrim gives ~5.6:1" note in the token had missed.)
  • Android demo — the model chooser's call-to-action bar is a container you can see (#3503). It was surface on a surface page with no divider, so the catalogue appeared to be sliced by an edge nothing drew. It is now surfaceContainerHigh behind a 1 dp outlineVariant hairline, and the content padding moved inside the scroll so a card row slides under that bar instead of stopping dead against it. surfaceContainerHigh because it is the role every other container in the app already uses, and because it is the lowest role with a tone in both schemes: in the light scheme surfaceContainerLowest, surfaceContainerLow and surfaceContainer are all 0xFFFFFFFF, so the obvious choice would have been visible in dark and invisible in light.
  • Android demo — every surface floating over the camera now has an edge you can actually see (#3503). The 1 dp 24 % white border on the glass chrome measured 1.03:1 against the surface it was supposed to bound, because Modifier.border strokes inside the bounds — on top of the panel's own 14 % white fill — so no opacity could have saved it. It is replaced by Modifier.overMediaEdge(shape), a new over-media-edge token: a 1 dp 36 % white ring straddling the boundary plus a 1 dp 75 % black halo 1 dp further out, both drawn outside the fill, on the media. WCAG 1.4.11 asks 3:1 for the line that identifies a control, and no single colour can hold that over a camera frame — a white wall kills the white ring, a night room kills the black halo — so the two bands carry each other. The floating dock, which had never drawn an edge at all, gets one too. To port the change: replace Modifier.border(width, color, shape) on chrome that sits over the camera or the 3D viewport with Modifier.overMediaEdge(shape), placed before the clip/background in the chain.
  • Double-tap to zoom works under a real finger (#3641). CameraGestureDetector started an orbit on the third ACTION_MOVE of a one-finger stream whatever the distance, and a fingertip reports several sub-pixel moves during any tap — so the second tap of a double-tap called grabBegin, which cancelled the zoom it had just started. adb shell input tap emits no move, which is why every scripted check passed. One finger now has to travel the platform touch slop before it orbits (new CameraGestureDetector.orbitTouchSlop, set by SceneView from ViewConfiguration.scaledTouchSlop; 0f restores the old behaviour), and the orbit grabs where the finger is, so nothing jumps when it begins.
  • Demo cameras no longer jump when their idle animation resumes (#3642, #3640). The auto-orbit shared by the Model Viewer, Lighting, Lighting Lab, Materials, Lines & Paths, Video Recording and Explore viewer screens dropped the user's camera three seconds after a gesture and resumed on its authored pose within one frame. It now takes the camera back on the pose on screen: the turntable carries on from the user's own azimuth, distance and height, and the Materials wall sweep eases back onto its path instead of cutting to it. Pausing and restarting the orbit, or switching model, no longer sends the yaw back to 0° either, and double-tap to zoom now works on those screens.
  • The Android demo is linted in CI again (#3660). lintDebug failed on main with three Compose errors that the "Lint" job never saw, because it only linted the two library modules. The demo module is now part of that job, and the three call sites read observable state: the spatial-audio distance formats through LocalLocale, and the animation clip fallbacks and Point-and-Ask failure copy read LocalResources instead of LocalContext.current — so all three now follow an in-place locale change instead of showing the previous language.
  • Release PRs now actually run their checks (#3664). release-fast.yml pushes the release branch and opens its PR with the default GITHUB_TOKEN, and GitHub emits no push/pull_request events for that token — so the PR opened with zero checks, auto-merge had nothing to wait on, and the tag job timed out after 170 minutes. Three releases out of three died this way; 4.36.0 was never tagged nor published. The workflow now dispatches ci.yml on the release branch explicitly (workflow_dispatch is the one trigger GITHUB_TOKEN can start), with a new force-all input so all nine required contexts appear regardless of the path filter's verdict, and fails loudly within two minutes if the run never materialises.
  • iOS dark mode: containers now have a visible background. The home hero painted the full-screen stage colour (#0B0F16) inside a card, landing at 1.01:1 against the dark page — in light the same card sits at 18.7:1, so the hero simply had no background in dark. Embedded 3D stages now take the elevated container value, and surface-container, chip-bg and the floating surface move off 1.11:1 / 1.09:1 / 1.27:1 to clear 1.25:1 (#22293E, #222831, #2F3549), the floor below which a filled container reads as bare page. Measured on device captures: 26.5% of the dark home body was indistinguishable from the page, now 40.3% of it reads at 1.31:1. Light is byte-identical.
  • Dark mode: containers now read as containers (#3681). The demo app's dark scheme held only three distinct surface tones, so cards, bottom sheets, chips, thumbnails and form fields were drawn within 1.13:1 of the background behind them — visible in light, effectively invisible in dark. The dark surface ramp is now solved for contrast ratio rather than picked by eye (surfaceContainer 1.32:1 against the page, up from 1.11:1), surfaceDim is no longer brighter than surface, and outline goes from 1.50:1 to 6.26:1 so an unfocused search field is a control rather than a rumour. Glass over media goes from 8 % to 14 % fill and 24 % border, because Android has no backdrop blur to do the separating. The light scheme is unchanged except surfaceContainerHighest, which gains a step.
  • iOS demo: every screen using the glass demo chrome crashed on open. #3684 added a View.glassBackground(in:) modifier while a free function of the same name already existed in DemoSheet.swift. Inside a View, the member wins overload resolution, so the seven .background(glassBackground(in:)) call sites — the back button, the identity pill, the model-viewer pills — silently became self.glassBackground(in:): the view drawn as its own background, recursing until the stack guard page (EXC_BAD_ACCESS, GlassCircle._makeView). 25 of the 48 demos, including the hero Model Viewer, closed on the first frame. The free function is gone and every site goes through the one modifier.
  • iOS: glass chrome no longer disappears over dark media. A bare .ultraThinMaterial is a blur of what is behind it, not a colour — over an unlit scene, a dim AR camera feed or the stage-background clear colour it has nothing bright to sample and resolves to nearly black, leaving the control with no background and only its label floating. DESIGN.md already specified the fix (glass-surface fill + 1 pt glass-border, as on Android) but 60 call sites applied the bare material; the contract now lives in one glassBackground(in:) modifier and the 19 chrome sites use it. glass-border goes 8 % → 12 % white, since at 8 % over a dark viewport the edge is not perceptible. Measured dark: About cards 1.274:1 → 1.443:1, AR View glass 1.443:1 and 1.591:1, Showcase unchanged.
  • SceneViewSwift: TextNode.centered() centres the text whatever the call order (#3697). It moved the entity instead of the mesh, so the documented .centered().position(p) left the text uncentred and .position(p).centered() sent it back to the world origin. It now re-centres the mesh on the entity's origin: either order works, and calling it twice changes nothing.
  • Demo cameras no longer cut when their source changes (#3698). SceneView draws whatever cameraManipulator.getTransform() returns, so every demo that handed it a different manipulator (a new remember(key) for a new framing, a scripted/free swap) or made the same one snapTo a start pose was showing a one-frame teleport — up to 260 % of the camera distance and 169° when Animation & Physics entered Reveal, 52° over six rebuilds during the Debug Overlay stress test, 11° on Materials' "Pause the camera". Animation & Physics, Debug Overlay, the Model Viewer park and gallery, the Explore viewer and every screen built by rememberHeroOrbitCameraManipulator (Lighting, Lighting Lab, Lines & Paths, Video Recording) now have a single camera writer, the demo's new ContinuousCameraManipulator: it is remembered once, the actual sources are handed to it, and a change of source is eased from the pose on screen — around the subject rather than through it, carrying the speed the camera had going in — unless the stage was empty, in which case the framing that arrives with a model is still taken at once. Animation & Physics eases into every camera mode, loop restart, subject and field of view; Debug Overlay dollies along the line of sight the user set instead of re-homing every 5 cm; the Model Viewer gallery, the park and the Explore viewer re-fit on rotation with a move. Materials is the exception: the screen keeps the two manipulators its Inspect swap is built on — and the matched cuts of #3624 with them — and its "Pause the camera" is fixed by MaterialStudio's critically damped sweep instead, below.
  • The auto-orbit starts and resumes from rest (#3698). The turntable of the hero-orbit demos (Lighting, Lighting Lab, Lines & Paths, Video Recording, Model Viewer, Materials, Explore viewer) was a wall-clock Animatable read from another coroutine: one frame stale, at full angular speed from its first frame, and jumping by stall × speed after a dropped frame. It is now OrbitSpin, integrated on the render clock inside the manipulator's update, with an eased angular speed that winds down while the user holds the camera and survives a rebuild of the manipulator. Pausing the Materials orbit keeps the angle instead of snapping it back to 0°.
  • Camera moves are timed on the render loop, and the camera loops no longer restart with a cut (#3698). A wall-clock move started on the frame that shows a model is eaten by the frames its upload drops: the Animation & Physics hero opened 43° further round in one picture. ContinuousCameraManipulator now eases on the render loop's clock rather than the wall clock — a tick counts for at most 50 ms, which bounds what a freeze costs the move, though a run of ticks whose frames the renderer refuses still spends it — treats a freeze as a cut — including the step a one-frame-stale source only shows a frame later — and scripted cameras — and the Model Viewer fly-in — wait for their subject and for steady frames (awaitSteadyFrames()) before they move. Animation & Physics' Reveal returns with an authored push-in and Tracking is a there-and-back dolly, so neither loop restarts with a jump; the idle hand-back of the hero orbit is paced by how far it has to travel (at most 45°/s) instead of a fixed 1.2 s; and Materials' Pause the camera glides the wall to its canonical framing — critically damped, from the sweep's own speed — while the sweep itself sets off from rest. OrbitSpin counts a frozen frame as a tenth of a second at most (was a quarter): after a 350 ms freeze the turntable stepped 3°, which the screen's camera then eased as a cut — a 1.5× surge of the orbit for half a second.
  • iOS: the orbit camera no longer jumps when you lift your finger (#3701). A released drag now keeps the finger's speed and coasts to a stop, the turntable picks its speed back up over 0.6 s instead of restarting at full speed on the next frame, a frame that arrives late (a model landing) no longer makes the turntable leap, and a pinch that ends mid-drag no longer replays the whole drag as one step. CameraControls gains endDrag(velocity:), advance(dt:), suspendAutoRotation() and autoRotateResumeDuration.
  • iOS demo: the three tabs share one gutter, one bottom inset and one ground (#3704). About and Explore hardcoded a 16 pt gutter next to Showcase's 20 pt token, and About sat on system black in dark. About's "Star on GitHub" label went from 1.8:1 to 10.5:1, Explore no longer shows a second search field when pushed from Showcase, its category chips stay on one line with 44 pt targets, and the search field contour and placeholder are readable at rest (6.3:1 and 5.7:1 in dark).
  • Web: the orbit camera no longer runs at the display's refresh rate (#3711). OrbitCameraController.update() added one fixed increment per requestAnimationFrame tick — one auto-rotate step, one velocity *= dampingFactor decay — so its "30°/sec ÷ 60fps" default held only on a 60 Hz panel. On a ProMotion Mac in Chrome or a 120 Hz Android display the turntable spun at 60°/s and a released drag's inertia died in half the time; 90 Hz and 144 Hz were off pro rata. Everything self-driven is now integrated against elapsed time, clamped to a 0.05 s step so a tab returning from the background pauses rather than leaping, with the first frame advancing nothing. Breaking, twice: update() takes a deltaSeconds: Double (no defaulted overload — a default would smuggle the 60 Hz assumption back in), and autoRotateSpeed / sv.setAutoRotateSpeed(...) is now radians per second rather than radians per frame, matching iOS CameraControls.autoRotateSpeed in both name and unit — multiply a tuned value by 60. The shipped default is unchanged in effect (30°/s before, 30°/s now) and 60 Hz self-driven motion — turntable and release inertia — is bit-for-bit what it was. The standalone viewer in samples/web-demo, which carries its own orbit camera rather than the module's, had the identical defect and gets the identical fix.
  • Web: a drag now turns the camera by the distance the pointer covered, on any display (#3711). With enableDamping = true the pointer handlers wrote nothing but velocity, so the damping tail that is meant to decay a released flick was applied to the drag in progress as well: the same swipe turned the model ×1.95 as far at 30 Hz and ×0.42 as far at 144 Hz, and a pointer held still kept the camera drifting under the finger. The handlers now apply their delta at pointer time and bank it; update() resamples that travel against the elapsed time, so the gain is exactly pixels × rotateSensitivity at 30, 60, 120 and 144 Hz and the velocity handed to the release inertia is rate-independent in turn. At 60 Hz the drag gain is what it always was; what changes there is that holding the pointer still now holds the camera still.
  • mcp/server.json no longer publishes a version that does not exist (#3725). The MCP registry entry was pinned at 4.0.12 while the package had reached 4.1.0: nothing bumps it — sync-versions.sh excludes mcp/ on purpose and mcp-publish.yml reads package.json — so it drifted silently, and a registry entry naming an unpublished version is an install that fails. It is back in step, and mcp/src/packaging.test.ts now holds all four packaging descriptors (package.json, manifest.json, server.json, gemini-extension.json) to the same name, version and entry point, every assertion comparing two files rather than a file to a literal.
  • The documented install commands named npm packages that do not exist (#3731). npm install @sceneview/sceneview-web returns E404 — that scoped package was never published, and the web SDK ships unscoped as sceneview-web, the name both sceneview-web/package.json and the publish manifest generated by release.yml already carry. The web quickstart, the platform matrix, the module README and the MCP server's get_web_setup tool therefore handed developers and AI assistants a command that cannot work. @sceneview/react-native was broken the same way in the MCP React Native guide, where the published package is @sceneview-sdk/react-native; platform-setup.test.ts had pinned the wrong name, so the suite passed on the broken instruction.
  • The Kotlin/JS Gradle snippet could never resolve (#3731). implementation("@sceneview/sceneview-web") was wrong independently of the package name: a bare string in a jsMain.dependencies block resolves as a Maven coordinate, and sceneview-web has none — it builds a webpack bundle rather than a Kotlin/JS library, as the root README states and the absence of a publish plugin in sceneview-web/build.gradle.kts confirms. It now reads implementation(project(":sceneview-web")), the path the web quickstart already documented and the only one that supplies the io.github.sceneview.web.SceneView API the snippet goes on to import. analyze-project.ts still accepts both spellings when reading a user's package.json, on purpose: a project that followed the old docs has the wrong name written down, and detecting it is the point.
  • Node.worldQuaternion ignores scale again — it had been folding any ancestor's scale into the rotation it reported (#3738). The world cache derived the quaternion with kotlin-math's Mat4.toQuaternion(), which is Shepperd's trace method run straight on the basis: correct only when that basis is orthonormal. A world matrix is T·R·S, so the scale entered the trace and the renormalised result was a different rotation — and not only for a non-uniform scale, which is the part that hid it. A node pitched 90° under a parent scaled 2 reported 106.26°; a tilted axis under a scale of 10 was 32.38° out, and under (0.25, 2, 10), 78.11°. Everything downstream inherited it: billboards under a scaled parent faced the wrong way, lookAt aimed off-target, AR anchor alignment drifted, and node.worldQuaternion = x followed by reading it back returned something else (|dot| = 0.98 under a parent scaled 2). The quaternion read now normalises the basis columns first — the Euler worldRotation getter always did, which is why the two disagreed — and the round trip is exact under any uniform scale. Breaking in behaviour, not in signature: the values you read back change, so application code that was hand-compensating for the old wrong numbers now over-corrects by exactly the amount it used to fix. Two scenes were immune and that is why this lasted: an unscaled hierarchy, and a turntable — a pure yaw keeps the quaternion axis exactly and corrupts only the angle, so an axis-only assertion sees nothing, and a yaw of exactly 0° or 180° comes through untouched.
  • A zero scale no longer produces a nonsense world rotation, and does not produce a NaN either. On a collapsed basis the old trace path stayed finite and returned a silently wrong rotation (12.73° off with one axis gone, up to 180° with three). Normalising the columns is what turns 0 / 0 into NaN, so the fix would have traded a wrong value for a poisoned one — a NaN propagating into every child transform and into any physics or animation driver integrating it, the failure mode the Scale KDoc already warned about for Scale(x = 1f). It is guarded: one collapsed axis is rebuilt from the other two, which recovers the rotation exactly; with two or three axes gone the identity is returned. Finite and meaningful, either way.
  • normalToTangent accepts a normal that is not unit-length. It built its tangent frame with the caller's vector as the forward column, so a normal of length 5 skewed the extracted quaternion by 18.92°. Two of the four in-tree callers can hit that: Geometry encodes vertex.normal exactly as the caller supplied it, and PlaneVisualizerV2's tangent encoder only rejects a near-zero normal (lenSq > 1e-8f) without normalising the rest — so this repairs a live path in the repo, not just a hypothetical external one. On a normal that was already unit the change is near-neutral rather than exactly neutral: over 200 000 random unit normals the largest difference is 0.0685°, and the canonical axes are bit-identical.
  • Two cases that no amount of normalising can fix are now documented on Node.worldQuaternion rather than papered over. A non-uniformly scaled ancestor with a rotation below it shears the world basis: it is no longer a rotation times a per-axis scale, so normalising its columns cannot give back the rotation you set. That normalisation rescales the basis without re-orthogonalising it, so the value you read is a unit rotation carrying no useful error bound — 29.13° off under a parent scaled (3, 1, 1), and up to ~180° in the worst pose under (0.25, 2, 10). An exact answer does exist for this case and is tracked in #3744; until it lands, keep an ancestor's scale uniform where you need exactness below it. A negative scale on an odd number of axes mirrors the basis, which is not a rotation at all: the value stays finite and unit but means nothing, and nothing can detect the case from worldScale, which reports column lengths (a scale of -2 reads back as 2). An even number of negative axes is a real rotation and comes back exact.
  • Web demo: a model framed past the hard-coded far plane rendered an empty canvas (#3742). The standalone viewer in samples/web-demo pushed a fixed near 0.1 / far 1000 frustum, which silently assumes every glTF is a metre or two across. The gallery's "Retro Piano GLB" is authored in millimetres — 2162.33 units across — so auto-framing placed the camera at 3892.20 and put the whole subject behind the far plane: luminance variance 0.2 at all 12 orbit angles, a genuinely blank canvas. It had never shown up because a pre-existing wheel clamp, Math.min(50, …), overrode the framing and dropped the camera inside the mesh, where most angles happen to see geometry and a ~120° arc sees nothing; which of the two the device-QA assertion caught came down to where the turntable stopped. The frustum is now derived from the distance the model was actually framed at, and the zoom window is a multiple of that framing rather than fixed world units, so a model authored in millimetres or centimetres frames and zooms like one in metres. The bounds only ever widen, so nothing that rendered before starts clipping. Zooming also counts as an interaction now: the wheel handler never touched the auto-rotate resume timer, so the turntable restarted three seconds after a zoom and drifted the camera the user had just aimed.
  • Web: a slow renderer turned the orbit camera at a fraction of its stated speed (#3742). #3711 expressed auto-rotation and inertia in seconds rather than frames, then clamped the integration step to 0.05 s — so on any renderer sustaining under 20 fps every frame was truncated and the frame rate crept straight back into the speeds it had just taken out. On a software rasteriser holding ~8 fps the turntable ran at 12°/s instead of its documented 30°/s (one second of frames turned 0.2094 rad instead of 0.5236), and a drag's banked travel was divided by at most 3 reference frames however long the frame really was, so releasing after a 1 s frame coasted 20× too far and releasing after a backgrounded tab launched the model on travel the user never made. OrbitCameraController.MAX_MOTION_STEP is replaced by MAX_FRAME_STEP, now a hitch detector at 0.25 s: every frame at or below it is integrated at its true length, and a longer one pauses the self-driven motion and discards the banked travel instead of crediting it at a made-up rate. The damping tail needs no bound of its own — #3711's closed form is exact for any elapsed time and can never travel more than velocity / (1 - dampingFactor). The demo's own orbit camera carried the identical defect and gets the identical fix; 60 Hz behaviour is unchanged.

Removed

  • SceneView(isRendering:) / Scene(isRendering:) are removed, with no deprecated overload (#3108). isRendering = true becomes frameRatePolicy = FrameRatePolicy.Continuous(). Everything else — the isDirty state, the dirtyToken, the LaunchedEffect { delay(200) } window the old parameter needed you to maintain by hand — is now the library's job: delete it rather than translating it. See the migration guide.

Tests

  • The home goldens no longer break on every release (#3666). HomeScreen read BuildConfig.VERSION_NAME directly to decide which demos carry a "New" / "Updated" chip, so the version bump in each release commit repainted the grid and failed all four HomeScreenSnapshotTest goldens on the release PR itself. The version is now a HomeScreen parameter, defaulting to BuildConfig.VERSION_NAME in the app and pinned in the tests to the newest version the demos declare — the badges are unchanged for users, and the goldens now move only when a demo does.

Docs

  • ARSceneView.updateMode's documentation named the wrong mode (#3713). The KDoc said LATEST_CAMERA_IMAGE blocks until a new camera frame arrives and BLOCKING returns immediately; ARCore is the other way round. Corrected, with a line on why LATEST_CAMERA_IMAGE is the right default for a vsync-driven render loop. Text only — the default has not changed.

v4.37.0 — 2026-09-16

Added

  • iOS demo — Explore searches the online catalog as you type (#3586). A 350 ms debounce with a 2-character minimum, Android parity, and four named states — browse feeds, searching, "no results", and an offline card with a working Retry.
  • Tilt the tray in the animation-physics demo and the balls roll downhill (#3621). A "Tilt with drag" toggle swaps what a one-finger drag does — camera orbit when it is off (unchanged), tipping the tray when it is on — with mirrored Pitch / Roll sliders for precise angles and screen readers, a ±20° clamp and a "Level" button that springs the tray back flat. Under the hood PhysicsBody / PhysicsNode (and the pure-Kotlin PhysicsState in sceneview-core) gained a gravity vector instead of a hardcoded -9.8 m/s² on Y: the tray hangs off one pivot node and the bodies get that same gravity rotated into the tray's frame, so the simulation keeps its flat floor plane and its axis-aligned rails. Bodies no longer fall asleep while gravity has a horizontal component, and the impact counter ignores resting contacts, which a tilted tray otherwise re-triggered on every step.

Changed

  • The AR placement reticle now has three documented states and spends colour only on the last one (#3570). ReticlePhase gains LOCKED: the white hairline ring stays achromatic while searching (35 % opacity, no dot) and on an estimated hit (60 %, small white dot), and the DESIGN.md primary dark-value accent appears only on the smaller centre dot once the hit is on a tracked ARCore plane (90 %) — so the cursor never competes with the model about to be placed.
  • Android demo: "Surprise me" is now a persistent shuffle pill floating over the Model Viewer scene, so another random model is one tap away without opening the Models sheet — matching the iOS viewer (#3585).
  • The splat-preview demo is now Real-World Scan — a real phone capture (three raccoons on a tree stump, 233 808 points, 3.3 MB) opened straight from the file a scanning app exports, with a live points-drawn budget — instead of a procedural rainbow sphere (#3620).
  • Demo app: a live, draggable 3D hero on the home screen that collapses as you scroll, plus a reduce-motion-aware motion pass over the catalogue, tab switches and settings sheets.
  • iOS demo — the home screen is alive. The hero card now renders the bundled Damaged Helmet through SceneViewSwift (RealityKit) instead of a poster frame: a slow turntable on the same studio IBL the demos use, framed in the card's free upper-right corner. Exactly one 3D scene runs at a time — the stage is torn down when the Showcase tab is hidden, the app backgrounds or a demo is presented, so the opened demo owns the GPU alone.
  • iOS demo — motion pass on the catalogue. The hero, the category chips and the cards fan in on ease-expressive (DESIGN.md Motion), the result count updates with contentTransition(.numericText), and every card answers a press with the app's one spring. Under accessibilityReduceMotion the turntable stops and the rise and cascade give way to a plain opacity fade.
  • The demo apps now ship in en-US only (#3661). The apps never had a second translation of their own — no values-<locale>/, no .lproj beyond Base, no string catalog — but they still rendered half-translated on a non-English phone: AndroidX and Material ship their own translations inside their AARs (appcompat alone carries 84 locale folders), so system-provided strings like "Cancel" appeared in the device language while every demo string stayed English. Both native Android demos now set localeFilters += ['en-rUS'], and the built APK's resource table reports no locale qualifier at all. Source copy was also normalized to American spelling — colour → color, centre → center, centimetres → centimeters, Licence → License — across the Android, iOS, Flutter and React Native demos. Locale-sensitive formatting is untouched and still tested: an en-US UI still runs on a French device, where a decimal comma must never reach the screen.

Fixed

  • ar-scene-semantics overlay never appeared, on every device (#3527, #3396). The demo painted the per-pixel semantic raster on a Filament 3D quad parented to the AR camera, but the camera background is deliberately drawn last (ARCameraStream priority 7, so it can early-Z-reject pixels already covered by opaque virtual geometry — #1617) while the overlay material had to disable depth write so UNLABELED pixels could stay transparent instead of punching an opaque hole. With nothing left in the depth buffer for the camera pass to reject against, the camera silently overdrew the overlay every single frame, regardless of device or opacity. The demo now colours the raster into a bitmap and composites it as a Compose Image on top of the ARSceneView, the same architecture ARDepthVisualizationDemo already uses — sidestepping Filament's render-order bookkeeping entirely. The colouring + display-rotation logic is a pure function, SemanticsOverlay.labelBufferToArgb, pinned by JVM tests.
  • Lighting Lab no longer builds the reflection probe's environment before the probe is switched on (#3554). The demo decoded a second 2048² HDR into a full cubemap and specular mip chain as soon as the screen composed, even though the local reflection probe it feeds starts off — making it the only demo holding two cubemap pyramids at once. It is now built the first time the probe is turned on, and without a skybox, since ReflectionProbeNode only ever reads the environment's indirect light and that skybox could never be drawn.
  • iOS demo: Cyberpunk Hovercar had no thumbnail in the Model Viewer list (#3584). ViewerAssetTests now walks ModelViewerDemo's own bundled-model and environment catalogs (instead of a hand-copied duplicate that could silently drift from them) so a future model added without its model_thumb_<asset> tile fails the suite instead of rendering a blank placeholder.
  • model-viewer demo: Recenter jumped the camera to a cold-open swing instead of flying from the current pose (#3622). Tapping Recenter rebuilt EntranceCameraManipulator, which discarded the pose the user had orbited to and replayed the wide, swung-off-axis start reserved for the very first arrival. Recenter now captures the pose actually on screen and flies from there to the resting framing.
  • Materials demo: back now returns to the material wall instead of leaving the sample, and the fly-to-detail camera lands on its final pose instead of cutting to another angle.
  • Physics: restored the pre-gravity JVM descriptors of PhysicsState, PhysicsBody and the PhysicsNode composables as hidden compatibility overloads, so code compiled against 4.36.0 keeps linking instead of failing with NoSuchMethodError.
  • Demo (Android): Back no longer closes the hidden Explore gallery when another tab is on screen — the gallery handler is now scoped to the Showcase tab.
  • Demo (iOS): deleting the Explore search field below two characters now clears the active search, instead of leaving the previous query's results under a field that can no longer produce them.

Tests

  • A changelog.d/ fragment shaped as a paragraph now fails CI instead of shipping an empty "What's new" card (#3616). check-changelog-fragments.sh checks that every non-blank line of a fragment (after stripping HTML comments) is a top-level - bullet or an indented continuation, and that the filename matches <issue-or-pr-number>-<short-slug>.md. Wired into ci.yml as the new changelog-lint job — gated on a changelog.d/** path filter, checkout + bash only — and into the top of collate-changelog.sh, so a direct invocation of the collator gets the same guard. Six fragments merged 2026-09-11 were paragraphs with no bullet marker; they are rewritten in this PR into the expected shape.

v4.36.0 — 2026-09-11

Added

  • Double-tap to zoom in, two-finger tap to zoom out (#3608). The gesture convention of photo viewers and maps, on every Scene / SceneView — animated over 300 ms and clamped by the same distance limits as the pinch. On by default; opt out with CameraGestureDetector.DefaultCameraManipulator.isDoubleTapZoomEnabled = false, and re-tune with doubleTapZoomFactor / doubleTapZoomDurationSeconds. A consumer's own onDoubleTap callback and tap-to-pick still fire — the camera does not steal the event. Custom CameraManipulator implementations opt in by overriding doubleTapZoom(x, y, zoomIn); the step and the easing are public (zoomedDistanceForDoubleTap, animatedZoomDistance) so they do not have to be reimplemented.

Fixed

  • Attaching a node to a live Scene now schedules a frame (#3560). A ModelNode (or any DSL child node) added to an already-running Scene/SceneView is now drawn immediately instead of staying invisible until an unrelated surface resize. Attaching or detaching a node now schedules a frame the same way a resize does, so a render-on-demand scene (isRendering = false) no longer needs a workaround nudge for newly attached content to actually appear.
  • SurfaceMirrorer recordings are no longer uniformly black (#3602). The live viewport no longer goes black while recording either. Mirroring now renders the scene a second time into each mirrored surface's own swap chain, after the scene's own frame has been presented, instead of calling Filament's Renderer.copyFrame in the middle of it — that copy left the window's colour buffer undefined on drivers that discard it once it leaves the EGL draw slot, so both the MP4 and the on-screen frame came out black. Wiring a surfaceMirrorer no longer forces the window swap chain to CONFIG_READABLE.
  • Dokka no longer sees the Android source roots twice under AGP 9 (#3604). The javadoc jar and the API docs build again at release time.
  • The Explore tab's search field no longer breaks the macOS demo build (#3605). textInputAutocapitalization is iOS-only and is now behind an #if os(iOS) guard.
  • Materials demo labels no longer eat camera gestures (#3609). A drag that started on a material name moved nothing. The floating labels were clickable, and a Compose node that accepts pointer input wins the hit test outright, so the SceneView underneath was never offered the gesture. The labels are now pure decoration and the wall orbits from anywhere, labels included. Tapping a sphere no longer teleports to Inspect either: the camera flies onto the picked ball on an eased dolly and flies back out to the wall when you leave Inspect. The sheet and the peek header now say the spheres are tappable, the tap answers with a selection haptic, and the focused label lights up while the camera travels.

v4.35.0 — 2026-09-11

Added

  • SceneView ships as a ChatGPT / Codex plugin, and sceneview-mcp gains a remote transport and an inline 3D viewer (#3470). OpenAI's Plugins Directory — one listing shared by ChatGPT and Codex — takes a manifest, skills and an optional MCP server; the repository is now that package: .codex-plugin/plugin.json points at the three existing skills under agents/ (each with agents/openai.yaml display metadata), .agents/skills/* symlinks let Codex pick the skills up in any checkout, and .agents/plugins/marketplace.json installs the plugin locally. npx sceneview-mcp --http serves the MCP Streamable HTTP transport at /mcp (stateless), /health and OpenAI's /.well-known/openai-apps-challenge domain-verification route; the remote surface lists the free tier only and refuses Pro tool names, so it complies with the directory's no-subscription-promotion rule. The 3D-viewer widget that died with mcp-gateway/ returns inside the free package: view_3d_model renders a public GLB/glTF URL with SceneView.js + Filament.js through the MCP Apps resource ui://widget/3d-viewer.html (text/html;profile=mcp-app, CSP declared in _meta.ui.csp), declared on the tool in tools/list and on the result. Listing copy, starter prompts, the 5 + 3 test cases and the owner gestures live in agents/OPENAI-PLUGIN.md. Motivated by GPT-6 Astra (2026-09-03), which drives 3D tools through computer use and MCP rather than generating assets itself.
  • SceneView opens .3mf — the format AI print flows emit, that nothing on Android or the web could view (#3482). Ask ChatGPT for a 3D print from a drawing and you get a .3mf: an OPC/ZIP package whose 3D/3dmodel.model part is XML with <vertices>, <triangles>, <components> and a <build> plate, in millimetres and Z-up. Until now no Android app and no web page opened one in 3D, let alone in AR. sceneview-core now reads 3MF in pure Kotlin on every platform — no java.util.zip, no XML library, no expect/actual — and converts it to GLB in memory, so the whole existing glTF path (materials, gestures, AR placement, the web viewer) is reused instead of a second loader per renderer. There is no new API to learn on Android: ModelLoader sniffs the payload by its ZIP magic, so rememberModelInstance(modelLoader, uri.toString()), loadModel("print.3mf") and every other entry point already accept a 3MF; a payload that is not a ZIP costs a 4-byte comparison. Conversion scales the file's declared unit to metres (a 60 mm print is life-size in AR without a magic number), rotates the printer's Z-up to glTF's Y-up so the part stands up instead of lying on its back, and gives every face its own normal — flat shading is what a printed part looks like, and a smoothed normal would round over the facets the slicer will extrude. <basematerials> and the materials extension's <colorgroup> become one glTF material per colour, per object and per triangle, all doubleSided because generated meshes are often inconsistently wound. Unrecognised 3MF extensions (slice, beamlattice, production) are skipped rather than rejected: an unknown extension must not stop a print from being previewed. For a custom pipeline, ThreeMfLoader.parse() returns the file's own objects, meshes and build items, and ThreeMfLoader.toGlb() / isThreeMf() are public in sceneview-core.
  • The web opens a .3mf too — in the viewer, on /open, and inside ChatGPT (#3482). The 3MF reader that landed in sceneview-core is pure Kotlin and already compiled for Kotlin/JS, so closing the gap on the web is plumbing, not a second parser. sceneview-web's loadModel(url) now sniffs what it fetched and converts a 3MF to GLB before Filament.js sees it: sceneview.modelViewer("canvas", "print.3mf") works with no new call, exactly as ModelLoader does on Android, and a payload without the ZIP magic is passed through as the same ArrayBuffer instance rather than copied. Two functions are added to the sceneview namespace for pages that hold the bytes instead of a URL — a dropped file, a fetch the page made itself: sceneview.isThreeMf(bytes) (cheap, never throws) and sceneview.threeMfToGlb(bytes) (a Uint8Array GLB, throws on an unreadable 3MF).
  • sceneview.github.io/open renders a printable model instead of dead-ending on it. The page the app's verified link already points at now accepts ?url=<model> and a .3mf dropped anywhere on it, converts it in the browser and shows it on a SceneView stage — the answer to "ChatGPT just made me a .3mf, now what?" on a desktop with no app installed. ?demo=<id> keeps its existing deep-link and QR behaviour untouched. The converter is fetched only once a payload actually starts with the ZIP magic, so a .glb link costs nothing extra.
  • view_3d_model previews a .3mf in ChatGPT. The MCP tool and its widget accept .3mf / model/3mf alongside glTF and GLB, so the assistant can show the print it just generated. The widget converts in the browser through the same compiled core and labels the format pill 3MF; a .glb or .gltf URL short-circuits before any extra fetch or download.
  • The demo app is now an "Open with" target for .3mf, .glb and .gltf — a print shared out of ChatGPT opens in 3D, then in AR (#3482). Reading 3MF in the SDK only matters if a file can reach it: on Android a .3mf sitting in Downloads or arriving from a chat has nowhere to go, because no installed app claims it. The demo now declares ACTION_VIEW and ACTION_SEND filters for all three formats and appears in the chooser, so the file lands in the viewer under its own name and the dock's View in AR carries it through to placement at its real printed size. Android's file typing is unreliable, so the file's own bytes decide. Measured on an emulator: a .3mf arriving through the share sheet has application/octet-stream as its type and no queryable display name at all, so both metadata signals are blank and a metadata-only check refuses a file the SDK reads perfectly. Incoming files are therefore sniffed — glTF magic, and for a ZIP the SDK's own ThreeMfLoader.isThreeMf, so a .docx or a .jar is not claimed just for starting with PK — with the declared name and MIME kept as the fallback. The file is copied into the app's cache before use: a content:// read grant is scoped to the launching intent and would expire under the viewer → AR navigation, and only the previous file is kept. Let the Android demo open STL, OBJ, and PLY files from file managers and share sheets. Add a pure-Kotlin ASCII and binary PLY loader with normals, vertex colours, polygon triangulation, and in-memory GLB conversion on Android, Apple, and web targets.
  • The iOS demo app opens 3D files from Files, Mail, Messages and any share sheet (#3492). .stl, .obj, .ply, .3mf, .usdz and .reality are declared in Info.plist, so SceneView shows up in the "Open with" list for files iOS otherwise has nothing to open — Quick Look reads USDZ and Reality only, and an STL or a 3MF that arrives by AirDrop or comes down from a marketplace is a dead end on a stock iPhone. LSSupportsOpeningDocumentsInPlace means the system hands over the original file instead of copying a 500 MB scan into the app's Inbox.
  • The opened file answers "how big is this, really?" The viewer shows the size in the file's own unit and in centimetres, plus the triangle count. The formats that carry no unit — STL, OBJ, PLY — get a mm / cm / in / m picker under the readout, because they store bare numbers and guessing silently is what puts a 21 cm print in the room at 210 m. 3MF and USD state their own unit and get no picker. View in AR places the model at that real size, bottom-aligned on the detected plane — not shrunk to a tidy preview, which would answer a different question than the one the file was opened to ask.
  • SceneViewSwift reads 3MF — the format 3D printing standardised on and Apple never shipped a reader for (#3492). MDLAsset reads OBJ, STL, PLY and USD; Quick Look reads USDZ and Reality; nothing on the platform opens a .3mf, which is what a Bambu, Prusa or Orca user is handed and what MakerWorld hands back. ModelNode.load(contentsOf:) now opens one like any other file, and ThreeMFDocument is available directly for apps that want the geometry without a scene.
  • The parser covers what slicers actually write, not just the happy path: <model unit> in every unit the core spec defines, <mesh>, <components> composed through their row-vector transforms, <build><item> placements, <basematerials> and the materials extension's <colorgroup> resolved per triangle so a multi-colour print comes back multi-colour, and Production-extension p:path references into other .model parts of the package — which is how Bambu Studio and Orca write project files, and the reason a reader that only ever opens 3D/3dmodel.model shows an empty plate for half the files in circulation. The root part is found through _rels/.rels rather than assumed. A package whose <build> is empty falls back to its objects instead of rendering nothing. 3MF declares its own unit, so it needs no unit: argument — passing one overrides the file.
  • A 3MF is an untrusted file, and it is opened like one. The ZIP reader is read-only, resolves entries by their exact recorded name (there is no path joining, so ../ matches nothing rather than escaping), and refuses to inflate an entry over 256 MB. The XML parser refuses external entities outright (externalEntityResolvingPolicy = .never) — without that, opening a file received by AirDrop or email is a file-read primitive. An object that contains itself is reported, not recursed into.
  • SceneViewSwift opens STL, OBJ and PLY, at real-world size (#3492). ModelNode.load was USDZ and Reality only, which meant the Apple side could not open the files people actually receive — a slicer's .stl, a scanner's .ply, a marketplace's .obj. It now reads all three through ModelIO and is one entry point for every format: ModelFormat.sniff(contentsOf:) reads the file's own bytes before believing its extension, because a model that arrived through a share sheet, AirDrop or a download routinely has the wrong one. ModelLoadingError.unsupportedFormat carries the extension the user tried, so a viewer can say which format it was handed instead of "load failed".
  • A unit, because these formats do not have one. STL, OBJ and PLY store bare numbers: the same coordinates mean millimetres out of a slicer and metres out of a photogrammetry pipeline, and RealityKit is metric — so loading either without saying which is meant puts a 21 cm print in the room at 210 m. ModelNode.load(contentsOf:unit:) takes a ModelUnit (µm / mm / cm / in / ft / m), defaulting to millimetres for STL and metres for OBJ and PLY, and bakes the conversion into the vertex positions so a caller's later .scale(_:) is theirs alone. ModelFormat.carriesUnit is false for exactly those three — the signal to offer a unit picker rather than guess.
  • MeshAsset — geometry you can measure before you render it. Parsing produces plain Swift arrays with no RealityKit dependency, so MeshAsset.load(contentsOf:) runs off the main actor and answers "how big is this, really?" (boundsInMeters, triangleCount) before an entity exists. ModelNode(_ asset:) turns it into a ModelEntity with one physically-based material per part; OBJ .mtl base colour, metallic and roughness are carried across, quad faces are triangulated, and PLY per-vertex colours survive on MeshGeometry.colors (averaged into the material tint, since RealityKit's MeshDescriptor has no vertex-colour channel).
  • mcp/src/guides.ts is under test (#3506). It was the last substantial module in mcp/src/ with no test file: 655 lines of static content that four tools return verbatim, with nothing pinning it. 18 cases now cover the BEST_PRACTICES key set and the guarantee that all still contains every topic body, the absence of uninterpolated module constants, every Maven and SPM coordinate carrying LATEST_SCENEVIEW_RELEASE instead of a literal, the platform rows and the Filament/RealityKit split, balanced code fences, and the Upcoming-version rule that caught the rot above.
  • sceneview-mcp now has a hosted endpoint, so Claude, ChatGPT and anything else that speaks remote MCP can reach it without installing anything. Until now the server only existed as a local process: npx sceneview-mcp over stdio, or --http on a port you hosted yourself. Nothing was deployed, which meant every remote-MCP surface — a Claude custom connector, the OpenAI API mcp tool — was out of reach no matter how complete the protocol work was. A Cloudflare Worker now runs that same server publicly: POST /mcp speaks Streamable HTTP, GET /health reports the version, and the 29 free tools are listed and callable anonymously with no key and no account. The Worker is 15 lines, because it does not reimplement anything — Cloudflare's httpServerHandler runs the existing node:http listener from --http verbatim, so the hosted endpoint and the local one are the same code path and a bug fixed in one is fixed in both.
  • Every tool now declares a human-readable title. 31 of the 32 tools shipped with readOnlyHint / openWorldHint / destructiveHint but no title, so clients that show a tool picker had nothing to show but the raw function name — and Anthropic's connectors directory rejects a server whose tools lack one. view_3d_model also stopped describing itself as rendering "inline in ChatGPT": it renders inline in whatever conversation is hosting it, and a host reading its own description should not be told it is a different product. A contract test now fails the build if a tool is added without a title or without its behaviour hints.
  • The hosted MCP server has a permanent address of its own — https://mcp.sceneview.dev/mcp — and a step-by-step way to add it to claude.ai in one paste. The Worker shipped in the previous release answered on a Cloudflare-generated *.workers.dev hostname, which is fine for a smoke test and wrong for anything a user is asked to keep: the URL a connector is added with is effectively permanent, because re-pointing it later forces every connected user to disconnect and re-add. mcp.sceneview.dev is now bound as a Cloudflare custom domain on the same zone as quota.sceneview.dev, with Cloudflare owning the DNS record and the certificate, so the address the docs publish is the address that stays.

mcp/README.md gains a Use as a Claude connector section and llms.txt a matching entry: the URL, the exact path through claude.ai (Settings → Connectors → Add custom connector), and what the endpoint is — authless and read-only, no account, no API key, nothing of yours stored, since every tool is a pure function of the SDK's own docs, samples and API surface. It also says plainly when not to use it: analyze_project reads a project from disk and search_models / generate_3d_model want your own API keys, and a shared anonymous endpoint can offer neither — that is what npx sceneview-mcp over stdio is still for. The deployment test that asserted the domain stayed unbound now asserts the binding instead, so the route cannot be dropped from wrangler.toml unnoticed. - The Showcase tells you what is new, and its featured banner scrolls (#3566, #3567). Nothing on the grid said which of the 49 samples had changed since the last release, so a returning user — the maintainer included — had no way to know which feature was worth opening again. Two optional fields on DemoEntry, sinceVersion and updatedIn, are now declared in each demo's own fragment (the file a PR already edits, so no shared registry to conflict on) and compared against BuildConfig.VERSION_NAME: a demo carries a New or Updated chip on its card for one minor version, then goes quiet on its own. updatedIn means user-visible behaviour changed — a refactor or a lint fix does not move it, or half the grid would wear a badge permanently and the badge would mean nothing. Seven demos are marked for 4.35. The static hero at the top of the screen became a HorizontalPager with a dot indicator: it looked like a carousel and swiping it did nothing. Its first page is "New in 4.35 — 7 samples are new or updated", which opens the what's-new sheet; the three that follow open Model Viewer, Materials and Lighting. - Added unit coverage for the shared MCP deprecated API and missing import checks.

Changed

  • The Android demo catalogue is now nine named sections instead of one flat run of cards (#2239). 53 demos rendered as a single uninterrupted grid, 33 of them behind one "Augmented Reality" chip — so nothing told a scrolling thumb where one subject ended and the next began, and demos that belong together read as scattered even when they were adjacent. The grid now draws a full-span header per section and the chips filter down to one: Viewer · Geometry & Materials · Rendering · Interaction · AR Placement · AR Tracking · AR Understanding · AR Anchors · Platform. AR is four sections because AR is four ARCore API families — placing content, tracking a subject, reading the room, and anchors that outlive the frame — not one. Three merges land with it, each folding demos that were the same SDK surface cut in half: fog became Lighting Lab's fifth mode (both are per-Filament-View option objects reached through the same rememberView handle), gesture-feedback-preview became Camera & Gestures' third mode (the Gestures mode flipped isEditable and drew no affordance; the preview drew the affordance with no controls), and ar-terrain + ar-rooftop became one Geospatial Anchors card with Terrain and Rooftop modes — they were 303 identical lines out of ~485, differing only in whether the resolve call names altitudeAboveTerrain or altitudeAboveRooftop. 53 cards → 50. Every retired id keeps deep-linking: sceneview://demo/fog opens Lighting Lab on the Fog mode, and the same for the other three, so no QR code, doc link or Maestro leg lost its target or its coverage.
  • The build moved to Android Gradle Plugin 9.4.0 on Gradle 9.7.1 (#3440). Two dependency bumps could not be merged at any version of themselves: androidx.navigation:navigation-compose 2.10.0 (#3416) and org.jetbrains.compose 1.12.0 (#3418) both declare an AAR-metadata floor of "Android Gradle plugin 9.1.0 or higher", and the repo was on 8.13.2 — checkDebugAarMetadata failed no matter how often Dependabot rebased. Both bumps ride along in this change, so consumers get Compose Multiplatform 1.12.0 and the current navigation artifact.

For SDK consumers the published artifacts are unchanged — same coordinates, same POMs, same public API (apiCheck passes against the committed .api dumps untouched). What changes is what it takes to build the repo: Gradle 9.7.1, since Gradle 9.6 removed the internal API AGP 8 relied on, and AGP 9's own floor is Gradle 9.

Three AGP 9 behaviours needed handling rather than accepting:

  • AGP 9 ships Kotlin support built in and drops the org.jetbrains.kotlin.android plugin. Adopting it compiles fine and silently deletes two gates: built-in Kotlin applies no Kotlin plugin id, and both binary-compatibility-validator and this repo's detekt wiring key on those ids. On that branch ./gradlew :sceneview:tasks --all listed zero api* and zero detekt* tasks while ./gradlew apiCheck still exited 0. The build therefore keeps the Kotlin Gradle plugin via android.builtInKotlin=false + android.newDsl=false, each carrying its removal condition in gradle.properties.
  • AGP 9 only creates host unit tests for the default variant, so :samples:android-demo:testReleaseUnitTest — the task #3410 added because 13 compose-rule tests once failed on release while passing on debug — stopped existing. It is restored explicitly through the variant API.

  • AGP 9 enforces unique library namespaces, and androidx.test.espresso:espresso-core and espresso-idling-resource both declare androidx.test.espresso at 3.2.0 — the version Flutter's integration_test plugin pins through espresso-core:3.2+. That broke flutter build apk --debug for the Flutter demo, which is fixed by forcing both artifacts to 3.7.0 (Espresso split the two namespaces in 3.5.1) in samples/flutter-demo/android/app/build.gradle.kts, with its removal condition in place.

android.suppressUnsupportedCompileSdk=37.0, added by #3439 as a temporary silencer, is gone from the repo root and from the React Native compile harness: AGP 9.4.0 supports compileSdk 37 officially, so an "unsupported compile SDK" warning is a real signal again. - The eight remaining helmet cards in the Android demo now show the helmet the app really loads (#3454). #3438 fixed model-viewer and the HomeHero banner, but lighting, lighting-lab, fog, camera-gestures, materials, debug-overlay, video-recording and secondary-camera were still generated from tools/demo-previews/refs/hero.webp — a stylised rusty helmet that khronos_damaged_helmet.glb does not render, even though all ten cards open the same GLB. Their prompts now point at refs/damaged_helmet.webp, the reference cropped from the modelviewer_default render golden, and name the model's real features (teal-green glass visor, cyan HUD ring, orange triangle marker, scuffed off-white plates, gold-brass jaw), so the sixteen regenerated images carry the same helmet, field, key light and contact shadow as the cards around them. Each card also states its own effect more plainly than before — the three coloured lights of lighting and the fog gradient of fog were barely readable, and camera-gestures no longer draws written labels over the scene. - One QA state-pin extra instead of two (#3455). #3449 and #3451 each landed an intent extra that pins an Android demo into a named UI state so the emulator, which has neither ARCore nor AICore (#2754), can capture every state: qa_ask_state for Point & Ask, ungated, and qa_state for Cloud Anchors, gated on qa_mode. They are now the same seam. --ez qa_mode true --es qa_state <id> carries every demo's vocabulary — Point & Ask reads its eleven card ids (checking … failed-persistent) through it, Cloud Anchors its fourteen scenarios — and each demo ignores the ids it does not know. The gate lives in one pure function, DeepLinkRouter.resolveQaState, which returns null unless qa_mode is on; a unit test pins both the accepted and the ignored path, and Point & Ask now also drops its pin at runtime when QA mode is toggled off from the sheet, as Cloud Anchors already did. State ids are unchanged, so an existing capture command only renames the extra. qa_ask_state is gone; the demo README lists both id sets next to the one command that uses them. - Scene Mesh and Streetscape Geometry are one card — "Scene Geometry", with Mesh and Streetscape modes (#3463). They were 246 identical lines out of 445 and — more to the point — the same primary API: both enable Config.StreetscapeGeometryMode.ENABLED and read frame.getUpdatedTrackables(StreetscapeGeometry::class.java). The whole difference is which node consumes the trackable: SceneMeshNode, which colour-codes each geometry by its MeshClassification (the enum that gives ARKit ARMeshAnchor parity), or the raw StreetscapeGeometryNode it subclasses. That is a toggle, and shipping it as two cards taught the reader that ARCore has two scene-geometry APIs when it has one with an optional classification layer. Both capabilities are intact — the classified colour map and its legend on Mesh, the single-material raw mesh on Streetscape — and each mode keeps its own ARSceneView and rememberEngine, so switching tears the inactive ARCore session and Filament engine down completely. 50 cards → 49. sceneview://demo/ar-streetscape still opens the card on the Streetscape mode, so no QR code, doc link or Maestro leg lost its target or its coverage; the card carries the KnownIssue badge the Streetscape half already had, because both modes still need an outdoor location with Street View coverage and an ARCore Cloud API key. - ChatGPT / Codex plugin: the listing logo is the 512 px export, and the submission packet records the Codex install that was actually exercised (#3470). .codex-plugin/plugin.json now points logo at branding/exports/logo/logo-512.png instead of the 192 px favicon, and agents/OPENAI-PLUGIN.md replaces "codex is not installed" with the verified codex plugin marketplace add / codex plugin add sceneview@sceneview-local flow (Codex CLI 0.149.0), including the two gotchas it surfaced: relative marketplace paths do not resolve, and the installed copy drops the .agents/skills symlinks, so skills resolve through the manifest path only. - The installable SceneView skill now says that .3mf needs no special handling (#3482). The SDK reading 3MF is only half the job: an AI that has not been told will invent a branch, a format check or a "3MF support" flag before calling the loader, and worse, will decide the format from the file extension or the MIME type — which Android does not reliably report. SKILL.md gains that as a critical rule, references/cheatsheet.md states it on the two loader rows an AI actually reads, and references/recipes.md gains a worked recipe whose Kotlin is compiled by :snippets-check like every other snippet in the reference set. - The Codex delegation script now pins the model it asks for (#3494). .claude/scripts/codex-delegate.sh never passed -m, so it inherited whatever the installed Codex CLI treats as its default. Codex CLI 0.153.4 makes gpt-6-astra that default: updating the CLI would have moved every delegation onto a scarcer allowance with no visible trace beyond the model line in each run header. The script now always passes the model explicitly — gpt-5.6-sol by default, overridable with CODEX_DELEGATE_MODEL or per call with --model — and routes the same choice into codex review, which accepts no -m, through -c model="...". A new --effort reaches model_reasoning_effort, previously unreachable through the script. CLAUDE.md records the routing policy that goes with it. - The Android demo's Materials section is rebuilt as a lit PBR studio (#3495). It used to be a single sphere with a row of sliders. It is now a nine-material gallery staged on a studio wall — Chrome · Gold · Copper · Brushed Aluminium · Glazed Ceramic · Car Paint · Velvet · Crystal · Signature Glow — under a swept key light, with three modes reachable from the dock: Gallery (tap any sphere), Inspect (one hero, live sliders, the glTF extension each parameter maps to spelled out under it — KHR_materials_clearcoat, _sheen, _transmission, _emissive_strength), and Occlusion (an occluder plane cut through the subject). A Compare control splits the stage into two spheres so a change reads against a reference instead of against memory, and four environments (Studio, Interior, Sunset, Night) show that every one of these parameters is a conversation with the IBL, not a colour picker. - Rebuilt the demo app's Lighting and Lighting Lab screens from scratch, split by role over one shared stage: Lighting is the showcase — three rigs (image-based, three-point studio, dynamic sun) with a chrome and a matte probe ball that make a light move readable — and Lighting Lab is the workbench, its five tabs replaced by a single frame where exposure, environment intensity and rotation, sky, local reflection probe, SSAO, fog, MSAA, FXAA and dithering are all live at once. Every retired deep link still resolves, now to the half that hosts its subject. - The Android demo's Camera & Gestures screen is rebuilt from scratch — one stage, one camera, and every camera capability expressed as something you do to it (#3500). The screen was three demos behind a segmented toggle: a manipulator-mode picker (Orbit / Free Flight / Map) with a distance slider, a per-node edit screen with four switches, and a third mode showing the editing affordances the second deliberately drew without. It was an inventory of API surface, not a demonstration — every mode tore down its own engine on a switch, and the distance slider rebuilt the Filament Manipulator on every step, so each change teleported the camera. The one thing a camera screen exists to convey, that the camera is a place you move through rather than a parameter you set, was nowhere on screen. What replaces it is a three-subject stage on a lit floor, driven by a single spherical rig that is never rebuilt: one finger orbits, two pan, a pinch dollies, and a release coasts to a stop; a tap flies to the subject under your finger, framed from that subject's own size, and a double-tap returns to the whole stage; five named views (Hero · Front · Side · Top · Close) fly to an angle relative to whatever has focus, always by the shortest arc round it; the dock's Cinematic item hands the camera to the SDK's own eased orbit ramp, spinning the framing you chose instead of teleporting to a canonical one; and its Move item makes the focused subject editable, so the same drag / twist / pinch moves the object — with the SDK's on-model affordances drawn over it — right next to the camera it competes with for the same gesture. A glass HUD prints the live azimuth, elevation and distance, the name of what has focus, and the gesture while it runs, because a camera demo that never says where the camera is asks you to infer it from pixels. The floor is what makes the difference legible: orbit swings its perspective lines, a pan slides them. - codex-delegate.sh now routes by what was measured, not by what is newest. Two changes, both from a measurement run on 2026-09-06 rather than from the model's press release. --model gpt-6-astra implies --effort high unless an effort is given: at its own default effort Astra answered "no actionable regressions" on a diff where the same model at high effort found two real protocol bugs, so the scarce allowance was being spent on the cheap reasoning. And ask escalates to Astra by itself when the prompt is larger than CODEX_DELEGATE_ASK_ESCALATE_BYTES (800 KB, about 230K tokens): every gpt-5.6-* model stops at a 272K-token window and a prompt above it is truncated, not refused, so a whole-module read or a dead session transcript would come back looking complete and be missing its tail. Astra takes about 922K tokens of input, which is the one job here that nothing else can do. What deliberately did not change: implement still defaults to gpt-5.6-sol. Three parallel Astra implements at effort high spent 206K tokens in nine minutes and exhausted an entire ChatGPT Plus five-hour window — for every model, Sol included — so Astra on implement is a one-at-a-time choice for a hard issue on a fresh window, never a default. - The Play Store and App Store listings for the demo app now say what the app opens, and show it. Both descriptions were missing four of the six formats the Android manifest already declares — the listings sold GLB and glTF while the app has been opening 3MF, STL, OBJ and PLY from any file manager — and the Play short description promised "Every demo is a screen you can actually use", which describes nothing a visitor gets. The six formats are now named on both stores, the promise is replaced by the real one (open any 3D file, at real size, in your room), and the App Store text only claims the formats the iOS app was measured opening. Store titles are unchanged. - The two listings carry a new captioned set of screenshots. The Play phone class goes from five uncaptioned frames to six captured on the Pixel_7a emulator, and the App Store from three to six per device class captured on the iOS simulators; every frame now sits under an English caption card, so the carousel reads before any pixel of app UI does. The Play feature graphic is a composed banner with a text hook instead of a full-bleed crop. Captions, cards and code panels are composited by a new versioned tool, tools/store-screenshots/compose.py, driven by a manifest that names every output — no PNG is retouched by hand, and DESIGN.md tokens are the only colours it draws with. - The AR screenshot now shows the app, not just a room. The "Real size, your room" slot was a generated photo with no app UI in it at all — the same defect the audit filed against the slot it replaced, and a screenshot that shows no app is one Google can refuse. It is now a composite: the generated room carries the AR screen's real chrome, captured on emulator-5554 and keyed off the flat-black AR surface by compose.py (kind: ar) — the back arrow, the Tap to Place identity pill, the Model · Sheen Chair placement bar, the reset control and Settings, drawn as the app draws them. The subject is the chair the captured chrome has armed, so the bar names the model the frame shows. The demo app's About screen is rebuilt. The 110dp gradient tile and its Material view_in_ar glyph are replaced by the app's own launcher mark, the seven look-alike cards become three labelled groups of settings-style rows (Learn, App, Legal), and supporting the project is now a single card near the top of the screen — Open Collective first, GitHub Sponsors beside it — instead of one row lost in the middle of a list. - The placement-scene demo is now "One-Call AR", and says what it demonstrates before the camera opens (#3568). Named "Placement Scene" and sitting next to "Tap to Place" and "Wall Placement", it opened straight into a camera with its only explanation buried in a Settings sheet — so it read as a second, worse copy of its neighbour, and the one person who wrote the SDK could not name its subject after a minute of use. Its subject is not that you can tap to place; it is that the entire camera screen is a single PlacementScene { anchor -> … } call, where Tap to Place hand-writes the same flow out of the low-level primitives to show what they are. The demo now opens on a still, themed screen that states exactly that, shows the snippet, introduces the cursor by rendering the real reticle in two non-AR scenes — one over a pale ground, one over a dark one — and only then opens the camera; Back returns there rather than leaving the demo. The deep link sceneview://demo/placement-scene is unchanged. It stays in the catalogue because it is the only demo that exercises the public one-call composable, which is also the first snippet llms.txt offers for AR placement. - The AR placement reticle is achromatic (#3570). RETICLE_TINT was #44E7FF and its documentation called that "the DESIGN.md primary cyan". DESIGN.md has no cyan: primary is #005bc1 / #a4c1ff, and #44E7FF is in no token table. On a real floor that saturated ring was the loudest thing in the frame and tinted the room. Every reticle worth copying — RealityKit's FocusEntity, Scene Viewer, Polycam, IKEA Place — is neutral, and says searching versus ready with opacity and shape rather than hue. The default is now the on-ar-scrim white, drawn as a hairline ring over a faint ar-scrim contact halo so it stays readable on a pale floor, with the one colour on screen confined to a small #a4c1ff centre dot that appears only in the ready phase. Theme-independent, like every element drawn over a camera frame. Callers that want the old look can still pass reticleColor = DEFAULT_RETICLE_COLOR. - Materials, Animation & Physics, Video Recording, Secondary Camera and Debug Overlay now show what they demonstrate (#3572, #3573, #3574). All five rendered something correct and explained none of it. Materials was nine unlabelled spheres: each now carries a projected caption naming its preset — Polished chrome, Car paint — clearcoat, Crystal — transmission, Neon sign — emissive — and tapping one opens it side by side with the same sphere in matte plastic, so the parameter that changed is visible rather than asserted. Animation & Physics demonstrated neither half: the animation side now names the playing clip, shows its playhead in seconds, lets you scrub the pose and cross-fade into a second named clip with a live blend percentage, and the physics side answers a drop with sphere-to-sphere collision response and an impact counter, plus a deterministic Replay. Video Recording now states what it is doing and lets you play back or share the MP4 it produced, instead of writing a file the user never sees. Secondary Camera labels both views and explains the second Camera feeding the picture-in-picture. Debug Overlay names each metric it prints and reports the first sustained drop below 55 fps as the stress test spawns nodes. Every string is a resource, and each sample carries a one-paragraph explainer naming the SDK API it exercises. - Reworked the Android demo app after a full QA pass: Floor and Wall placement are now one AR Placement entry with a named surface selector (the wall-placement deep link still opens it on Wall), the Showcase opens on a model instead of a release note, Collaborative AR shows Alice and Bob as two live panes, Contact Shadows no longer hides the TV behind its explanation, and 2D in 3D starts on one readable, tappable card.

Fixed

  • The splat-preview demo clipped its own subject (#2646). The orbit home was a hand-picked z = 1.6 m against a shell whose silhouette is 0.536 m (0.5 m of centres plus one 3σ billboard disc), which in portrait left a half-width of only 0.309 m at the subject — the sphere overflowed both edges by ~1.7x, and the demo that exists to show Gaussian splats never showed the whole cloud. The distance is now computed from the projection Filament actually uses (a 35 mm-equivalent focal length against a 24 mm sensor, so tan(fovV/2) = 12 / focalLength, the horizontal angle scaled by the 9:20 portrait aspect) and from the tangent condition d = radius / sin(halfAngle) — framing on tan would fit the flat disc through the centre and still clip the silhouette. Covered by SplatFramingTest, which asserts containment and keeps the old 1.6 m as a regression witness, and the demo's render is now baselined: splatpreview_default.png is a real golden and the slug joined BASELINED_GOLDENS.
  • The splat-preview demo rendered unguarded, and ten doc surfaces undercounted the node inventory (#2646). P1c proved SplatNode renders on the QA emulator with a one-off orbit screenrecord, but that probe was never committed — so DemoRenderingScreenshotTest covered 14 demo slugs and not the one whose pixels come from a custom .filamat. Added the splat-preview case (first-run path: the slug is deliberately absent from BASELINED_GOLDENS until a reviewed capture is promoted), which makes the suite's only runtime check of the splat.filamat ABI invariant — a mismatched blob blanks the viewport, and a blank viewport is what the settle probe refuses to capture. Separately, README.md, the doc site (cheatsheet, manifest.json, platforms, try, structured-data.json), mcp/README.md, mcp/src/guides.ts and website-static/index.html still advertised 46+ node types while .cursorrules and .windsurfrules had been corrected to 48+ in #3352; all twelve claims now read 48+.
  • A gate script that cannot find its tool now says "could not run" instead of reporting a failure (#3192, workstream 2). Audit of every script a workflow or CLAUDE.md still invokes as a check, now that #3244 has removed the local harness (pre-push-check.sh and the script_report_failure helper the issue names are gone with it). Four legs turned a missing tool into a verdict about the tree: validate-demo-assets.sh read a missing curl as HTTP 000 → "transient", spent 23 s of backoff per URL and exited 0 with every CDN reference "not checked"; ios-device-qa.sh exited 1 — the code a flow that really failed returns — when xcrun (or macOS itself) was absent; ar-replay-qa.sh exited 1 — "a demo crashed" — when python3 was missing for the verdict merge, and only found out after a full emulator sweep; qa-android-demos.sh printed "APK build failed or timed out" on a stock macOS host that has no GNU timeout, without Gradle ever starting. Each now uses the code its own header reserves for "could not run" (exit 2, checked up front), or resolves gtimeout the way lib/maestro.sh already does; nothing changes when the tool is present. test-validate-demo-assets.sh pins the curl contract.
  • MCP Apps is now declared as an extension, so a host that follows the negotiation rules can find the 3D viewer (#3192, workstream 4). Everything the widget needs shipped a while ago — the ui://widget/3d-viewer.html resource, its text/html;profile=mcp-app mime type, and _meta.ui.resourceUri on both tool declarations and tool results — but MCP Apps is opt-in, negotiated through capabilities.extensions (SEP-1724), and neither sceneview-mcp nor the hosted gateway ever named io.modelcontextprotocol/ui anywhere. A spec-following host had nothing to switch on: it saw a server with tools and resources and no reason to look for a UI. Both handshakes now declare extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } } from one shared source in mcp/src/widgets.ts, so the gateway and npx sceneview-mcp cannot drift. The declaration is additive on every revision either server speaks — the ext-apps spec advertises the same capability over protocolVersion: "2024-11-05" in its own example — so hosts negotiating an earlier revision are unaffected.
  • The gateway answers server/discover, so a 2026-07-28 client can discover it at all. That revision removes the initialize handshake, which means an extensions block living only in the handshake result is invisible to a modern client; it would have got a bare -32601 and learned nothing. The gateway now returns the same identity, capabilities and revision list without a session or a handshake, the way sceneview-mcp already did (#3349). It advertises only the revisions it actually implements (2025-06-18, 2025-03-26) — announcing 2026-07-28 while implementing none of its per-request _meta versioning or result envelopes would be the worse bug.
  • The gateway degrades to text for a client that negotiated MCP Apps without our mime type. The extension spec asks servers to check the peer's capabilities before advertising UI-enabled tools. Silence stays permissive on purpose: every host predating the extension framework — ChatGPT included, which drives the widget off the openai/* _meta keys — declares nothing, and gating on silence would have dark-shipped the live listing. Only a client that names the extension and lists mime types excluding ours loses the pointer; the tool stays listed and callable. Tool declarations also merge _meta.ui instead of replacing it, so re-affirming the widget pointer can no longer drop the openai/* spellings or any key the declaration gains later. The mime type itself is unchanged and correct: text/html;profile=mcp-app is the current MCP Apps value, not the withdrawn text/html+skybridge.
  • SceneView(onFrame = …) now fires only for frames that actually reached the surface, so the Materials demo no longer shows a blank viewport (#3444). onFrame was invoked from the pre-render step, before Renderer.beginFrame had decided anything — and Filament refuses frames while the GPU is behind. On the QA emulator the Materials demo presents 4 frames in its first 6.3 s (Filament compiling the ToyCar's KHR_materials_clearcoat / _sheen / _transmission variants) before settling at 60 fps, so the callback fired on refused attempts, the demo scaffold's loading cover lifted on tick 1, and the viewport sat black for ~10 s with no spinner, no label and not even the 12 s "Still loading…" card — which is what the reporter, the QA screenshot and any store capture recorded. The callback is now gated on SceneRenderer.presentedFrameCount actually advancing; everything else in the tick (load updates, node ticks, framing passes, the camera manipulator) still runs every frame, or a stalled surface could never recover. No signature changed — only when the callback fires.
  • The demo scaffold waits for a sustained frame cadence before dropping its loading cover. A presented frame means submitted, not displayed: a warming driver spends ~1.5 s on each of those first frames and still emits the occasional close pair, so "one frame arrived" — and even "two arrived quickly" — uncovered a surface the driver would not paint for another 8 s. FirstFrameState now needs 8 presented frames in a row no more than 250 ms apart (~133 ms once the loop runs at 60 fps, unreachable during warm-up); the 12 s "Still loading…" card remains the backstop for a device that never gets there.

  • The demo viewport names its own state. It is "Scene loading" while the cover is up and "Scene ready" once a frame has reached the surface, so TalkBack announces whether there is anything to look at instead of leaving an unlabelled rectangle.

  • A QA launch (--ez qa_mode true) no longer lands on the "What's new" sheet. A QA run deep-links into a single demo; a modal that opens itself over it swallowed the gestures and could end up in the capture. It stays silent rather than acknowledging itself, so the badge still has its list waiting for the next human who opens the app.
  • ./gradlew :samples:android-demo:detekt no longer fails Gradle's task-ordering validation (#3450). detekt scans the same src/main/java tree kotlinc compiles, which contains the build-generated GeneratedDemos.kt, but only the Kotlin compile tasks declared their dependency on generateDemoRegistry; the demo's detekt tasks now declare it too. The CI Lint job runs the demo's detekt alongside the four library modules, so the task is exercised on every Android PR instead of only on a maintainer's machine; its 93 pre-existing findings (70 of them MaxLineLength, 91 distinct signatures) are grandfathered in buildSrc/config/detekt/baseline-android-demo.xml, the same treatment the library modules got, so only new violations fail.
  • Web: LightManager.setShadowOptions() and LightManager$Builder.shadowOptions() no longer throw UnboundTypeError with the bundled Filament.js (#3456). The runtime vendored at website-static/js/filament/ (and the copy the web demo serves) was Filament.js 1.70.1, whose Embind bindings never registered quatf — the type of ShadowOptions::transform — so both public shadow-option entry points failed with Cannot call LightManager._setShadowOptions due to unbound types and nothing on the web could set mapSize, normalBias, contact shadows or stepCount. The runtime is now Filament.js 1.72.1, the first tagged release that carries the upstream fix (google/filament#10116), the filamentWebsite pin and RUNTIME.json move with it, and the three website-static/materials/*.filamat blobs are recompiled with the matching matc (MATERIAL_VERSION 70 → 72) in the same change, as the runtime/material ABI invariant requires. A Playwright spec (samples/web-demo/tests/shadow-options.spec.ts) now calls both entry points against the shipped runtime and proves a mapSize change visibly moves the rendered shadow. The npm filament pin used by the Kotlin/JS bundle (filamentWeb, 1.52.3) is unchanged: npm publishing of that package stopped at 1.53.4, so the fix is only reachable through the vendored runtime.
  • The Play Store and App Store listings now lead with the helmet the app really renders (#3461). The generated AR visual that opens every screenshot class (#2844) and the Play feature graphic were still image-to-image from tools/demo-previews/refs/hero.webp, a stylised rusty helmet that khronos_damaged_helmet.glb does not render — the defect #3454 fixed on the catalog cards, one surface further out. All six store files are regenerated from refs/damaged_helmet.webp, the crop of the real modelviewer_default render golden, so the store, the Showcase hero banner and the Model Viewer card converge on the same teal-visor helmet. The prompts now live in tools/demo-previews/store.json and gen.py --kind store cuts each slot to its exact store pixel spec, so the art is reproducible the way the cards are; refs/hero.webp is deleted, nothing references it any more.
  • The Flutter demo's pubspec.lock now records flutter_sceneview at the plugin's current version (#3462). The lockfile still pinned the path-based plugin entry at 4.31.0 while flutter/sceneview_flutter/pubspec.yaml had moved on, so a fresh clone's first flutter pub get rewrote a tracked file before any code was touched. The lockfile stays committed (the Flutter team's recommendation for applications); the demo README now says it is refreshed by flutter pub get and never hand-edited.
  • The demo's release bundle builds again under AGP 9; the Play Internal deploy on main was broken since the AGP 9 move (#3467). :samples:android-demo:buildReleasePreBundle failed with Entry name contains invalid characters: root/META-INF/SceneView:sceneview_release.kotlin_module. Kotlin Gradle plugin 2.4.10 names every JVM/Android compilation <project.group>:<project.name> (plus _<variant> for Android variants), and the default Gradle group of a subproject is the root project's name — so every module in this build compiled as SceneView:<module>, with a colon in the Kotlin module name. The compiler writes the .kotlin_module file under a sanitised name (SceneView_sceneview_release), which is why debug APKs, the AARs and assembleDebug were all green; but R8, on the minified release build, re-emits that resource under the raw module name, and AGP 9's bundle packaging validates zip entry names and rejects the colon. The app's existing META-INF/*.kotlin_module packaging exclude is not a fallback: AGP 9 does not apply it on the bundle path at all — the AAB built with this fix still carries all 19 .kotlin_module entries, so excluding the resource could never have made the entry legal.

The fix is at the root: the root build.gradle now sets a colon-free Kotlin module name on every JVM/Android compilation of every subproject — <project.name> for main, <project.name>_<compilation> otherwise, i.e. sceneview_release, sceneview-core, sceneview-compose, android-demo_release — which is the name KGP used before 2.4. It covers kotlin-android modules and the multiplatform ones (androidTarget(), AGP 9's androidLibrary { } and jvm() targets) alike; Kotlin/Native and Kotlin/JS compilations are untouched. The only trace in the binary-compatibility dumps is arsceneview.api, where the mangled accessors of seven internal Compose lambda singletons move from …$SceneView_arsceneview_release to …$arsceneview_release — the module name is part of the JVM mangling of internal members, which are not public API; the dump merely records them. No public declaration changes, in any module.

:samples:android-demo:bundleRelease — the exact task the Play deploys run — joins the local verification list for build-system changes alongside the assembleDebug / testReleaseUnitTest / lintDebug set recorded for the AGP 9 move (#3440), and CI's build job now builds the demo's release bundle whenever a PR touches the build system (gradle/**, build.gradle*, settings.gradle*, gradle.properties, the wrapper), so this gap cannot reopen silently. - CI on main no longer ends cancelled in "KMP native unit tests (iOS sim)" when the job runs on the self-hosted Mac (#3469). The tests were green in ~20 s on every affected push; the 30 minutes were spent in Post Run ./.github/actions/setup-gradle. The composite action writes the Gradle cache on push events, and on a self-hosted runner the Gradle User Home is the machine's own ~/.gradle — a home the action never restores into ("Gradle User Home already exists: will not restore from cache") but did tar and upload in full on every main push: 2.2 GB + 3.0 GB entries still at Sent 1073741824 of 2201734690 (48.8%), 0.6 MBs/sec when the job timeout fired (job 100769714909), or a transfer stuck at Sent 0 of 1269721491 (0.0%), 0.0 MBs/sec for the whole 30 minutes (job 100796430540). The same commits' PR runs are read-only and spent 0 s in that step, which is why the failure only ever showed on main. All four main runs that reached this job on the self-hosted runner spent 17 to 30 minutes there. The save also ran gradle --stop against the developer's live daemons (one of the two it stopped belonged to a local Flutter build) and pushed multi-GB macOS entries into a repository cache already at 14.5 GB active against the 10 GB quota.

setup-gradle now stays cache-read-only whenever runner.environment is self-hosted, on every workflow that uses it; hosted runners keep repopulating the cache on push exactly as before. The job's timeout-minutes was not the problem and is unchanged; a comment next to it points at the post-step for the next reader. - The iOS demo's home cards now show the helmet the app really loads (#3474). The Showcase hero banner and the Model Viewer, Dynamic Sky, PBR Materials and Fog preview imagesets in Assets.xcassets were still drawn from the deleted hero.webp render or from unrelated helmets — the defect #3454 and #3461 fixed on Android and in the store listings, one platform further out. The five imagesets are regenerated from tools/demo-previews/refs/damaged_helmet.webp with the prompts recorded in prompts.json / heroes.json (gen.py --format jpg), so the iOS cards, the Android cards and the store art converge on the same teal-visor helmet. - A model authored away from the origin is now framed where it actually is, not where it was measured (#3482). sceneview-web's auto-centre pass moves content onto the origin through the content-root pivot, then auto-dollies the camera to fit — but it fitted the bounding box measured before that move, so the camera aimed at the content's old position. A glTF authored around the origin has a ~zero offset, which is why it went unnoticed; a 3MF is authored in the positive octant by specification, so a converted print rendered a third of a frame off-centre. - An opened file reached the AR placement picker labelled opened-model (#3482). The staged copy is deliberately named opened-model on disk — a display name comes from whichever app shared the file, so putting it on a path would mean sanitising untrusted text — and AR derived its row label from that path's basename. The user opened rocket.3mf and the picker offered them opened-model. The viewer now carries the file's own name across to placement, next to the real-world size it already measured. - The npx sceneview-mcp stdio server now honours the MCP Apps mime negotiation it advertises (#3485). The server declares the io.modelcontextprotocol/ui extension, which tells a host it follows the extension's rule: degrade to text when the client declares mime types excluding text/html;profile=mcp-app. The HTTP gateway did that; the stdio server did not — it mapped every tool declaration through unchanged, _meta.ui.resourceUri included, and attached the same pointer to every view_3d_model result, so a host that had just said it cannot render our widget was pointed at one anyway. Both handlers now read the client's declared extensions from the handshake and take the pointer off declaration and result together, so the two transports cannot disagree. What does not change: a client that declares no extension at all — ChatGPT today, which drives the widget off the openai/* keys — still gets its widget, and so does one that names our mime type. The tool itself stays listed, callable and text-answering in every case: degradation, not failure. An explicit empty mimeTypes list is now documented as counting with silence, since the spec makes the field required and an empty one is malformed rather than a refusal. - "View in AR" from the Model Viewer sometimes dropped the current model and opened the picker instead (#3493). The handoff passed whatever model the viewer was showing, but AR only recognised it when it also happened to be one of the six models curated for the AR placement catalogue. The Damaged Helmet is deliberately not one of them — a design call from #2023 about what a first-time visitor should be offered — so tapping "View in AR" while looking at it silently failed to arm anything and landed on the picker instead of the camera. The handoff now always wins: any model the viewer was showing opens AR directly, with the same model already armed, and the picker is still one tap away if the user wants to change it. - DynamicSkyNode pointed its sun light at the sky instead of at the scene: Filament's light direction is the direction the light travels, but the node set it to the vector toward the sun. A noon sun therefore lit the scene from underneath the ground — no cast shadow at any hour, and the time-of-day slider only changed the light's colour. The direction is now negated, so the sun rises, casts and sets as documented. - The MCP gateway no longer refuses discovery to hosts on a newer protocol revision, and a tool result no longer carries a widget the session negotiated away (#3502). server/discover is the handshake-free call whose whole purpose is to say which revisions this server speaks, yet it sat behind the MCP-Protocol-Version gate: a host sending its own current revision in that header — exactly what the spec invites — got a 400 instead of the one answer that would have let it negotiate down. Discovery is now exempt from the gate; every other method still is not. Second half: tools/list already stripped _meta.ui.resourceUri for a client that declared MCP Apps mime types excluding text/html;profile=mcp-app, but tools/call attached the pointer unconditionally, so a host that discovers widgets from results rather than declarations was still handed a widget it had just said it cannot render. The call path now reads the same session decision and takes the pointer back off the result — the text content and structuredContent are untouched, which is the graceful degradation the extension asks for. Four transport tests pin both halves, including that a client declaring nothing still gets its widget. - The MCP roadmap no longer advertises a version that already shipped (#3506). get_platform_roadmap returned an "Upcoming" section still promising v4.0.0 — SceneViewSwift stabilization, Android XR, the Flutter and React Native bridges — while 4.34.0 is published, so every host that asked the server what was coming next was told the current major line was still ahead of it. The section now names the workstreams without a version number, and says that 5.0.0 is a deliberate milestone rather than an automatic bump. - The site's Discord link was dead on eight pages, the two landing-page CTAs included. discord.gg/sceneview returns Unknown Invite from Discord's API; the live invite is the one the README already used. The footer's "Twitter" entry pointed at an unrelated personal account and is removed rather than guessed at. - Stale counts on the marketing surfaces. The MCP server ships 38 compilable samples, not 33 (mcp/src/samples.ts), and the README advertised two npm packages whose own deprecation notices call them archived scaffolding. - An oversized Codex prompt no longer reports a quota block that never happened. The Codex CLI caps one turn at 1,048,576 characters whatever the model's window is, so gpt-6-astra's advertised ~922K tokens of input is not reachable through codex exec — the real ceiling lands around 260–300K tokens, which is "a bit more than gpt-5.6-sol", not four times more. Handing it a 2,043,968-character session transcript was refused with input_too_large; and because that transcript itself quoted the words session limit and rate_limit, and Codex echoes the prompt into its log, codex-delegate.sh's quota grep matched the prompt's own text and stopped with a plan limit that did not exist. Named causes are now checked before word-matching — the same shape of fix the function already carries for an earlier instance of the bug — so an oversized input exits 1 with its character count instead of 3, and ask refuses it up front rather than spending minutes uploading a prompt that will bounce. A real quota block still exits 3 and no workaround is ever attempted. CODEX_DELEGATE_MAX_PROMPT_CHARS raises the cap if a future CLI does. - Leaving a screen while a model was still loading could kill the process (#3523). A cancelled loadModel coroutine and ModelLoader.clear() could both reach destroyAsset/releaseSourceData for the same glTF asset, and the second one dereferenced a freed native pointer — a SIGSEGV in libgltfio, not an exception, so the runCatching around those calls never had a chance to help. It reproduced about twice per ten QA runs on the demo app, always by navigating back before the model finished loading. ModelLoader.destroyModel now claims the model out of its live-asset registry and only destroys it if the claim succeeded, so of any number of concurrent callers for one asset exactly one reaches Filament and the rest are no-ops. Registration into that registry also moved inside the same main-thread hop as the asset creation itself, closing the window where a model existed but was not yet claimable. Frame an opened model at any scale. The demo viewer clamped its camera to 20 cm at the near end, so an STL, OBJ or PLY authored in metres — read in millimetres, as those unit-less formats are — opened as a near-invisible dot that Recenter could not bring back. The framing distance and the camera's near plane now follow the subject's own size, and a unit-less file a few units across is offered the metre reading ("Looks like metres — open at real size") instead of being scaled in silence. - A 3MF, STL, PLY or OBJ that declares no colour now renders as a lit, shaded, neutral grey instead of a blown-out white solid wearing a bloom halo (#3548). The fallback albedo the four loaders share was written 0.62, 0.64, 0.68 — sRGB numbers, put straight into glTF's baseColorFactor, which is defined in linear space. So the "light grey" those numbers describe was really an sRGB 0.81 near-white, and SceneView's camera runs about two stops over sunny-16 by design (f/12, 1/200 s, ISO 200, to match RealityKit). Under the model viewer's 30,000-lux IBL that albedo clipped: every shading cue vanished, the surface read as unlit, and it crossed the bloom threshold so the print wore a yellow halo. A CadQuery chair measured 435 of 480 sampled surface pixels fully clipped, with a visible glow outside its own silhouette. The fallback is now 18% linear grey — the photographic mid-grey, neutral on all three channels — defined once and shared by the 3MF, STL, PLY and OBJ paths, so a colourless file reads the same whatever format it arrived in. Same chair after: zero clipped pixels, no glow outside the silhouette, and facet-by-facet shading you can read. Files that do carry colour are untouched — an explicit 3MF displaycolor still goes through the sRGB→linear transfer it always did. - Nine demo render goldens still showed the retired demo chrome (#3551). The unified demo moved from a light Material app bar plus a FAB to the floating dark pill, so every baseline recorded before that redesign disagreed with the live app across a quarter to three-quarters of its pixels — animationphysics, debugoverlay, fog, geometry, lighting, lightinglab, modelviewer, pickingcollision and secondarycamera were all failing for the chrome, not for the render. They are re-recorded on the reference AVD (Pixel_7a, 1080x2400 @ 420 dpi, light mode, hardware GPU) and each capture was reviewed by eye before promotion: the model, the environment and the on-screen controls are present in every one. The suite now runs 15 of 15 green. - The demo-render-goldens CI leg had never compared a single golden, and reported green for it (#3551). The job booted the system image's default AVD, which renders at 320x544; every golden is 1080x2304. So all 15 cases died on Size mismatch before reading a pixel, and the || true inside a continue-on-error job turned that into a green check. Worse, the run never got past 2–3 cases — the emulator process was going away mid-suite (adb: device offline), which is also why the demo-render-golden-captures artifact had never contained a file: the adb pull ran against a device that no longer existed. The job now pins the emulator to 1080x2400 @ 420 dpi — the geometry the goldens are recorded at — with -skin plus wm size/wm density, asserted in the step so a wrong geometry breaks the run instead of hiding in it, with the RAM, cores and data partition that framebuffer needs, and a new step writes the real executed / passed / failed counts to the run summary, annotating a shortfall when cases never ran. The leg stays advisory, per this repo's doctrine for emulator legs — but advisory now means "not a merge block", not "unreadable". render-goldens/README.md is stated as the single source of truth for how a golden is recorded, and the workflow comment that claimed goldens "MUST come from this exact [CI] config" is gone: that config renders on SwiftShader and cannot produce a baseline a real GPU will match. The captures that leg finally produced then showed what SwiftShader actually renders: nothing — every frame is the demo's own "The scene has not rendered a frame yet." card, which the suite had been comparing against a golden and reporting as a 99.75 % render regression. The suite now recognises that card; on a run that declares softwareRenderer=true it is an explicit skip with a reason, and everywhere else it stays the hard failure it should be. No pixel comparison is relaxed, so a hardware-GPU runner would start gating for real without another edit. What the leg still does not do is finish: it dies capturing the Lighting Lab frame, identically on both system images and already at the old geometry, so 12 of the 15 cases never run. That is now visible in the summary rather than swallowed, and tracked as #3554. The demo app builds for macOS again: the iOS-only navigation bar calls that broke the App Store macOS archive since v4.33.0 now go through platform-guarded helpers (#3556). Release-fast releases now reach the stores: tag-release.sh dispatches play-store.yml and app-store.yml alongside release.yml, and a read-only prod-status.sh probe reports what is actually live on every public surface versus VERSION_NAME (#3557). - A hidden node can no longer leak a visible child, and the AR reticle no longer flashes at the world origin (#3569). isVisible is computed from the parent chain, but the Filament layer mask that decides rendering was only pushed when a node's own visibility field changed. Re-parenting changed the computed answer without touching any field, so a child attached to an already-hidden parent kept the default visible mask and rendered anyway. In PlacementScene that surfaced as a flat, un-rotated reticle disc floating over the camera feed at the world origin for the first frames of every session, before ARCore had produced a single hit. Node.parent now refreshes the subtree's rendered visibility, and PlacementScene composes its reticle only while the camera is TRACKING, so it is also gone the moment the session stops. - A tap in PlacementScene now always places something once the camera is tracking (#3571). The composable ran frame.hitTest(event) and fed the result to an acceptance filter that had a branch for InstantPlacementPoint hits. ARCore never returns one from Frame.hitTest — instant hits come only from Frame.hitTestInstantPlacement — so with instantPlacement = true (the default) that branch was unreachable and every tap taken before a plane had converged under the finger was dropped in silence. On a low-texture floor in a dim room that is most of the first minute, which reads as a screen that simply does not respond. The tap now resolves plane-first with the instant point at a 1 m approximate distance as the fallback — the Sceneform ArFragment behaviour the KDoc always promised — and a successful placement fires a LongPress haptic, so a tap that lands feels different from a tap that misses. - The Augmented Faces mesh is visible again, and it is lit (#3575, #3576). Face detection was never broken — the demo banner truthfully read "Tracking 1 face(s)" while the screen showed nothing. Augmented Faces only runs on a Session.Feature.FRONT_CAMERA session, and ARCore documents that such a session never tracks the device pose: Camera.getTrackingState() always returns PAUSED. PoseNode hides any node whose camera tracking state falls outside visibleCameraTrackingStates, which defaults to {TRACKING}. AugmentedFaceNode builds its mesh inside its own constructor, while that field still holds its initial value, so the mesh appeared for a frame or two and was then hidden — with its children — for the rest of the session. AugmentedFaceNode now opts out of the camera gate entirely; the face's own TrackingState, which is the one that actually means "there is a face here", still gates the mesh. With the mesh back on screen, its shading was the second half of the report: the demo painted it with an unlit flat colour and computeTangents = false, one uniform blue with no highlight and no falloff — a filter, not a fitted mesh. ARCore force-disables light estimation on a front-camera session, which is why the demo had drifted to unlit, but "no estimate" argues for a deterministic rig rather than for no shading. The face is now a lit PBR material with per-frame tangent quaternions, and the demo installs its own key and fill lights instead of inheriting the SDK's straight-down (0, -1, 0) default — the overhead angle that buries the eyes, the base of the nose and the mouth. A front-camera session pins world space to the device, so a fixed direction out of the screen is a stable, camera-anchored portrait key light. - The iOS demo's dark mode has surfaces again, and four buried or broken things in the Model Viewer and Explore work (#3582, #3583, #3584, #3585, #3586). Dark mode drew every card fill with the same #0D1117 as the page it sat on, because DESIGN.md's surface-container (#161C2C) had never been ported to Swift — cards, the hero ground and the header chips were all literally invisible against their own background, which is what "flat and inky" meant. That token now exists, together with outline, on-surface-faint, primary and a primary-container for tinted actions; cards and the hero scrim land on it, hairlines use outline, and the selected filter chip trades the shouting white pill for a solid primary one. Light is untouched — every value is a light/dark pair whose light half is what shipped. With it: the Model Viewer opens on an environment you can see (outdoor_cloudy, backdrop drawn) instead of a studio rig whose backdrop is four softbox panels in a void, while the "Show environment" switch still wins and its answer is remembered across launches and environments; Cyberpunk Hovercar and Butterfly have thumbnails instead of silently falling back to an anonymous cube.transparent (thumbnailName probes UIImage(named:) and returns nil when an imageset is missing, so nothing failed — a unit test now walks the whole catalogue for tiles, USDZs and backdrop defaults); "Surprise me" is promoted to the top of the Models sheet and re-rollable from a pill in the viewer itself, instead of being the second-to-last row of a list nobody scrolls; and Explore has a search field again — it had one all along, but .searchable renders into a navigation bar and the embedded Explore is pushed onto a screen that hides its bar, so the field silently did not exist. Embedded Explore now draws its own inline field, with distinct empty, no-results and error states, and sits on surface rather than the system black it had been falling through to. The Android demo's model picker gets the same "Surprise me" promotion, so the two apps agree on where the feature lives. iOS demo — the Lighting sample now demonstrates lighting. Changing the option produced no visible difference and nothing in the scene ever looked reflective. Two causes: the demo added its own light on top of the SceneView system key + fill it never disabled (10 000 + 3 000 lux, so its own 2 000 lux directional was a rounding error), and it set no .environment(_:), so half-rough white spheres had no IBL to mirror. The screen is rebuilt as the iOS counterpart of Android's #3496 rebuild — three rigs (Image, Studio, Sun) over one stage with a chrome probe and a matte probe, each rig disabling both system light slots so what you see is the rig and nothing else. - Every model in the iOS viewer now opens correctly framed, zooms both ways, and comes back to where it started (#3595, #3596, #3597, #3598). App Store QA on an iPhone SE found the Toy Car opening as an extreme close-up that refused to zoom out, the Butterfly opening as a speck, the Cyberpunk Hovercar off-centre, the Recenter button doing nothing visible, and the menu's Reset restoring everything except the zoom. Those were four faults wearing one costume. SceneView re-armed its fit-to-bounds pass by writing value-type @State from the RealityView update: closure and from a .task(id:) — writes SwiftUI drops, because both run on a view value it has already moved past — so after the first subject latched, every later model inherited the previous one's pivot and orbit radius. The latch, the stability tracker and the recenter token now live in the reference-type applied cache, where a write sticks. The zoom-radius limits are assigned from the current content's bounds instead of merged with the outgoing subject's: a stale floor clamped the fit above the distance that frames the new model, which is precisely "opened zoomed in and will not zoom out". The orbit drag and the pinch were two competing .gesture(_:) modifiers, so DragGesture claimed the touch sequence and no closing pinch ever reached the SDK — they are composed with .simultaneousGesture now, with the orbit drag suppressed for the duration of a pinch. And a model file may ship its own cameras (the Khronos ToyCar sample carries eight); RealityKit renders through one of those, so the camera being fitted and recentred was not the camera on screen. SceneView owns the camera, so authored ones are stripped from loaded content. - New: SceneView.recenterCamera(_:) — bump a token to re-frame the camera on the content that is already loaded. Hosts previously had to re-key contentID, which rebuilds the model and restarts its animation to move a camera. - The framing driver no longer allocates per tick. The content-bounds union folded into a fresh array on every one of its 30 Hz passes, on the main thread, while the user was pinching — and with the latch broken it never stopped. It folds in place now, and it latches. - Tapping a demo card in the iOS Showcase now expands that card into the demo (#3599). The full-screen demo used to appear with the stock cover slide, with nothing tying it to the card the thumb had just hit. It uses the same iOS 18 zoom transition the Explore gallery already uses: the tapped DemoMediaCard — or the hero, when the demo is opened from it — is the transition source, and the demo collapses back into it on close. - Fixed the Android demo screens whose text sat under the system bars, whose subjects were clipped or too small to read, and whose AR coaching kept running after the session had already failed; aligned the demo palette and typography with DESIGN.md.

Removed

  • Device-QA: the Rosetta x86_64 AR rig is gone (#3521). setup-ar-emulator.sh --rosetta and its whole provisioning path are removed. The rig existed to test whether an x86_64 guest could host a live-camera ARCore session on Apple Silicon and was measured not to (same camera topology as arm64 — no HAL id 0; the ARCore install kills system_server); that finding is kept in .maestro/README.md, the ~650 lines that could only re-derive it are not. The QA host now keeps exactly one AVD, Pixel_7a on emulator-5554, which the same script recreates if it is missing. Real ARCore-session QA still needs a physical device.
  • sceneview-mcp no longer sells a Pro tier. The gateway it pointed at was deleted months ago and both of its URLs answer HTTP 404, so every "upgrade to Pro" surface in the package was an upsell to a dead end: the [PRO] prefixes on tool descriptions, the startup banner's pricing link, the "set SCENEVIEW_API_KEY to unlock it" stub and its invalid-key / rate-limit variants, the SCENEVIEW_API_KEY credential in mcpize.yaml, the subscription clauses in TERMS.md and PRIVACY.md, and the hosted-gateway sections of the rerun / gaming / interior READMEs. Every tool is free and runs in-process. The tool list is unchanged on both surfaces: the three generation tools that need your own third-party credentials are still local-only, and now say so honestly instead of asking for a subscription (#3590).
  • The dead mcp-gateway/ pricing dashboard and Cloudflare Worker are removed, made obsolete by the MCP Pro tier's removal in #3590; the last commit that still contains it is tagged archive/mcp-gateway-2026-09-10.

Tests

  • Five rebuilt demos have a render golden again (#2323). materials (#3495 / #3538), camera-gestures (#3500 / #3540), custom-geometry (#3423), two-d-in-three-d (#3424) and lines-paths (#3425) each replaced their scene wholesale, so each rebuild deleted the golden that pictured the old one and took its slug out of BASELINED_GOLDENS. That is the documented first-run path, but it leaves the case assumeTrue-skipped — five demos whose render nothing was checking. All five are re-recorded from the rebuilt scenes on the shared Pixel_7a AVD, each capture looked at before promotion (the nine-sphere material wall, the three-subject camera stage, the runtime torus knot, the four depth-tested Compose cards, the tube-extruded splines), and each verified by a second run that compares green against the committed PNG.
  • The Android device-QA flow waits for real pixels before it interacts, and asserts them before it captures. Liveness alone passed a black demo — the Activity was perfectly alive the whole time — and waiting for the loading cover to be absent passed just as trivially, in the instant between launchApp and the first composition. .maestro/android/flows/demo.yaml now waits for the viewport's positive "Scene ready" node before the orbit swipes (the cover swallows touches, so swiping under it orbited nothing) and asserts it again at capture time. That is the app's own "there are pixels" signal, so it needs no pixel reader and no new script; a demo that never presents a frame now fails QA instead of passing it. The same signal replaces the zoom legs' fixed 9 s render-warm-up guess, which waited on a marker that never existed.

Docs

  • Every public surface now says what SceneView actually ships: 3MF, and the ChatGPT / Codex plugin. The README, the MkDocs site, llms.txt, the Android skill, the landing page and both store listings were written before either landed. A new Model Formats page states the whole matrix — glTF/GLB everywhere, USDZ on Apple, .3mf on Android with no new API — what the conversion does to units, axes, normals and colours, and how the demo receives a file from the share sheet. llms.txt gains the other half of that contract: the formats that do not load (STL #3486, PLY #3487, OBJ + MTL #3488, 3MF on the web #3491), so a generator names the issue instead of inventing a loader. The ChatGPT / Codex plugin is now installable from the README, CONTRIBUTING.md, the docs and the site with the commands that were actually exercised, and the plugin manifest carries a 3MF starter prompt and keywords.
  • Play Store and App Store listings rewritten against the shipped app. The Android listing led with a "Dynamic Sky" demo the app no longer has and never mentioned that the app now opens a .3mf, .glb or .gltf handed over by another app; both listings now describe the ~50 screens that exist, resolved from DemoRegistry's fragments and the iOS scene files rather than from memory.

v4.34.0 — 2026-09-03

Added

  • Opt-in on-model gesture feedback for editable nodes. New multi-consumer Node.addEditingListener / NodeEditingListener hook reporting move / rotate / scale editing gestures (including pinch updates rejected by editableScaleRange, with the bound that was hit), plus a Compose layer: rememberNodeEditingFeedback(node) exposes the live gesture as snapshot state (saturation-free yaw readout, scale percentage, limit hits) and NodeEditingOverlay draws the ready-made visuals over the scene — selection ring, rotation ring with sweep arc and yaw badge, scale percentage badge with a bounce at the range limits, and a soft contact shadow while dragging. Nothing renders unless the app opts in. The feedback acknowledges the touch from first contact — an "armed" state dispatched on touch-down, ahead of any gesture-recognition threshold — and fades the half of the base ring that lies behind the model so the ring reads as a mark on the ground rather than a decal in front of it. New gesture-feedback-preview demo (non-AR, QA-able on any emulator).
  • refreshIntervalMs on the HitResultNode composable. The class has honoured the throttle since #2328, but ARSceneScope.HitResultNode never exposed it, so Compose callers had to reach through apply { } to find a knob the class already had. All three composables (HitResultNode, ReticleNode, PlacementReticle) now take the parameter and re-apply it in a SideEffect instead of keying remember on it — changing the rate re-rates the live node rather than destroying and re-creating it.

Changed

Add AR visuals to the Play Store and App Store listings (#2844). The listing text sells AR but no image showed any. Slot 1 of every screenshot class (phone, tablet7, tablet10, iphone-6.9, ipad-13) and the Play feature graphic are now AI-generated marketing visuals — Gemini image-to-image from the committed hero-model reference, the sci-fi helmet anchored in a real photographed room per DESIGN.md's AR art direction — because a real AR capture needs a device camera: ARCore's recording/playback path fails on the QA emulators (session creation probes camera HAL id 0 before consulting the playback dataset, and the arm64 AVDs have none). The existing real captures were shifted down one slot, not replaced. Committing is not uploading — the release workflows sync both listings from the repository, so the visuals reach the stores with the next minor release. CLAUDE.md no longer restates the CI path-gating detail that CONTRIBUTING.md already carries, and its Codex bullet is condensed to the rules an agent must follow. Every fact that only lived in CLAUDE.md is kept. The file goes from 4,074 to 3,566 bytes (-12.5%), which is the whole of what this repository contributes to a session preamble (#3001). The Android demo's default models are no longer a glTF conformance suite. Both surfaces that offer a bundled catalogue — the shared tap-to-place picker (BUNDLED_PLACEMENT_MODELS, used by the AR View tab and the ar-placement demo) and the Model Viewer's model sheet — led with two untextured low-poly rows, khronos_fox (flat vertex colours, no material maps at all) and shiba (a single base-colour map). In a full-screen PBR viewer those two are the models that make Filament look worse than it is, and neither is something anyone would place in a room. They are replaced by three Khronos glTF-Sample-Assets pieces that each drive a different material model: Glam Velvet Sofa (KHR_materials_sheen + KHR_materials_specular, Wayfair, LLC — Eric Chadwick, CC-BY-4.0, 3.1 MB), Sheen Chair (KHR_materials_sheen, same author, CC0-1.0, 4.1 MB) and Iridescent Dish with Olives (KHR_materials_iridescence + transmission + volume + ior, same author, CC-BY-4.0, 5.7 MB). All three were already in the repo — the android-tv-demo bundles the same byte-identical GLBs — so nothing new was sourced, and all three are already proven to load in Filament's Android gltfio (PNG/JPEG textures, not EXT_texture_webp, which that prebuilt cannot decode — #2305).

Nothing was deleted. khronos_fox.glb and shiba.glb still ship: SampleAssets uses them as offline fallbacks and ARTerrainAnchorDemo loads the fox directly. Their thumbnails stay mapped in ModelThumbnails too, so any surface naming them by stem still gets an image.

Three knock-on fixes fell out of the same work:

  • The picker's cards now show the model. Every card rendered the same generic ViewInAr glyph, so the grid was six identical tiles under six labels and the only way to learn what a row looked like was to place it. Bundled rows now render their generated model_thumb_<stem>.webp; streamed rows keep the glyph, because their bytes are not in the APK and there is nothing honest to show until they land.
  • realWorldSizeMeters is measured, not estimated, for the three new rows (2.19 m, 0.83 m, 0.53 m). They are authored in metres, Y-up, sitting on y = 0, so the number fed to ModelNode(scaleToUnits = …) — and therefore what 100 % means on the pinch read-out (#3326) — is the GLB's own bounding box.
  • Two ar_placement offline fallbacks finally resemble what they stand in for. #2960 documented that "Coffee Mug" fell back to a toy car and "Wooden End Table" to a fox — silhouette-class matches only — and said closing that gap needed assets the APK did not ship. It ships them now, so they fall back to the dish and the chair. Pairwise distinctness (#2355) is unchanged and still pinned by SampleAssetsTest.

Attribution was wrong for two of the three in assets/catalog.json, which is what every generated CREDITS.md is built from: the Sheen Chair was recorded as KhronosGroup / CC-BY-4.0 when its upstream README.md says Wayfair, LLC (Eric Chadwick) / CC0-1.0, and the Iridescent Dish carried a sourceUrl that 404s (IridescenceDishWithOlives — the directory is IridescentDishWithOlives) plus the same wrong author. Both are corrected and every CREDITS surface regenerated.

One caveat worth recording rather than hiding: on emulator-5554 (Android Emulator OpenGL ES translator, ES 3.0 over Metal) the sofa's and chair's sheen reads exactly as intended, but the dish's iridescent shell renders as a dark glossy form with no thin-film colour shift under either the studio or the outdoor IBL. The model, its orientation, its scale, the brushed-metal dish and the olives are all correct; it is the KHR_materials_iridescence contribution specifically that does not appear on that GL path. Confirm on a real GPU before quoting iridescence as the reason for this pick.

The three GLBs moved out of samples/android-tv-demo/src/main/assets/models/ rather than being copied. The TV demo merges the phone demo's asset folder via sourceSets.main.assets.srcDirs, so the same models/x.glb present in both folders is Error: Duplicate resources at mergeAssets — a hard build failure, not a last-one-wins. The TV demo reaches all three at the identical models/... paths through that same line; its APK still ships them at the same paths and the same byte sizes, and TvModelListTest searches both folders, which is what makes the move a no-op for the TV demo.

Bundled model assets grow 17.7 MB → 30.1 MB. Android demo: the two settings surfaces are now one. The overflow menu is gone and its actions — Reset demo, Send feedback, QA mode — moved into the settings sheet the dock's Controls item opens, below the demo's own controls. The sheet's own settings reset stays pinned in the header, and the per-demo reset it used to be confusable with is now labelled "Reset demo". The chrome also carries its own scrim, so the back arrow, the identity pill, the dock and a demo's status pill stay legible over a light scene instead of disappearing into it. - Every Android module now compiles against SDK 37, and the okhttp bump it blocked has landed (#3385). okhttp-android 5.5.0 raised its own compile-SDK floor to 37, so checkDebugAarMetadata failed the build and pinned the SDK at 5.4.0. compileSdk moves 36 → 37 in sceneview, arsceneview, sceneview-compose, samples/common, samples/android-demo, samples/android-tv-demo and tools/snippets-check, and 35 → 37 in the Flutter (flutter/sceneview_flutter/android) and React Native (react-native/react-native-sceneview/android) bridges, whose host demo apps follow so a plugin never compiles against a higher SDK than the app embedding it. targetSdk is deliberately unchanged at 36: the AAR-metadata floor is a compile floor, and moving targetSdk opts the Play Store app into API 37 runtime behaviour changes that need their own device QA. AGP stays at 8.13.2, which was tested up to 36, so android.suppressUnsupportedCompileSdk=37.0 documents the gap in each of the four independent builds' gradle.properties — remove it with the AGP upgrade. - Binary compatibility: the new refreshIntervalMs parameter added to the already-released public ReticleNode / PlacementReticleNode constructors and to the HitResultNode / ReticleNode / PlacementReticle composables is source-compatible but binary-incompatible (it changes the shipped JVM signatures and the Kotlin default-args synthetics). It therefore rides a MINOR release, never a patch, and binary consumers of arsceneview must recompile against the new artifact — the same rule the predicate parameter followed when it was added to these same classes. Model Viewer demo: the launch cover is a real loading state instead of the demo's preview image (no more wrong-picture flash), the dock items carry labels and clearer icons, the default environment is the punchy Sunset HDR, and the model now arrives on a short Material-standard camera dolly-in. - Demo app: one AR placement flow, and you pick the model before the camera opens. The ar-placement demo is now two phases — a still, themed chooser screen that arms the model and the placement mode, then the shared tap-to-place camera it already used. The ar-instant-placement demo is folded into it: instant placement is the chooser's "Instantly" mode, and sceneview://demo/ar-instant-placement keeps resolving through the deep-link alias table. Back out of the camera returns to the chooser instead of leaving the demo. (#3405) Rebuilt the demo app's Cloud Anchors screen as an explicit two-step flow — Host an anchor (place, map the room, upload, share the code) and Resolve one (paste a code, resolve, see the anchor). The host/resolve state machine moved into plain, unit-tested Kotlin, so a running request now owns the status line instead of being shadowed by coaching copy, severity comes from the state rather than from substring-matching the sentence, and no action is offered that cannot work — Host waits for a placed anchor and a sufficient ARCore FeatureMapQuality, which is rendered as a room-mapping meter. Failures are explained in the app's own words instead of printing the raw ARCore constant, hosted codes can be copied or shared through the system share sheet and pasted back from the clipboard, and a missing or rejected ARCore Cloud API key now gets the same explanation card as an unavailable ARCore session. The screen's own chrome uses the AR overlay tokens, so it reads identically in light and dark over a camera feed. - The custom-geometry demo is rebuilt from scratch around a mesh generated at runtime (#3423). The demo was named for custom geometry but never authored a vertex: one sub-mode composed built-in SphereNodes and CylinderNodes into a molecule, the other extruded 2D outlines through ShapeNode — both built-in node types, and the first of them a near-duplicate of the geometry primitives demo. It now generates a (2, 3) torus knot swept by a twisting, rippling ribbon: TorusKnot.vertices() computes every position, normal and UV in plain Kotlin, Geometry.Builder uploads them, and MeshNode draws them. Three controls rebuild the mesh live — Segments (24 → 264 rings, 125 → 5 035 vertices), Twist (stepped in half-turns, the only values at which the ribbon closes on itself) and Ripple — plus a Wireframe toggle in the dock that redraws the same vertices as PrimitiveType.LINES, and a status pill counting the vertices and triangles currently on screen. Twist and Ripple keep the vertex count constant and so reuse the allocated GPU buffers through Geometry.update(engine, vertices); only a Segments change allocates, and a paired DisposableEffect frees the old buffers with Engine.safeDestroyGeometry. Normals come from finite differences on the surface itself, so they stay correct under ripple and leave no crease at the seam; TorusKnotTest (17 JVM cases) pins the topology, the unit-length normals, the UV range, the seam closure and the camera framing. The segmented-button toggle is gone, so the retired shape deep link now lands on the demo's single view (it is no longer listed in DeepLinkRouter.ALIAS_INITIAL_TAB); sceneview://demo/custom-mesh and sceneview://demo/shape both keep resolving to custom-geometry. - 2D in 3D demo rebuilt from scratch around ViewNode (#3424). The demo carrying the app's headline "2D in 3D" name used no ViewNode at all: it was four unrelated scenes behind a segmented button — TextNode labels, a gallery of procedurally-drawn ImageNodes, an MP4 on a VideoNode with a bespoke cinematic camera, and a BillboardNode — so the one API that puts live Compose UI into a 3D scene appeared nowhere in it. Its Billboard tab was inert on top of that: BillboardNode and TextNode billboard only when handed a cameraPositionProvider, and neither was given one, so the sign whose caption promised it would "stay readable" never turned. It is now one scene — the Khronos Damaged Helmet on a turntable, with three world-anchored Compose call-out cards and one live, tappable control card — and four controls that each answer a real ViewNode question: Billboard (dock) keeps the cards square to the viewer or lets them ride the turntable edge-on; Always on top pairs MaterialInstance.setDepthCulling(false) with PRIORITY_LAST so the model can no longer swallow a label; Card size shows what pxPerUnits = 250 px/m means in centimetres; Card distance moves the call-outs off the subject. Tapping the button inside the scene stops the turntable — a real Button.onClick, reached through a Filament picking ray. The framing and the billboard solution moved into CalloutLayout, pinned by CalloutLayoutTest. The retired text, image, video and billboard deep links still resolve here; with the tabs gone, none of them carries an initial-tab hint any more. Rebuilt the lines-paths demo around real stroke geometry, and added the Tube geometry and TubeNode it needs: LineNode / PathNode draw PrimitiveType.LINES, which every mobile backend rasterises at one device pixel with no width control, so the old screen's line and polyline were invisible on a phone. A tube sweeps a circular cross-section along a polyline with rotation-minimising frames, giving a line a radius in metres. The demo now shows a closed route (polyline / rounded / spline), a marker and trail sampled by arc length, a marching dashed ground track and the control-point set, with live curve, stroke, points and animate controls. - Four catalog cards in the Android demo now show the demo they open (#3437, #3438). custom-geometry, two-d-in-three-d and lines-paths were rebuilt from scratch, but their home-screen art still advertised the scenes they replaced — a ball-and-stick molecule, a gallery of framed photos, a chain of beaded hairlines. model-viewer was wrong in a different way: its card showed a stylised helmet that khronos_damaged_helmet.glb does not render, so the first screen a user sees promised a different model than the one that loads. All four pairs are regenerated through tools/demo-previews/, light and dark, on the same studio backdrop as their neighbours; the two procedural demos are drawn from references rendered by their own generator code (TorusKnot, LinesPathsScene), so the card shows the exact curve the app computes. The HomeHero banner above the grid showed the same wrong helmet as the model-viewer card and is regenerated with it — the two sit a thumb's width apart on the first screen, which is what #3438 was filed about. tools/demo-previews/gen.py grew a --kind hero (heroes.json) so that banner has a recorded prompt like every other asset instead of being a one-off.

Fixed

  • The self-hosted macOS runner now falls back to macos-15 when the host is nearly full (#2816). The runner heartbeat marked the Mac SELF_HOSTED_MACOS_ONLINE=true on runner liveness alone, so a job could route to a host with ~4.6 GiB free: a Flutter iOS type-check then died at No space left on device and left a truncated Flutter.xcframework in the runner tool cache, poisoning every later self-hosted Flutter job. .claude/scripts/runner-heartbeat.sh now refuses to declare the runner online below RUNNER_MIN_FREE_DISK_GB free GiB (default 15 — a Flutter setup peaks near 4.5 GiB and the host's 6 GiB local-build gate must stay clear), checked before the GitHub API probe and logged with the reading that caused the refusal. setup-self-hosted-runner.sh --check prints the current free-disk reading and its verdict next to the heartbeat state. device-qa.sh's sketchfab / arcore-cloud sub-legs only checked airplane_mode_on before trusting a streamed-asset run — a radio that was ON but routeless (captive portal, dead DNS, a dropped VPN) still passed the gate, so every streamed Sketchfab slug could silently resolve to its bundled fallback while the report said the path was exercised (measured closing

2942). A new lib/qa-connectivity.sh layers a real probe — airplane mode,

Android's own captive-portal-validated dumpsys connectivity signal, and an actual ping to the streamed-asset host — and fails closed to an honest skipped when none of them prove a route. ios-device-qa.sh gets the symmetric host-side probe (the Simulator shares the Mac's network). Both scripts record the probe detail in their reports and accept --allow-offline to downgrade the loud connectivity banner to a quiet, explicit skip list for a deliberately offline run. App Store slot 1 shows the Cyberpunk Hovercar again instead of a second copy of slot 2's Damaged Helmet. The issue reported the hovercar as "framed as plinth-sized, so it reads small next to the fixed slot 2"; the plinth half of that had already been fixed (#3315 stripped the display plane from cyberpunk_hovercar.usdz, and the flattened USD confirms no plane prim remains). Capturing the demo on the 6.9" simulator showed the real cause: the model-viewer slot was not rendering the hovercar at all.

Two independent changes collided. #3003 switched dynamic-sky — slot 2 — to khronos_damaged_helmet, and the showcase redesign (#3308) rewrote ModelViewerDemo to default selectedModel to bundledModels[0], which is that same helmet. The redesign kept the "the hovercar is the iOS store hero" comment while making the code disagree with it, so the store hero silently stopped being captured and the listing showed one subject twice. Under qa_mode the view now selects storeHeroAssetName explicitly before the first load; the interactive first-run subject is unchanged — a new user still lands on the Khronos reference helmet.

captureFramingMargin stays at 0.62, and the constant now records why rather than reading as a preference. Swept against live captures with the -camera_distance override (#2785): 0.75 leaves the car at roughly 45 % of the frame width and 0.5 clips its tail against the right edge. It is a floor, not a choice.

One framing defect is documented and deliberately left open, because it is an SDK change rather than a sample constant: the car renders right of the frame centre with the left third of the frame empty, and its silhouette is markedly smaller than the bounds the auto-fit pass is fitting. CameraControls.fitRadius inscribes the space diagonal of the union AABB in a sphere and fits that sphere to the narrower FOV axis — width, in a portrait store frame — so a wide, short subject whose authored bounds exceed its visible geometry cannot fill the frame however tight the margin gets. Fitting the projected AABB instead would close it. - pub.dev publish verification no longer false-reds on its own documented propagation delay. .claude/scripts/verify-published-version.sh's pub budget grew from 5x20s (100s) to 20x30s (10 min), matching the window pub.dev's own upload response states; release.yml's pub-publish job timeout grew from 15 to 20 minutes to give that budget room. - Repo guards now say "could not run" instead of faking a verdict when a tool is missing (#3192). Five checks reported a result they had not actually verified when an interpreter or CLI was absent: check-web-filamat-abi.sh exited 1 with two bogus MISMATCH blocks and an empty hash when neither shasum nor sha256sum existed; sync-versions.sh printed a green 116-check report without python3, which is what reads and rewrites every JSON version file; verify-published-version.sh turned a missing curl (or python3 for pub.dev) into a claim about the publish; web-bundle-smoke.sh died with a bare npm: command not found (127) that reads like a broken web bundle; and validate-release-artifact.sh blocked a Play Store upload with "could not parse 'package' from artifact manifest" when the missing piece was python3, not the artifact. Each now resolves its tooling up front and exits 2 (0 WARN + SKIP for the release guard, whose own contract is that missing validation tooling must never veto a release), so 1 keeps meaning "the thing under test is wrong". The demo app's visible surfaces now draw their colours from the design system instead of hardcoded Material swatches: the category accents sample DESIGN.md's gradient-hero ramp (blue → violet, with a dark variant) rather than a six-hue rainbow, the About tab's icons, hero gradient and rating star follow the theme in both light and dark, and the Explore category chips use the primary container instead of the tertiary one. - A tap on a ViewNode seen from behind now lands on the pixel you are looking at (#3329). The view material is double-sided and un-mirrors its UVs on the back face, so a quad orbited past edge-on keeps reading correctly on screen — but the touch mapping stayed front-face, so every touch on a turned-away quad landed on the horizontally mirrored pixel and the button under the finger did nothing for half of every revolution. CollisionSystem.hitTest now stamps the picking ray direction onto its HitResult (RayHit.getWorldDirection()), and ViewNode uses it to pick the right mapping. New worldToLocalDirection / localToWorldDirection conversions transform a free vector with w = 0, so a node's translation cannot leak into a direction. - Demo app — Picking & Collision is one scene instead of two settings tabs, and is lit (#3329). The ray hit-test shapes and the live Compose card now share a single SceneView, and the card reports the hit-test state so the two halves visibly share one picking pass. Both card faces are real Card(onClick = …) targets, so a tap always reaches a Compose component whichever side is turned towards you. The primitives moved from flat unlit fills on black to lit PBR instances under the studio IBL plus a warm key light, and the row was pulled in to x = ±0.5 with the eye at 4.2 m so it stops being clipped by the portrait viewport edges. The in-scene card also re-applies SceneViewDemoTheme: a ViewNode composes in its own off-screen ComposeView and inherits none of the host's CompositionLocals, so the card used to resolve the Material 3 light defaults and stayed pale lavender while the rest of the app went dark. - The Spatial Audio demo now says what is emitting the sound (#3332). The scene was two unlabelled spheres — a big orbiting one carrying the bell and a tiny centre marker that meant nothing — with no cue about where the sound started or who was hearing it. The emitter now pulses translucent shells outward so it reads as the source at a glance, the meaningless centre marker is replaced by the faint ring of the orbit path it travels on, and a glass legend over the scene names both roles ("sound source" / "listener — the camera, i.e. you") next to a live readout of the source-to-listener distance and the gain the selected falloff curve is applying, taken from the same AudioFalloff.gainFor the audio backend uses. - The AR camera background no longer gets an extra contrast boost (#3338). The three camera-stream materials decoded the camera texel with Filament's inverseTonemapSRGB(), whose transfer leg is pow(c, 2.2), while Filament re-encodes the frame with the exact piecewise sRGB OETF (its Rec709-sRGB-D65 color-grading output stage). The two do not cancel: the round trip crushed the shadows by up to 8.5/255 at code 16 and lifted the highlights by ~1.5/255, applied to the camera feed only — so real and virtual content were graded differently. The materials now decode with the exact IEC 61966-2-1 EOTF, making the round trip bit-exact. The YUV→RGB conversion was never at fault: ARCore delivers the buffer as STANDARD_BT709 | TRANSFER_SRGB | RANGE_FULL, and the EGL external sampler already handles it correctly. - The plane renderer no longer fires an ARCore raycast just to pick which plane to highlight (#3339). PlaneRendererMode.RENDER_CENTER is the default mode and the plane renderer is on by default, so every AR screen ran a centre-screen frame.hitTest() continuously — gated by maxHitTestPerSecond = 10, which against a 30 fps camera stream admits one pass every fourth frame, 7.5 times a second, for the lifetime of the screen. ARCore attempts a depth sub-test inside every hitTest, and on devices whose motion-stereo depth pipeline is unavailable that sub-test fails and ARCore's native logger emits a four-line FAILED_PRECONDITION / depth_hit_test.cc / motion_stereo_manager.cc / AR_ERROR_ILLEGAL_STATE block per call — forever, on every AR screen, with no way to opt out short of disabling the plane renderer. Nothing on the Kotlin side can filter a native log (the depthPoint = false result filter runs after the native call) and no Frame.hitTest overload accepts a trackable-type filter, so the only fix is not to make the call. The centre plane is now found analytically, by intersecting the camera's optical-axis ray with each candidate plane, applying exactly the acceptance rules the discarded firstByTypeOrNull(HORIZONTAL_UPWARD_FACING) applied through its defaults: TRACKING only, HORIZONTAL_UPWARD_FACING only, hit point inside the plane polygon. Subsumed planes are additionally skipped, since they are never drawn and highlighting one selected a plane that is not on screen. The failing depth sub-test never removed anything the call site wanted — the result was narrowed to horizontal planes, so depth could only ever have contributed DepthPoint candidates that were discarded anyway, and the warning was benign: it did not prevent anchoring or placement. Public API is unchanged — maxHitTestPerSecond keeps its name, default and meaning as a rate limit on the whole update pass, and PlaneRendererBase.viewSize stays part of the contract. Applies to both PlaneRenderer (V1, the default) and PlaneRendererV2. - AR occlusion was inverted wherever ARCore had no depth, and the demo never said what it was showing (#3340). camera_stream_depth.mat fed ARCore's "no depth here" sentinel (depth_mm == 0) straight into the projection, so view.w was 0 and gl_FragDepth became +inf — clamped to 1.0, which under Filament's reverse-Z is the near plane. The camera quad therefore occluded every virtual fragment exactly where the depth image had no data: sky, thin edges, moving subjects, whole frames when depth estimation drops out mid-session, and every frame before the first depth image lands. The model simply disappeared with occlusion on. Invalid texels now project a far-but-finite distance instead, so "no depth data" degrades to "occludes nothing". camera_stream_person_occlusion.mat carried the same hole plus its own sign error — it wrote 0.0 for PERSON-mask pixels, the reverse-Z far plane, pushing people behind virtual objects rather than in front of them; it now writes the near plane. ARDepthOcclusionDemo also stopped explaining itself: its DEPTH ON / DEPTH OFF chip named an ARCore setting rather than an effect, and the toggle that is the demo lived two taps deep in the Settings sheet. The on-screen pill now states the consequence of each state ("real objects in front of the model hide it"), a coaching line names the one gesture that reveals it, and the toggle sits under the thumb next to Clear. The chip's hardcoded green/red is gone, replaced by the shared ar-scrim overlay tokens. - The demo app's own chrome hid the "AR unavailable" card it was supposed to let through (#3341). #3374 gave the SDK a real availability state and an explanation card, but on an unsupported device — every emulator (#2754) — the demos still showed a black viewport forever. ARCameraInitScrim is a full-screen opaque backdrop drawn as a later sibling of ARSceneView, dismissed when the first camera frame arrives; when ARCore rules the session out that frame never comes, so after its eight-second timeout the scrim settled into a permanent black cover on top of the card explaining why. It now takes the verdict as a required argument and steps aside the moment ARCore answers, at any point in the start sequence — waiting through the spinner phase is pointless once the answer is "this device cannot run AR". Every demo that draws the scrim now passes the verdict through, sourced from onARCoreAvailability. The demos' status copy no longer contradicts the card either: the Rooftop Anchor sheet and banner, the Cloud Anchor and Terrain Anchor guidance, and the Instant Placement scanning pill all keyed off "not tracking yet", which on an unsupported device is permanently true — so "Initializing camera…" sat next to a card saying the camera was never going to start. They now read the verdict first, matching what the Orbital demo already did. The same reading covers the shared "Scanning for surfaces…" banner: seven more demos gated it on a flag — isTracking, a detected-plane count, a first-plane boolean — that an unsupported device leaves untouched forever, so the banner promised a scan that could not start. Two of them, Depth Occlusion and AR Fog, only looked correct because the scrim was covering them; uncovering the card exposed the banner underneath. All seven now defer to the verdict. The demo scaffold's back control was already drawn above the scrim, so leaving the demo has worked throughout; what was missing was any reason to. - The Orbital AR demo showed too many models to follow and offered nothing to catch (#3341). The scene put eight objects on eight heights at eight speeds around a 1.5 m ring, each contributing its own screen-edge arrow: five to seven arrows pointed in five to seven directions at once and none of them was worth turning toward. Half the slots were static props — a helmet, a lantern, a toy car, a walking soldier stepping through empty space — so "models flying around you" was true of four slots out of eight, and the one the demo designated as the chase target was the toy car. And there was no catch mechanic at all: no tap handling, no hitbox, no success state, no feedback, so a user who did turn toward a model and tap got nothing back and read it as a mechanic they kept failing. The ring is now four streamed animated flyers, one per quadrant, at 0.10–0.18 rad/s (the old fast end, 0.30 rad/s, crossed a phone-width of view in about a second). Tapping catches: one projection pass per frame feeds both the arrows and the hit test, so the hitbox can never disagree with what is drawn; the disc is 72 dp, 1.5× Material's minimum touch target for a stationary control, because the target, the hand and the phone all drift between the start of a tap and its landing. A caught flyer freezes in place and grows 1.25×, the status pill keeps the running score, and a ring is drawn for hits and misses — a silent miss is indistinguishable from a dropped tap. Once all four are caught, a tap anywhere releases them, each resuming from its frozen angle rather than jumping. In keyless mode the four solar entries also all fell back to the same bundled character, putting one identical model at four points of the ring; they now fall back to four distinct GLBs, guarded by the same pairwise-distinctness test that already covered ar_placement (#2940). Point & Ask (demo app) no longer answers every tap with the same "Gemini Nano couldn't answer". The captured AR frame is now cropped around the tap and downscaled to the on-device model's budget — ML Kit only clamps a bitmap's short edge to 768 px, so a 1080×2424 phone capture used to reach Gemini Nano as a 768×1723 strip — and each failure mode now names itself (unsupported device, stale AICore, busy model, rejected frame, capture failure, empty answer) instead of collapsing into one string. A failure that retrying cannot fix retires the "tap to try again" invitation and explains the on-device-only design instead. - sceneview-mcp answers server/discover (MCP 2026-07-28) instead of -32601 Method not found. A 2026-07-28-aware client now learns in one handshake-free round trip which revisions the server actually serves, plus its identity, capabilities and cache hints (ttlMs, cacheScope), rather than being left to guess after a "method not found". Against @hasmcp/mcp-spec-test 0.1.1 the 2026-07-28 run goes from 8 passed / 6 failed / 22 not verified to 14 passed / 0 failed, and 2025-11-25 stays at 0 failures. The Play Store graphics README's follow-up bullet on tablet Model Viewer framing non-determinism no longer cites the retired v2 evidence. The v3 re-capture (#3350) replaced the frames the bullet pointed at, and in the committed v3 set the helmet holds the same head-on pose on both tablet classes (Model Viewer is slot 2 there, not slot 1) — verified by comparing tablet7-screenshot-2.png and tablet10-screenshot-2.png by eye. The bullet now records that the committed pair matches while keeping the standing lesson: the hero orbit is free-running, so any re-capture rolls the pose lottery again and the camera distance must survive the widest orbit pose. - A vetoed editing gesture no longer strands its parent mid-gesture (#3357). onMoveBegin / onRotateBegin / onScaleBegin delegate to the parent node both when the transform flag is off and when the node's own callback lambda vetoes, but the matching End handlers routed on the flag alone — so an editable node whose lambda vetoed ended a gesture the parent was running, leaving the parent's editingTransforms holding the edited property for the life of the node. End now routes on the gesture the delegate actually claimed at Begin. - A release whose PR auto-merges is now always tagged (#3361). GitHub emits no workflow events for pushes made with the default GITHUB_TOKEN, so when release-fast.yml's release PR auto-merged, the merge produced no push event and the push-triggered Tag release workflow never ran: v4.33.0 sat on main with VERSION_NAME bumped, no tag, no publications and nothing red anywhere until a human noticed. release-fast.yml now owns the tag in the same run — a tag job waits for the PR it opened to merge, tags the merge commit and dispatches release.yml — so the release no longer depends on an event chain, and a PR that never merges ends in a red run instead of a silent half-release. tag-release.yml remains as the human-merge safety net and a manual recovery handle; both callers share the idempotent .claude/scripts/tag-release.sh. - AnchorNode no longer answers move gestures unless isEditable = true (#3359). Its isPositionEditable override was a plain field that silently dropped Node's isEditable && gate, so a drag on any anchored node detached and re-created its anchor even with editing off — observed as ARCore "already removed/detached" bursts killing placed models in the ar-instant-placement demo. PoseNode.isRotationEditable carried the same ungated override and is fixed the same way. Behavior change: code that relied on anchors being draggable by default must now opt in with isEditable = true on the AnchorNode (the demo placement helper now does exactly that). - The demo app's web deep link can no longer be widened into a link hijacker (#3366). The HTTPS intent-filter was already scoped to sceneview.github.io/open, matching DeepLinkRouter.extractCandidate, and never shipped host-less — but nothing in the build said so, so dropping android:host would have silently made the demo a candidate for every https link on the device. A new pure-JVM guard (DeepLinkManifestScopeTest) reads the scope back out of the manifest and pins it to the router's own constants, so the two layers can only move together. The manifest and website-static/.well-known/README.md also stopped claiming that App-Links verify on debug builds: assembleDebug is signed with the default ~/.android/debug.keystore, which is not — and cannot be — listed in assetlinks.json, so an unverified domain on a debug install is expected, and sceneview://demo/<id> is the deep-link channel for QA. Demo app: the Contact Shadow Preview "Shadow intensity" slider no longer reads 100% with the thumb short of the track end. The control multiplies each context preset's own opacity and deliberately runs past 1.0 to 1.5, so a percentage was promising a maximum it did not have. The readout is now the multiplier it always was — 1.00× at the default, 1.50× at the end of the track (#3372). Fixed a coloured band painted across AR fallback screens when ARCore never delivers a camera frame (#3373). The camera-stream quad was drawn from the moment it joined the scene, sampling an external texture that had no image attached and reading an unset UV buffer. It now seeds identity UVs at build time and stays hidden until the first ARCore frame binds a real texture. The demo app's camera-init scrim also keeps its opaque backdrop after its defensive timeout, dropping only the spinner, so the "AR couldn't start" fallback always has a deliberate background. - AR no longer hangs on "Initializing AR" on devices without ARCore (#3374). ARCore compared ArCoreApk.checkAvailability() only against SUPPORTED_INSTALLED, then asked for an install on every other verdict — including UNSUPPORTED_DEVICE_NOT_CAPABLE, where requestInstall throws. The exception was swallowed into onArSessionFailed, a callback no demo wires, so the session never started and the app sat on its own "initializing" copy forever. Availability is now a first-class state: the new ARCoreAvailability enum (Unsupported, NotInstalled, NeedsUpdate, CheckFailed, SessionFailed) is published through ARCore.onARCoreAvailability, and ARSceneView draws a built-in explanation card — overridable via arCoreAvailabilityOverlay, or observable via onARCoreAvailability — with an Install / Update / Try again action, and none at all on a device that simply cannot run AR. retryARCoreAvailability() un-latches a cancelled Play Store flow so the action works twice. SUPPORTED_INSTALLED is not a promise that Session() will succeed — an emulator with Google Play Services for AR installed still fails to create one — so a session that throws on creation now publishes SessionFailed and offers a "Try again" that destroys and recreates the session (ARCore.retrySession()), instead of leaving the same silent hang one step further along. Behaviour on a working ARCore device is unchanged: SUPPORTED_INSTALLED starts the session immediately and UNKNOWN_CHECKING still waits silently. - sceneview-mcp install instructions now name the path Claude Code actually reads. Every surface (llms.txt and the generated gpt/knowledge-*.md, docs/docs/ai-development.md, website-static/.well-known/llms.txt, mcp/demo/) now leads with claude mcp add --scope project sceneview -- npx -y sceneview-mcp and names .mcp.json at the project root, with ~/.claude.json for user scope; the old .claude/mcp.json and ~/.claude/mcp.json snippets were silently ignored by Claude Code, so the server never loaded. The repo's own config moved from .claude/mcp.json to a committed root .mcp.json, which .gitignore no longer excludes. iOS/visionOS: CameraControls.fitRadius now fits the subject's projected extent on each FOV axis instead of inscribing its bounding box in a sphere. The old formula collapsed the box to half its space diagonal and divided by sin of the smaller half-FOV — the horizontal one on a portrait phone — so every subject paid for a diagonal it does not occupy, and the vertical axis paid the horizontal axis's distance. A 3 m column in a portrait viewport was pushed back to 5.90 m where 3.00 m frames it exactly: the subject filled barely half the height available to it (#3383).

Framing stays invariant to azimuth, as an auto-rotating model must not clip when it turns broadside. Rather than fitting the pose you happen to be at, the fit takes the box's sweep about world Y — a cylinder — and fits that exactly through its support function, so the result is the tightest azimuth-independent distance rather than an upper bound on one. The new distance is never larger than the old one, so no scene is framed further away than before; portrait gains are up to 49 % of the old distance for tall subjects and 10–17 % for cubic ones, while wide subjects in portrait are unchanged because their horizontal reach genuinely requires that distance.

Unlike the sphere fit, the result now depends on elevation, since a subject's projected height changes as the camera rises. The default defaultFitMargin of 1.15 covers the worst case measured (1.124, a 4 m panel in landscape), so orbiting after an auto-fit still does not clip.

The Android (sceneview/) and web (sceneview-web/) framing helpers carry the same bounding-sphere approximation and are not touched here; they are reported in the pull request for a separate follow-up. - The committed App Store screenshots match what the app renders again (#3384). The four PNGs in samples/ios-demo/appstore-screenshots/ dated from 4 August and showed an app that no longer exists: the pre-redesign chrome (#3308) over every frame, and the white display plinth (#3315) under the hovercar. app-store.yml reads this directory inline at every release, so those frames were what Apple received. All four re-captured on the documented qa_mode pipeline, at the dimensions App Store Connect accepted (iphone-6.9 1320 × 2868, ipad-13 2064 × 2752). The subjects are unchanged — hovercar in slot 1, Damaged Helmet under a live sky in slot 2 — which for slot 1 is only true because of the qa_mode hero override (#3382) and the stage fix below.

  • The iOS store hero is staged on a lit backdrop again, not on black. The model viewer opens with showSkybox = false, so nothing is drawn behind the model — the right look for a viewer you are about to orbit, the wrong one for a store frame: the hovercar's dark bodywork read as a grey silhouette on near-black, which is the "dim, dark-on-black" capture #2896 was filed about. The pre-redesign code pinned a studio_warm hero environment for exactly that reason and #3308 dropped it. Under qa_mode the demo now also selects storeHeroEnvironmentName and draws its skybox, alongside the existing hero-model override (#3382). The interactive first-run look is unchanged.

  • QA-mode chrome no longer leaks into published store frames. qa_mode paints a "QA ×" chip so a human who enabled it can switch it back off; a scripted capture pass has no human and its output ships to the App Store. The chip arrived with the redesign (#3308), after the last capture, so it never shipped — but the pipeline launches with -qa_mode 1, so this refresh would have baked it in. DemoSheet now suppresses it when DeepLinkRouter.isScriptedCapture is true — keyed on the -demo <id> launch argument that only the capture and XCUITest passes carry. The determinism scripted passes rely on (frozen pose, framing, hero selection) is untouched; only chrome that exists to serve a human is hidden.

  • In-app bug reports now name the screen they were filed from and carry a usable log window (#3390). Reports arrived with neither: the demo id was matched against the literal route demo/{id} while the declared route had grown a ?model={model} argument, so it always resolved to null, and the tab host published nothing at all — a report filed from the Explore gallery or a live AR session was indistinguishable from any other. Every report now opens with a Screen row (Demo · model-viewer, Explore gallery, AR View tab · session active, …), read from the navigation arguments rather than a route string that drifts, and the display resolution moves to its own Display row. The log window went from ~30 lines to ~1200 captured: the share path carries the whole capture, and the pre-filled GitHub issue binary-searches the largest tail that fits the URL budget instead of snapping down a coarse 60/30/10/0 ladder, with each threadtime line stripped of its date and pid/tid columns — the millisecond timestamps stay, so the period of a repeating warning is readable straight from the issue. The report sheet states what it is about to attach before it is sent.
  • ReticleNode and PlacementReticle can now be throttled like every other AR hit-test node (#3391). HitResultNode has rate-limited its per-frame ARCore Frame.hitTest() through refreshIntervalMs since #2328, but neither reticle subclass accepted or forwarded that value: it appeared in no constructor, no KDoc and no composable parameter list, so both silently inherited the 0 = every-frame default and the raycast ran once per rendered frame whatever the caller wanted. Both classes and all three composables now take refreshIntervalMs and forward it to the base node. 0 remains the default and byte-for-byte the previous behaviour; 100 gives a 10 Hz reticle. The throttle covers the hit test only — the inherited smooth-transform easing, and PlacementReticle's orientation smoothing, still run every frame, so a rate-limited reticle glides between hits instead of stepping.
  • PlaneRendererV2's KDoc no longer claims V2 is the default plane renderer (#3392). The class documentation still described the v4.16.0 state — "V2 is the default plane renderer as of this release" and "the legacy V1 PlaneRenderer ... is now @Deprecated" — while the code has said the opposite since v4.16.1: ARSceneView's planeRendererVersion defaults to PlaneRendererBase.Version.V1, and PlaneRenderer carries no @Deprecated annotation. v4.16.0 briefly shipped V2 as the default, on-device QA showed the visual output not matching the design intent, and v4.16.1 reverted the default to V1 while V2 is polished (#2203) — that revert updated PlaneRendererBase, PlaneRenderer, ARSceneView and llms.txt but missed PlaneRendererV2, PlaneVisualizerV2 and the ARPlaneRendererV2Demo sample, so a reader landing on the V2 class was told to expect V2 behaviour on a stock ARSceneView and that V1 was on its way out. All three now state that V2 is an experimental opt-in (Version.V2), that V1 is the default, and that V1 was never deprecated; the #2203 sprint table records PR #5 as reverted instead of landed. The demo's KDoc distinguishes its own starting state (it opts into V2 explicitly) from the SDK default. Documentation only — no behaviour, no API and no default changed.
  • Sending a bug report from the demo app no longer pops a confirmation snackbar (#3398). Handing a report off to GitHub or the share sheet used to greet the user back with a "GitHub opened — finish and submit there" / "Report shared" snackbar (#3263). The hand-off itself is already the acknowledgement — the sheet dismisses and the browser opens on the pre-filled issue (or the system share sheet takes over) — so the snackbar was redundant, and it could not be truthful anyway: with no GitHub API call on device, "sent" only ever meant "intent launched". The failure path is untouched: when no app can handle the intent, the sheet still stays open and shows the error inline. BugReportSheet loses its onSent callback, MainActivity its snackbar host, and the two now-unused strings are gone.
  • The Android demo's "four tabs" comments now describe the real three-entry RootTab (#3401). Three comments still described a four-tab bottom bar while RootTab has had three entries (Showcase, ArView, About) for a while: MainActivity.kt's "list" route claimed a "4-tab root (Explore / AR View / Samples / About)" whose "Samples" tab hosted a DemoListScreen that no longer exists (demo deep links actually navigate straight to demo/<id>), RootScreen.kt said "the four tabs get their 168 dp of dead bottom gutter back" — a figure that survives only in that comment, the gutter having been reclaimed when the feedback FAB became a sibling card — and FeedbackReport.kt quoted #1930 as requiring the button "on the 4 tabs". All three now match the code, and the obsolete 168 dp figure is gone so nobody reintroduces it as a real constant. Comment-only — no behaviour change. Demo app — Point & Ask now shows what it is doing, and stops lying about your phone. On a Pixel 9 the demo answered nothing, "saw nothing" on the AR frame, and after a few taps declared it could not run on that device at all. Three defects behind that: the frame was read back from the whole window, which can lose the Filament SurfaceView layer the AR scene lives in; the only validation was a transparency probe, so the same lost layer coming back as opaque black passed straight through to Gemini Nano; and a retry counter promoted three ordinary failures into a permanent "not supported on this device". The frame is now read back from the AR view itself, validated for size, transparency and flatness before it leaves the app, and the screen is an explicit state machine — checking, downloading, ready, capturing, thinking, answer, or a failure that names its cause and offers the one action that fixes it. Only a report from the platform can say a device is unsupported. Debug builds show a thumbnail of the exact frame that was sent. :samples:android-demo:testReleaseUnitTest failed 13 tests on main (DemoScaffoldTopBandTest, DemoScaffoldBottomBandTest, DemoBackgroundRoleTest) while the exact same tests passed in the debug variant, and nothing in CI ever ran the release variant to notice — the demo's JVM tests only reached CI as a dependency of verifyRoborazziDebug, which is debug-only.

The cause was a dependency scope, not application behaviour. Every failing test hosts a composable through createComposeRule(), which launches a bare androidx.activity.ComponentActivity by explicit component name. That activity's manifest entry came from androidx.compose.ui:ui-test-manifest on debugImplementation — correct per that artifact's own guidance ("never let it reach a shipped APK"), but it also meant the entry only reached the debug variant's manifest and resource-link pipeline. testReleaseUnitTest links its unit-test resource package from the release variant's own implementation graph, which a debugImplementation dependency never touches, so every Robolectric-hosted compose-rule test in the release variant died with "Unable to resolve activity ... ComponentActivity".

Fixed by declaring the same ComponentActivity manifest entry directly in samples/android-demo/src/main/AndroidManifest.xml instead of pulling it in from ui-test-manifest — it has no launcher intent-filter and nothing in the app calls it, so it ships as an inert, unreachable entry in every build type and now resolves for both unit-test variants. :samples:android-demo:testReleaseUnitTest is also now wired into the Unit tests CI job (same android path gate as the existing debug suite), so this variant has real coverage going forward instead of sitting invisible on main. - Demo app — "Tap me" on the Picking & Collision card now fires only from its Button (#3422). The card was a Card(onClick = onTap, …) wrapping the button, so a tap anywhere on the card — its title, its shape/tap counters, the padding around them — counted the same as pressing "Tap me". The card is a plain, non-clickable Card now; only the Button has an onClick. A tap Compose does not consume this way used to fall through to the scene's onGestureListener as a ViewNode hit and bump the counter there too — that fallback branch is gone, so a miss on the card is now a true miss, exactly like the ray-cast half of the demo. Shape picking on the rest of the scene is unaffected. Camera framing and zoom. Three defects that shared one theme — the camera was told the wrong thing about the subject in front of it.

  • Auto-fit framed the bounding sphere, not the subject. fitDistanceForBounds charged every scene for half its AABB's space diagonal, then billed each field-of-view axis the other axis's distance. On a portrait viewport the horizontal FOV is the narrow one, so a subject bound purely by its height was pushed back by a width constraint it never hits, and the viewport could never be filled. The fit is now per FOV axis, in closed form, and frames the subject's sweep about world Y so an auto-rotating model still never clips at any yaw. It is never further than the old distance, and up to 2× closer for tall or compact subjects. This is the Android counterpart of the iOS fix in #3383. Pass azimuthInvariant = false for a static head-on scene that should not pay for a rotation it never performs. (#3426)

  • Pinch-to-zoom moved the camera a fixed number of metres. Filament's orbit manipulator translates the eye by zoomSpeed × scrolldelta world units regardless of how far away it is, so one full-screen pinch moved it ~11 cm: on a scene framed 5 m away that is forty gestures to halve the distance, and on a 5 cm model the same gesture punched the eye straight through the orbit pivot — at which point Filament flips the manipulator and the next drag rebuilds the view from a negative distance, aiming the camera away from the subject. Zoom is now a ratio of the current camera-to-target distance and is clamped either side of the framed distance, so one pinch is one comfortable step at any scale and the camera can never cross its own pivot. (#3403, #3426)

  • The Model Viewer reset its camera whenever anything nearby changed. Its manipulator was rebuilt from remember(framing, modelCenter, recenterGeneration, sliderDistance), and a Filament manipulator carries the whole camera pose — so every step of the zoom slider threw the user's orbit away (#3403), and so did opening the animation bar, which is when the scaffold first measures its identity row and changes the framing insets (#3404). The manipulator is now keyed on the content alone and reads the framing, pivot and zoom live. Pinch and the "Camera distance" slider drive the same number, and that slider's range is now relative to the model's own fitted distance instead of a fixed 0.5–10 m.

Demos re-framed off a shared, aspect-aware helper instead of hand-tuned literals: the Model Viewer gallery (one fixed radius served models normalised from 0.20 to 0.85 units), Lighting Lab's sky section and Secondary Camera's main view. - SpatialAudioNode no longer runs a blocking MediaPlayer.prepare() on the frame the sound starts on (#3427, reported as "a slight lag right when the beep plays" in the Spatial Audio demo). SpatialAudioPlayer built its private MediaPlayer with a synchronous, blocking prepare() — documented as "sub-millisecond" for a short in-assets clip, but the container parse and decoder setup it triggers is real main-thread work, and it landed inside the exact composition pass that also calls play() via autoPlay. Playback now goes through prepareAsync() and starts from the onPrepared callback instead, via a small PreparePlayGate state machine (unit tested) that defers a play() requested before the player is ready. - The release gate reads the GA4 stream id from its new carrier. #3443 moved the analytics loader and its Stream ID: comment into website-static/assets/analytics.js; sync-versions.sh only searched .html files, read an empty id, reported the carrier as corrupted and blocked the 4.34.0 Release fast run at the version gate. The check now covers .js carriers too. - The Play Store graphics README now describes the tablet v3 screenshot sets it actually ships. Two tables in samples/android-demo/distribution/play-store/en-GB/graphics/README.md still said both tablet classes carried the v2 set at three slots (model-viewer · dynamic-sky · multi-model), while #3350 re-captured tablet7/ and tablet10/ on the redesigned v3 set at four slots each (showcase Home · Model Viewer · Lighting Lab · Materials) — verified against the committed PNGs. Doc-only — no behaviour change. docs/mkdocs.yml declared extra: twice, so YAML last-key-wins silently dropped the GA4 analytics block and the "Was this page helpful?" feedback widget — the published docs site had 0 of 55 pages instrumented. The two mappings are merged; the docs site and all 16 static website pages now share one GA4 loader (website-static/assets/analytics.js), the /go/* short links report a go_redirect event, and the GitHub Sponsors CTA reports outbound_click. New recipe: Measure your AR funnel. The SDK and the demo apps still ship no telemetry. tools/download-asset-by-uid.sh no longer embeds a Sketchfab API token; it now requires SKETCHFAB_API_KEY from the environment (companion of the download-assets.sh fix).

v4.33.0 — 2026-08-26

Added

  • Desktop SceneViewer via filament-kmp (#2540). The sceneview-compose desktop actual loads a glTF and presents Filament frames through filament-kmp's offscreen Skia path. Public API is unchanged — no Filament type in the façade. Requires JDK 22+ (FFM). samples/desktop-demo consumes SceneViewer. A translucent EnvironmentSource.Color or Hdr(showSkybox = false) renders transparent over the Compose content behind, matching Android (filament-kmp 0.4.0). centerOrigin reaches full cross-platform parity (#2763). sceneview-web gains NodeHandle.centerOrigin(originX, originY, originZ) — aligns the AABB point selected by a normalized origin (-1..1 per axis, 0 = bounding-box center) with the node origin, typed in sceneview-web.d.ts. It calls the exact same -(center + origin * halfExtent) * scale formula as Android's ModelNode.centerOrigin(Position), now extracted into a shared sceneview-core KMP function (io.github.sceneview.math.centerOriginTranslation) both platforms compile — Android and Web literally share one code path and cannot numerically diverge. A centerOriginGoldenVectors table pins that formula in sceneview-core's commonTest, which runs unmodified on both the JVM (android) and JS (Karma) targets; the same expected values are duplicated in SceneViewSwift's ModelNodeTests (RealityKit has no dependency on the KMP module, so it reimplements the math natively), following the RerunWireFormatTest cross-suite precedent. llms.txt documents the parity. The iOS demo host now accepts a -camera_distance <float> launch argument, matching Android's camera_distance intent extra (#2652): DeepLinkRouter.validateCameraDistance applies the identical 0.05...100 clamp on both platforms. ModelViewerDemo — the demo used by the App Store screenshot pipeline — threads the validated value into its .framingMargin(_:) override, taking precedence over both the interactive and qa_mode defaults, so a tight store frame no longer needs Android-only tooling (#2785).
  • Point & Ask gets voice questions and a Drop-3D model picker (#3083). The free-form question field now has an optional mic button that launches the system speech recognizer and fills the field with what it heard — the same zero-permission ACTION_RECOGNIZE_SPEECH pattern the feedback sheet's voice dictation already shipped (#3292), so there is no RECORD_AUDIO grant to request. Long-press placement is no longer locked to the shiba: a "Long-press drops" chip picker in Settings lets you choose which bundled model gets dropped on the tapped surface (Shiba, Fox, or Toy Car — all three already ship in the APK, so switching is instant with zero new assets). This re-implements the voice + Drop-3D work that landed on claude/point-and-ask-voice (#2648) but never reached main; that branch's raw SpeechRecognizer + runtime RECORD_AUDIO and its two new ~7 MB model assets were re-scoped against main's current shape rather than reapplied byte-for-byte. Android demo: a QA-only synthetic room backdrop (--ez qa_backdrop true, debug builds) is drawn beneath the AR demos when the emulator delivers no camera frame, so AR screenshots no longer land on a black surface.
  • The Android demo tells you what changed since the last build you signed off, not since the last release. Testing the demo app by hand is irregular and several releases can pass between two sessions, so every session used to start by re-testing everything blind. The Samples tab now opens a "What's new since you last tested" sheet listing the cumulative delta — skip three releases and all three are there, grouped by version then by category, with the not-yet-released work merged since the last tag as its own leading section. The ## Unreleased block is synthesised at build time from the changelog.d/ fragments (BundleChangelogAsset), which matters because releases are rare and merges are not: without it, an installed build could never show anything that landed in the window the surface exists to cover. Every HTML comment in a fragment is stripped on the way in, so the maintainer-only notes that #3037 caught one release short of the public notes cannot reach the app either.

"Seen" is a button, never a glance. The marker is written only by Got it; dismissing the sheet leaves it pending and it retreats to a badge on a top-bar action rather than re-opening a modal on every resume. It is keyed on versionCode + versionName — the app versions as 4.31.0-main.<commit>, so the build identity changes on every shipped build while the base version orders releases — and it is stored in SharedPreferences, so it survives an app update. Alongside it live the ids of the entries acknowledged at the time, each a hash of the entry's own text: between two releases a version comparison alone would re-present the entire pending backlog on every build, and because the id follows an entry from fragment to released section, acknowledging something while it is unreleased also keeps it quiet once it ships. A fresh install adopts the running build silently, so the surface arms from the next one instead of dumping the project's history on day one.

Changed

  • One tap-to-place experience, not two (#2482). The AR View tab's "Start AR Camera" flow and the ar-placement demo shared an engine but still shipped two of everything the user touches: two model catalogues (6 entries vs 5), two pickers (a bottom-sheet grid vs a chip strip buried in Settings), two answers to "what will the next tap place?", two reset controls, and one onPlaceModel each. Both now render the same TapToPlaceExperience composable — the same reticle, the same N placed · tap to add pill, the same top-start back arrow (never an X), the same Model · <name> bar and the same picker sheet, applied on both surfaces. Tap-time model resolution (the #2476 invariant) now has a single call site, so it cannot regress on one surface only. The two entry points keep only the differences their roles justify: AR View stays the quick launcher (bundled catalogue, ARCore availability gate, immersive chrome), ar-placement stays the feature demo (bundled + streamed CC-BY Sketchfab catalogue, Snap-to-plane / Show-reticle toggles, QA tracking-failure shim). The demo's auto-cycle is gone — a tap places the model the picker names, and the Damaged Helmet leaves the AR View catalogue too, applying #2023's "every placeable is a grounded object" audit to the list it never reached.
  • tree_scene.usdz's grass-prim strip is now reproducible in-repo (#2948). The 2 665 sub-pixel Grass_* prims removed by hand in #2929 (RealityKit's USD import cost scales with prim count: 91.7 s → 2.58 s) existed nowhere as code, so the fix could not be redone or checked — only pinned against drift. tools/strip-usdz-prims.sh is that missing step: it decompiles a USDZ's scene.usdc, deletes every prim matching a given name prefix (brace-balanced, indentation-agnostic), recompiles, and repacks (Stored, 64-byte-aligned data offsets, deterministic ordering/timestamps). Run with --prefix Grass against the shared, unmodified assets/models/usdz/tree_scene.usdz, it reproduced the asset as it stood before this PR byte-for-byte (SHA-256 e6a359d5…, the same checksum #2947 pinned). assets/catalog.json's note now points at this script instead of .claude/scripts/sync-assets.sh, which no longer exists (removed with the rest of the harness layer in #3244). Android demo app: DemoScaffold is now edge-to-edge with a glass chrome — no top app bar, a back / identity-pill / overflow row over the scene, a bottom HorizontalFloatingToolbar dock (dock, dockAccent params) that hosts the Controls item, a tap-to-hide chrome, and an optional previewRes loading cover with an explicit "Still loading…" card after 12 s. The settings FAB and peek chip are gone. Android demo app: the home is now a showcase — a hero card, category filter chips and a grid of media cards, with the online gallery reached from a closing "Browse online models" card instead of a tab (tabs are Showcase · AR View · About). Every demo ships a generated 800×640 light + dark preview image in res/drawable-nodpi/ (Gemini image-to-image from real captures), shown on the home card and as the scene's loading cover. DESIGN.md gains the app type scale, home chip/hero tokens, stage colour, glass-over-media, dock and motion tokens, and a preview-image art-direction section. iOS demo: the app mirrors the Android showcase redesign — a Showcase · AR View · About tab bar, a home with a Model Viewer hero, category chips, search and 5:4 media cards (previews shared with Android), the online gallery behind a "Browse online models" card, a glass chrome (.demoChrome) with back button, identity pill, overflow menu and floating dock replacing the gear FAB, and a Model Viewer with fitted framing, Models / Environment sheets, an animation bar and a "View in AR" handoff. Redesign the Android Model Viewer around fitted framing, a compact action dock, model and environment sheets, animation controls, and an AR handoff. Codex delegation is now documented as a standing orchestration lever (exploration, diff reviews, mechanical implementation) at the lead session's discretion instead of a temporary quota measure, and .claude/settings.json no longer pins the session model, so each session inherits the developer's own model choice (#3290). Android demo: regenerated the eight Damaged Helmet preview pairs (lighting, lighting-lab, fog, camera-gestures, materials, debug-overlay, video-recording, secondary-camera) from the hero reference so every card shows the actual Khronos Damaged Helmet instead of an invented helmet or prop. Android demo home polish (#3308). The search header's X now closes the search in one tap — query cleared, field collapsed, keyboard hidden — with a separate clear icon inside the field; the hero gives way while a query is typed so results stay visible above the keyboard. The category chip row scrolls edge to edge with the grid inset as content padding, so the last chip is no longer flush with the right edge. "Browse online models" gets real artwork — a collage of the bundled model thumbnails under the hero scrim with a globe badge — instead of an icon tile. Play Store phone screenshots re-captured as a four-frame set from the redesigned demo: the showcase Home, Model Viewer, Lighting Lab and Materials — replacing the three black-background frames of the previous design. AR frames wait for a capture with placed content (the emulator never reaches tracking). Website "Try it" Android thumbnail refreshed from the redesigned demo: the Model Viewer helmet replaces the old placeholder capture (whose 2:1 card crop showed a broken glyph), converted to JPEG (1.3 MB PNG → 165 KB). Play Store tablet screenshots (7" and 10") re-captured from the redesigned demo app — Home showcase, Model Viewer, Lighting Lab and Materials — aligning both tablet classes with the phone set shipped in #3321.

Fixed

  • android-demo's Lines & Paths "Stroke Width" slider now visibly changes the on-screen stroke across its whole range (#1432). Filament's LINES primitive is hardware-capped at 1 px, so the demo drives thickness by scaling per-point sphere "beads" laid along the LineNode/PathNode instead. That scale was correctly wired to a DisposableEffect, but it was mapped linearly from 0 to 4× the base radius — roughly the bottom third of the drag produced a sub-pixel scale indistinguishable from no change at all, which read as "the slider does nothing," and the top end ballooned into a chain of oversized balls that dwarfed the line (device QA, Pixel 9). The scale now lerps between a non-zero floor and a lower ceiling (0.6×–2.4× instead of 0×–4×), the base bead radius is slightly smaller, and the line's bead count is higher so beads merge into a continuous stroke well before the slider tops out. Every position on the track now changes something visible, and the default and maximum settings both read as a stroke rather than a string of beads. llms.txt's "Android-only — no port planned or pending" table was missing SplatNode / SplatCloud / rememberSplatCloud (3D Gaussian Splatting) — an AI generating SceneViewSwift code for splat content got no signal the capability doesn't exist on iOS. The row now matches the one already added to cheatsheet-ios.md. The demo-catalog parity ledger (parity-manifest.yml) also pointed its splat-preview gap at #2768, which explicitly parks the RealityKit port as a maintainer decision rather than owning the implementation — it now points at #2646 (item 2 of its scope), which does own it (#2768).
  • device-QA now probes connectivity before trusting a streamed-asset leg (#2959). A qa-clean snapshot that cold-boots with the emulator's radio in airplane mode made every streamed Sketchfab slug silently resolve to its bundled fallback — same file in, same file out, so the round-trip reload path never actually ran while the report still said passed (measured closing #2942). setup-ar-emulator.sh now asserts airplane_mode_on=0 right after boot and repairs it via cmd connectivity airplane-mode disable, logging when a snapshot needs re-seeding with the radio off. device-qa.sh's sketchfab and arcore-cloud sub-legs are no longer gated on key presence alone — a leased emulator that is still unreachable (leased already-running, or the repair failed) now reports an honest skipped naming the missing gate, never a silent pass.
  • cyberpunk_hovercar.usdz's display plinth no longer inflates the iOS demo's auto-framing (#3006). The App Store slot 1 hero shot read as a small subject adrift in empty backdrop: ModelViewerDemo.swift's .framingMargin(_:) fits the camera to the loaded entity's bounding sphere, and that sphere was set by a flat, gently-bobbing Plane_005 display pad baked into the asset — not by the car. tools/strip-usdz-prims.sh --prefix Plane_005 removes that one mesh prim (126 → 125 mesh prims); the car's own 125 meshes and all 15 textures are untouched (byte-identical). Verified on a booted iOS Simulator by swapping the stripped asset into the installed demo's app bundle (#2948's method) and screenshotting the Cyberpunk Hovercar in ModelViewerDemo before/after under qa_mode: the plinth is gone and a pixel diff shows the car itself unchanged, edge-to-edge, with no load errors. The strip script itself had a brace-depth-counting bug that corrupted any prim with an inline xformOp:*.timeSamples = { ... } dict (closed by a bare } with no matching bare {) — this asset's animated transforms hit it on the first run — fixed to track a net brace delta per line instead of bare-line matching. The Flutter iOS SceneView.onTap now actually fires. Two separate bugs, not one: the FlutterPlatformView hosting the 3D and AR viewers registered with Flutter's default .waitUntilTouchesEnded gesture-blocking policy, which — per Flutter's own header doc — lets a platform view's UIGestureRecognizers see the whole touch sequence but never complete recognition, so no tap fired at all; both platform-view factories now register .eager instead. Even with the touch reaching SwiftUI, RealityKit's targetedToAnyEntity() entity hit test still resolved nothing from inside a Flutter platform view — measured directly, side by side with a plain untargeted tap that fired correctly at the same location — so SceneViewSwift's tap gesture on iOS/macOS now resolves the tapped entity with a manual screen-to-world raycast (Scene.raycast) against the camera it already tracks, instead of depending on that hit test. Verified on an iPhone 17 Pro Max simulator: tapping the Flutter demo's Fox model now shows "Tapped: khronos_fox"; it did not before either fix.
  • app-store.yml derives the App Store CFBundleVersion from a UTC YYYYMMDDHHMM timestamp instead of github.run_number + 1000. The counter scheme worked (~1288, above the historical 366) but broke on two paths the workflow's own comment named — a run-counter reset on workflow rename, or a historical build above 1288 — and both surfaced as Apple rejections at archive time. The timestamp is computed once in the check job and shared with the iOS and macOS legs through a job output, so both upload streams and the submit step's ASC_EXPECTED_BUILD carry the same number (#3081, #2443).
  • .cursorrules, .windsurfrules and .github/copilot-instructions.md handed Cursor, Windsurf and Copilot a Gradle coordinate 32 minors stale (#3189). All three pinned io.github.sceneview:sceneview:4.0.0 / arsceneview:4.0.0 against a real VERSION_NAME=4.32.0, so every AI assistant reading these files handed a developer a dependency version six releases behind the deprecated 4.0.x line. Bumped to 4.32.0 in all three files (no automated sync exists for this coordinate yet — a future drift is possible until one is added; see .claude/scripts/sync-versions.sh's existing SPM-snippet sweep for the pattern to extend). Also fixed in the same three surfaces: .cursorrules's iOS SPM comment pointed at the archived sceneview-swift mirror instead of the sceneview monorepo; the iOS Swift snippets in .cursorrules and .windsurfrules called SceneView(environment:) and ModelNode(named:), neither of which exists on SceneViewSwift's actual API (SceneView { content in … }.environment(_:) and ModelNode.load(_:) are the real, async, RealityKit-backed shapes) — replaced with a snippet mirroring SceneViewSwift/README.md's own Quick Start; the 3D node-type lists in .cursorrules and .windsurfrules undercounted by two (GeometryNode and RenderableNode exist under io.github.sceneview.node but were missing — 26 listed against 28 real, "46+ node types" corrected to "48+"); and .github/copilot-instructions.md's node-type list named four types that have never existed in this codebase — GeospatialNode, DepthNode, InstantPlacementNode, ArrowNode — replaced with real ones (CapsuleNode, DepthMeshNode, PlacementReticleNode, TrackableNode) not already listed.
  • ar-body-tracker's coaching pill now uses the shared ar-scrim dark scrim instead of a hand-rolled brand-colour pill (#3266 follow-up). Every other AR demo's status pill was migrated to DemoStatusBanner when the AR coaching overlay was redesigned around a near-opaque ar-scrim ground (#3265) — ar-body-tracker still painted its own MaterialTheme.colorScheme.primary / error pill at 82% alpha, exactly the anti-pattern that redesign replaced: a mid-tone brand colour sits close to a lot of real-world luminance. On a Pixel 4a pointed at a lit wall the pill read as a pale, low-contrast lavender instead of legible white-on-dark (device QA). Now renders through DemoStatusBanner with Blocked for a missing pose model or a hard tracking-failure reason, Guidance otherwise.
  • ar-orbital's off-screen arrows no longer stack into an illegible blob when two orbiting objects align behind one another (#3269 follow-up). Each arrow was placed by casting a ray from the screen centre toward the target's true bearing and clamping to the viewport edge, independently per target. Two objects with nearly the same bearing from the camera — which happens whenever their orbits transiently align — landed within a few px of each other on the same edge, so both triangle glyphs and both distance labels overlapped into unreadable garbled text (Pixel 4a device QA, real ARCore tracking — the emulator couldn't produce this). Arrows on the same edge are now spread apart by a minimum on-screen gap before drawing; each arrow's rotation still points at its target's exact true bearing, only the drawn position is nudged, the standard trade-off radar-style HUDs make to keep clustered indicators legible.
  • ar-scene-semantics's bottom legend no longer wraps a label one character per line (#3274 follow-up). The legend hand-chunked the 11 semantic labels into fixed rows of four inside a non-wrapping Row. That held for "Sky Building Tree Road" and "Vehicle Person Water", but "Sidewalk Terrain Structure Object" — the widest row — didn't fit the band left free by the Settings FAB reserve on a real device: a bounded Row doesn't overflow visibly when it runs out of room, it squeezes the last child's max-width constraint down instead, so "Object" wrapped one character per line (Pixel 4a device QA). The legend now uses a FlowRow that measures each swatch+label pair as one unit and wraps whole units to the next line, so a label is never split mid-word regardless of screen width, density or the FAB's reserved band.
  • telemetry-worker and mcp-gateway no longer ship a vulnerable sharp (#3299). Both Cloudflare Workers pin wrangler in the 4.36.0 - 4.119.0 range flagged by npm audit (libvips CVE-2026-33327/33328/35590/35591, inherited via wrangler → miniflare → sharp <0.35.0). npm audit fix could not close it without pulling wrangler past a floor that requires @cloudflare/workers-types v5 — a breaking major bump out of scope here. Both package.jsons already forced several transitive fixes via overrides (undici, esbuild, ws, postcss, …); this adds "sharp": "^0.35.3" to the same block, resolving the CVE at the nested dependency without moving wrangler or @cloudflare/workers-types at all. npm audit now reports zero vulnerabilities in both packages; typecheck and test suites pass unchanged.
  • ar-orbital's off-screen indicator now reads as an arrow (#3304). The glyph was drawn inside a translucent disc wider than the arrow itself, so the silhouette that reached the eye was a circle and the direction was unreadable. It is now a shaft-and-head arrow with a shape-following halo and keyline, in the DESIGN.md AR-overlay guidance accent instead of the theme-dependent Material primary, with its distance label on its own scrim pill. Denying the camera permission no longer throws the user out of the app (#3308). ARSceneView used to react to a denial by opening the system App Info page with a toast, backgrounding the activity with no explanation. It now shows an in-app explanation over the scene — the new ARCameraPermissionOverlay, with a "Grant camera access" button that re-requests — and only offers "Open settings" once the system has stopped asking (shouldShowRequestPermissionRationale is false after a denial), and only on that explicit tap. ARCore exposes the state (onCameraPermissionDenied, isCameraPermissionDenied, retryCameraPermission(), openAppSettings()) and ARSceneView gains a cameraPermissionOverlay slot (null to draw nothing); every existing call site compiles unchanged. Device QA: the disk gate called a helper removed with the agent harness and failed on every CI runner, turning the blocking web leg red on each push; it now checks free space inline. Four demo-app polish fixes from Pixel 9 QA (#3322):

  • The Lighting Lab mode selector's "Environment" segment wrapped onto two lines on a phone-width screen — the segmented buttons no longer reserve space for a selection icon (selection is already shown by color), so all four labels fit on one line.

  • Custom Geometry's shape picker matched its chips against a raw String used as both the display label and the dispatch key into three separately hand-copied maps — a fragile pattern that a future rename or localization pass would silently break. Replaced with an internal Shape enum.
  • The bug-report sheet's note field could end up hidden behind the keyboard with no way to scroll it into view; the sheet now applies imePadding() and brings the field into view on focus.
  • Voice dictation (bug-report note and Point & Ask's question field) cut off after a short pause — both now request longer silence-detection windows from the system speech recognizer so a normal pause for breath doesn't end the recording early.

"Revoir les bounding box" from the same feedback batch is out of scope here and tracked separately. - The demo screens' "more" (⋮) menu no longer opens away from the button (#3323). DemoOverflowMenu — the shared overflow anchored last in the identity row of every sample screen — was a plain Box { GlassIconButton(); DropdownMenu() } with no alignment hint. That button sits flush against the trailing safe-drawing edge on every demo, exactly the layout DropdownMenu is known to mis-anchor (AndroidX b/168594123). The anchor Box now carries Modifier.wrapContentSize(Alignment.TopEnd), the documented Compose fix, so the menu opens attached to the glass button on all screens. - Model Viewer: visible "Surprise me", and the loading cover shows the right theme (#3324). "Surprise me" was a plain text row indistinguishable from its neighbours in the model picker sheet — it now carries the app's AutoAwesome "featured" glyph in a primary-tinted badge, per DESIGN.md's "bold colours for hero elements". Separately, the full-screen loading cover shown while the first Filament frame renders was hardcoded to the dark preview image regardless of the device's actual theme — a light-theme device briefly flashed a visibly mismatched dark image before the live scene took over. It now follows isSystemInDarkTheme(), like every other preview consumer. The default bundled models are intentionally untouched — better references are coming separately. - Android demo snackbars no longer overlap the bottom navigation (#3325). The confirmation shown after using "Send feedback" (and the per-demo "Reset" confirmation) rendered flush with the window edge, covering the bottom NavigationBar/floating dock's buttons in z-order instead of floating clear above them, and read as barely visible as a result. Root cause was two-fold: MainActivity's app-root SnackbarHost had no bottom padding or safe-drawing inset at all, and DemoScaffold's per-demo SnackbarHost went through Scaffold's own snackbarHost slot, which only reserves space for Scaffold's own bottomBar/floatingActionButton — the demo dock is a plain overlay inside content, invisible to that layout pass. Both hosts are now hand-placed, padded clear of whichever bottom chrome applies (SETTINGS_FAB_RESERVED_SPACE, or DemoScaffold's measured dock band) plus WindowInsets.safeDrawing, with symmetric horizontal margins. Verified on emulator-5554 in light and dark mode: before/after screenshots of the "Send feedback" confirmation show the snackbar going from overlapping the bottom nav row to floating as a distinct, centered pill above it. - AR placement now behaves like Scene Viewer (#3326). Dragging a placed model did nothing at all: the model node consumed the move gesture and then resolved it against its parent's collider, and an AnchorNode is a pose with no geometry — so every drag was a silent no-op, on both the AR View tab and the ar-placement demo. The model node no longer takes the gesture (isPositionEditable = false), which routes it to the anchor, the one node that knows how to move in AR: it detaches on move-begin, follows a per-frame ARCore hit test constrained to the same surfaces a tap would accept, and re-anchors on release — translating only, so the object slides across the floor instead of pivoting to face the cast ray. Models are placed at their real-world size (a soldier stands 1.8 m, a toy car is 18 cm) instead of every catalogue row arriving as the same 30 cm object, and pinch-to-resize reports a percentage of that size with a detent — and a haptic tick — at 100% · actual size; the old fixed 0.1f..10f scale band silently rejected the first pinch event on any model whose fitted scale fell below 0.1. A placement now grows into place over 260 ms rather than popping in at full size, and the centre reticle is the consumer-AR ring with a centre dot on lock, cross-fading between its searching and ready states instead of stepping between them on a flickering hit test. The screen also stops shouting: it showed three overlapping affordances at once — two of them saying the same thing in different words, and the plane-discovery pill laid out on top of the model bar on first launch — and now shows exactly one short line in the readable AR scrim (#3295), which goes quiet once the user has placed something. The initialising phase keeps its scrim, but no longer dead-ends into a silent black viewport when ARCore cannot start at all: the scrim dismisses itself after 8 s whether or not a frame ever arrived, so one second later the coaching line now says AR couldn't start — check that ARCore is available and try again instead of nothing. It is gated on the initialising state alone, so a session that starts late can never leave that sentence behind it. PlacementReticleVisual gains an additive alpha parameter so any caller can animate the same transition. - MaterialInstance.setDoubleSided(true) on a createColorInstance material now actually works (#3335, reopens #1426). opaque_colored.mat never declared the doubleSided key, so it compiled without Filament's double-sided capability — setDoubleSided(true) was a silent runtime no-op (Parent material does not have double-sided capability), and GeometryDemo's spinning plane still disappeared for half of every rotation despite #1426's fix. The .mat now declares "doubleSided" : false: per Filament's MaterialBuilder::doubleSided(bool), presence of the key — regardless of its value — is what turns the capability on, while false keeps every existing createColorInstance consumer single-sided and back-face culled by default, so there is no rendering or performance change for callers that never call setDoubleSided. GeometryDemo's plane and TwoDInThreeDDemo's chrome/floor materials (both silently affected by the same gap) now honour the runtime toggle. - AR object-label detections now anchor on the actual object, not a fixed corner of the screen (#3337, follow-up from #3322). ARMLObjectLabelDemo maps each ML Kit detection's bounding-box centre — reported in CPU image pixel space — to a screen-space point for Frame.hitTest. That mapping scaled the centre onto a hardcoded 1000×1000 square instead of the AR surface's real pixel size. On a tall portrait phone (e.g. a Pixel 9 at 1080×2424) that square only spans the top ~41% of the screen, so any object detected in the lower half of the camera frame hit-tested against the wrong point and its label landed off the object. The demo now measures its real surface size via Compose layout and scales against that instead. tools/download-assets.sh no longer embeds a Sketchfab API token; it now requires SKETCHFAB_API_KEY from the environment.

Removed

  • The two orphaned Grass_C textures ship no more (#2948). Grass_C_baseColor_cutoff128.png + Grass_C_normal.jpg (705 KB) belonged to a material zero surviving mesh prims bind (bindings: Bark/Leaves/material/material_3 only) — which is why #2929's strip only shrank the file by 620 KB despite removing 2 665 prims and 173k triangles. tools/strip-usdz-prims.sh now also takes --drop <archive-path> for exactly this case: an already-orphaned file, named explicitly, never auto-detected. Before deleting them for real, both textures were proven visually dead weight — not just unbound on paper — by swapping a stripped copy into the already-installed app bundle on a booted iOS Simulator and diffing full-resolution screenshots of the MultiModelDemo Tree slot before/after: identical SHA-256, ImageChops.difference bounding box None. tree_scene.usdz drops from 14 146 523 to 13 440 458 bytes (−705 065 B, matching the two files exactly).
  • The placement-reticle-preview card is gone from the Android demo catalogue (#3275). "AR Placement Reticle Preview" was a developer screen shipped to users: a non-AR SceneView re-staging the placement reticle on a synthetic floor, with switches for SEARCHING/READY phase and RING/DISC — internal vocabulary, on the Play Store build. Its stated purpose was catching reticle regressions off-device, but no automated check ever consumed it, and its second half (a placed model with a contact shadow) is what contact-shadow-preview exists for. The reticle it previewed is now judged where users meet it, in the canonical tap-to-place experience (#2482). No 3D asset was removed — every model and environment it used is still bundled and still used elsewhere.

v4.32.0 — 2026-08-22

Added

  • sceneview: rememberCameraManipulator(orbitRadius = 2.5f) — a distance-first overload (also on createDefaultCameraManipulator and DefaultCameraManipulator). The camera starts orbitRadius metres from targetPosition along the default 3/4 viewing angle, so with the default target the value is the camera-to-subject distance and the autoCenterContent vector-length trap (#2873) does not apply. Converges on the iOS CameraControls.orbitRadius vocabulary. The pure derivation is exposed as orbitEyePosition(orbitRadius, targetPosition) / DEFAULT_ORBIT_DIRECTION and unit-tested (#2932). The framing lever iOS gained with .framingMargin(_:) now exists at the same level on the other two platforms. Android's SceneView / Scene composable takes a framingPadding: Float = DEFAULT_FRAMING_PADDING parameter so autoFitContent leaves a per-scene amount of air — the same additive fraction CameraNode.frameToContent(padding = …) already used, and changing it re-arms the auto-fit pass. Web's fitToModels() accepts an optional margin multiplier (1.0 keeps the previous 2.5 × radius dolly, clamped to 0.2…10 like iOS), typed in sceneview-web.d.ts. The cross-platform note in llms.txt now names the Web lever and spells out at every call site that Android's padding is additive (0.15) while iOS and Web take a multiplier (1.15), so a snippet ported between platforms does not land at 2.15× the distance. The iOS and web demos now credit every bundled third-party asset where a user can see it, as the Android demo already did. generate-credits.py gained two JSON scopes next to the Markdown ones — samples/ios-demo/SceneViewDemo/Resources/BundledCredits.json (41 assets) and samples/web-demo/site/credits.json (15 assets) — generated from assets/catalog.json and gated by the same --check, so a bundled file nobody declared fails the gate on every platform. The iOS About → Credits sheet lists the bundled models, HDR environments and media ("by author — license", tap to open the source) above the streamed Sketchfab models it already showed; the web demo gets a Credits tab (#credits deep link) rendered from credits.json at runtime.

Changed

  • sceneview: Deprecated the orbitHomePosition parameter of createDefaultCameraManipulator / DefaultCameraManipulator in favour of eyePosition. The name promised a "home" gesture that never existed — it is the camera's initial absolute eye position, nothing returns the camera to it. Behaviour is unchanged; the old name still compiles with a @Deprecated(WARNING) + ReplaceWith quick-fix. The Composable rememberCameraManipulator keeps its original orbitHomePosition parameter name as-is (major version 4 forbids the binary break a rename would cause there); its KDoc now clarifies that orbitHomePosition is the eye position, and points at the new orbitRadius overload for the common case (#2932). Advertise MCP protocol revision 2025-06-18 and expose schema-validated structured output for gateway tools that return structured data.
  • The demo app's animated home background now reads as 3D (#3236). The cloud of flat unlit discs behind the Explore and Samples tabs is replaced by a real scene: lit PBR primitives (spheres, tori, cubes, capsules in the brand ramp) hovering over a ground plane with a perspective grid, casting shadows from an angled key light, staggered in depth so near objects render large and far ones small, fading into theme-coloured fog toward the horizon, with a slow lateral camera dolly for motion parallax. The scrim above it is re-tuned so headers keep their contrast while the scene is actually visible in both light and dark; SSAO and bloom are switched off on this view to keep the frame cost close to the old backdrop.
  • Codex CLI can be delegated to as a second developer (#3238). Contributors working with an AI assistant can hand bounded implementation, independent investigation and adversarial review to Codex through .claude/scripts/codex-delegate.sh, with the new root AGENTS.md carrying the conventions a delegated agent must respect. Write access is confined to an isolated worktree, and the wrapper refuses to run unless Codex is authenticated against a ChatGPT subscription rather than the pay-per-token API. The Android demo app now reads its spacing, radius, motion and layout constants from a single SceneViewTokens object that mirrors DESIGN.md token-for-token, instead of repeating the numbers as literals. Two Material shape roles move as a result, because Shape.kt claimed to follow DESIGN.md while using values it never defined: the large role goes from 28dp to 24dp (radius-lg) and extraLarge from 32dp to 28dp (radius-xl). Cards, bottom sheets, buttons and chips in the demo app render with slightly tighter corners; nothing outside the demo app changes.
  • Redesigned the demo app Explore screen as a spatial gallery. A full-bleed hero stage with a glass search pill replaces the old list header; trending models flow in an asymmetric media-first rail, sources move into a compact chip rail, and the search state now opens the keyboard immediately and shows a single, focused results layout. New glass/scrim design tokens back the treatment in light and dark. Removed the Claude Code harness layer from the repository: agent skills, subagent definitions, slash commands, hooks, the agentic CI workflows and the bulk of .claude/scripts/. What remains is verification (build, tests, emulator QA, release/store pipelines, licence compliance) and the installable SceneView skills under agents/, which are a product surface. CLAUDE.md is now 60 lines of non-obvious facts instead of 263 lines of process.
  • The five ARCore Cloud demos share one "Cloud service unavailable" banner, always in the main AR view. ARCloudAnchorDemo had no shared banner at all — its status pill was ad-hoc Text, and Host/Resolve stayed tappable even when the Cloud key was missing. ARRooftopAnchorDemo and ARTerrainAnchorDemo duplicated the missing/rejected-key branches banner-by-banner. None of the five told ERROR_NOT_AUTHORIZED apart from ERROR_RESOURCE_EXHAUSTED or a plain network outage — all three read as the same silent failure. A new CloudServiceStatus (common/CloudServiceStatus.kt) and its CloudServiceStatusBanner now cover all four causes plus "Geospatial hasn't localised yet" (no VPS lock), and Host/Resolve/Drop/Place controls disable for the whole time the service is unavailable instead of staying tappable. ARCloudAnchorDemo also now checks for network before attempting a Cloud call. @Previews for every state, light and dark, live in common/CloudServiceStatusBannerPreviews.kt.
  • ArcoreCloudDemoGuardTest now enforces the shared banner, not just the shared strings — it fails if a Cloud demo stops computing CloudServiceStatus or stops rendering CloudServiceStatusBanner.
  • android-demo: the in-app bug-report sheet now dismisses itself and shows a confirmation snackbar after a report is sent, instead of staying open with no feedback; a failed send (no app to handle it) now keeps the sheet open with the error shown inline instead of a passing Toast. "Send to GitHub" is now the primary, default action ahead of "Share report". The description field also gets an optional microphone button that launches the system speech recognizer (device locale) and appends the dictated text — hidden automatically on devices with no recognizer available. No new permissions (#3263). The in-AR instructions overlay shared by every AR demo (DemoStatusBanner) is now a dark-scrim coaching pill — white 16 sp text, a leading spinner or severity icon, a hairline border and a soft lift — instead of a flat brand-coloured capsule with 14 sp text, which was hard to read over a live camera feed. Passing null or a blank string now animates the pill out, so a demo whose step is done hides it without extra code (#3265).

Fixed

  • Ensure every push to main evaluates CI path filters against its own parent commit.
  • android-demo: the Shape Extrude sub-mode of custom-geometry no longer clips its triangle, star and hexagon on a portrait phone. It paired orbitHomePosition = (0, 0, 1.5) with a target at z = -1, which reads as a 2.5 m camera but — with autoCenterContent putting the shape on the origin — framed from 1.5 m. The distance is now derived from the frustum in ShapeFraming and pinned by a JVM test, as #2923 did for the geometry demo (#2937). Demo app: the Multi-Model visibility chips and Gallery category chips now fade their overflowing edge, so a chip sitting off-screen reads as scrollable instead of invisible (#2944).
  • android-demo: the streamed-model leak fixed in the Materials demo by #2945 is now fixed in its two siblings too — the Model Viewer's Multi-Model section (which leaked four Models per bundle-chip switch) and Gallery section, and the Orbital AR planets. The three private copies of rememberFileModelInstance are replaced by one shared helper in demo/common, so there is one copy of the load + dispose contract. The helper's doc, and the rememberModelInstance KDoc in sceneview, no longer claim the previous Model is destroyed after the consuming ModelNode detaches: that ordering only holds within one composition and the node lives in the Scaffold's subcomposition — it is safe in either order, and the disposal covers completed switches only (#2954). iOS demo: every curated Sketchfab slug now declares whether its bundled keyless fallback is the same subject as its label or a stand-in (SketchfabSlug.fallbackRole). The 16 slugs whose fallback is a different subject — no vase, mug, crate, table, sofa, camera, statue or plant is bundled — show "Offline placeholder" in the asset-source pill instead of "Offline model", so a keyless build no longer renders a confident wrong subject; a reviewed allowlist test pins every subject-match claim (#2960). iOS demo: the AR placement demos now place each streamed slug at its own scaleToUnits (a coffee mug at 0.10 m, a floor lamp at 1.55 m) instead of a hardcoded 0.3 m, and the attribution caption under every streamed-slug picker credits the model actually on screen — the bundled fallback's own name, author and licence on a keyless build (including CC-BY-NC fallbacks), the Sketchfab author only when the stream really loaded (#2966). The SceneViewSwift node count is no longer a hand-typed number that disagreed with itself: the MCP platform-setup tool said 16, the README, MULTIPLATFORM.md and the docs site said 19, and the tree holds 20 public *Node structs. generate-version.js now counts them from SceneViewSwift/Sources at build time (IOS_NODE_TYPES / IOS_NODE_TYPE_COUNT), the MCP guide and list_platforms use that value, the iOS setup table lists all 20 (it was missing ShapeNode, ViewNode, SpatialAudioNode and AnchorNode), and a test fails if the table and the Swift tree ever disagree. The prose surfaces that cannot read a generated value drop the number instead of restating it.
  • The iOS App Store screenshot docs stop claiming the iPad frames are byte-reproducible across days. Measured on the iPad Pro 13-inch simulator (iOS 26.3): simctl status_bar override --time accepts only the …T09:41:00.000Z ISO form, and that form sets the clock alone (converted to host-local time) without pinning the date, so the iPad status bar always carries the capture date. The README documents the measured limit, keeps the plain "9:41" override, and replaces its pointer to capture-appstore-screenshots.sh — removed in #3244 — with the manual capture procedure (#3004). iOS demo app: 17 more SceneView call sites stop re-creating the RealityView with SwiftUI's .id(_:) — the pattern that intermittently leaves the viewport black on iOS 26 Simulator (#3008). Each scene now stays mounted for its whole lifetime and swaps or rebuilds its content in place via SceneView.contentID(_:): the Explore viewer and gallery (4 sites), Materials, Environment, Lighting, Fog, Dynamic Sky, Movable Light, Gesture Editing, Video Texture, Collision, Shape Extrude, Physics, Double Pendulum, the AR Placement Reticle preview and the Depth Collider simulator fallback. Continuous parameters — fog density, time of day, light intensity, marker visibility, auto-rotation, the HDR environment — are applied to the live scene instead of rebuilding it per slider tick. Materials and Environment also keep the scene mounted while their model loads (spinner in an overlay). The Video Texture demo's Loop toggle now actually replaces the previous quad and pauses its player. The three ARSceneView re-keys (AR tab, AR Lighting, AR Instant Placement) are left as they are: ARSceneView has no contentID, and there the re-key means "restart the AR session". (#3020) The Roborazzi goldens under samples/android-demo/src/test/snapshots/ are now a declared input of the demo's test task, so verifyRoborazziDebug re-runs the comparison whenever a golden changes instead of coming back UP-TO-DATE having read nothing (#3029). CI drops the --rerun workaround, and DEMO_TESTING.md stops teaching the bare command as if it were safe without saying why it now is. Of the three surfaces named in #3034, only this one still exists: impact-check.sh and the automation-map skill were removed with the agent harness in #3244. Every statement of the Apple platform floor now matches SceneViewSwift/Package.swift (iOS 18 / macOS 15 / visionOS 2). The floor moved in #719 but llms.txt on the website, docs.html, structured-data.json, the Copilot instructions, the MCP setup guides, the demo's About screen and the visionOS badges kept promising iOS 17 / macOS 14 / visionOS 1, and two pages still asked for Xcode 15 although Swift 6 and the iOS 18 target need Xcode 16. The root Package.swift also declared .visionOS(.v1) for a target whose light and shadow APIs need visionOS 2. A new .claude/scripts/check-ios-floor.sh compares the podspecs, both manifests and the user-facing docs against the sub-manifest and exits 1 on drift. A model created while its load was being cancelled no longer leaks. ModelLoader.loadModel and loadInstancedModel hop to the main thread to build the Filament asset, and withContext drops that result when the caller is cancelled mid-hop — which is exactly what SceneViewer does on every source swap. The asset is now destroyed on that path, and a model cancelled during resource loading is freed instead of sitting in the loader until destroy(). SceneViewerError's constructor is public, so an app can unit-test its onError handler without a renderer. The self-hosted runner installer gives CI its own GRADLE_USER_HOME. iOS demo: ReflectionProbesDemo now loads the selected environment into its ReflectionProbeNode via environmentTexture(_:) and points the metallic sphere and cubes at the probe, so the probe actually carries an ImageBasedLightComponent and the Intensity slider visibly changes the reflections instead of rebuilding an identical scene (#3158). The MCP server no longer names Filament.js 1.70.2 anywhere. That version was a hand-copied string in the web rendering guide and in the artifact generator, and it was wrong on both counts: it never matched the runtime vendored for sceneview.js (filamentWebsite, 1.70.1) and it was never published on npm, so the jsDelivr URL the generated 3D artifacts loaded (filament@1.70.2/filament.js) did not resolve. generate-version.js now reads filamentWeb and filamentWebsite from gradle/libs.versions.toml at build time; the artifact CDN URL uses the npm pin and the guide names the website runtime, so the next Filament bump propagates without a manual edit.
  • Point & Ask describes whatever you tap, including the prop you dropped (#3187). The tap handler rejected any tap that hit a node, so once the shiba was placed — or an answer was pinned — tapping it did nothing: no ping, no capture, no answer. The node is ignored now and every tap asks. The default prompt is "Describe what I'm pointing at, in one short sentence." and the question field starts blank, instead of prefilling "Is there an animal in this room?".
  • Point & Ask always shows its answer on screen (#3188). Once a tap pinned a world-anchored card, the round was routed only to that in-scene card and the bottom card was forced idle — so an off-screen, edge-on or not-yet-textured card meant the answer was invisible, and so was a failure. The bottom card now shows every round (thinking, answer, failure) and the anchored card is the one that stays in the room. Two more blank-screen paths are closed: a PixelCopy that never calls back fails the round after 3 s instead of hiding the overlays forever, and the availability check reports "Checking Gemini Nano availability…" while it runs and falls through to the "unavailable" banner on an Error (e.g. a missing ML Kit class in a minified build), not only on an Exception.
  • The Kotlin/JS test browser is configured to survive a CI runner, and to say what happened when it does not (#3192). The blocking Build web targets leg died repeatedly with Test running process exited unexpectedly — zero test results and no Karma or Chrome output in the job log — on inputs Gradle reported byte-identical to main. sceneview-core was a bare browser(): no --no-sandbox, no --disable-dev-shm-usage, no activity timeout, no console capture. Both Kotlin/JS modules now carry a byte-identical karma.config.d/browser-hardening.js with a hardened launcher, timeouts sized for a loaded shared runner, and browser-console capture that CI uploads on failure. retryLimit re-attempts browser capture only — the suite is never retried, since a retried suite would hide the flake this exists to make visible.
  • karma.config.d/ is now an input of the Kotlin/JS test task, so a change to it re-runs the tests. Those files are appended verbatim into the generated karma.conf.js, but Gradle tracked neither module's directory as an input: a broken launcher config gave BUILD SUCCESSFUL with jsBrowserTest UP-TO-DATE against a stale generated config. Local only — the task is not cacheable, so CI was never affected — but local Gradle is the only place a Karma config is ever verified.
  • The hosted MCP gateway now declares its 3D-viewer widget in tools/list, not only on tool results (#3192). _meta.ui.resourceUri rode on the result of view_3d_model alone, so a host deciding from the tool list whether a tool has a UI — the MCP Apps convention — never learned the widget existed. The declaration now carries the same ui://widget/3d-viewer.html pointer; non-widget tools gain no _meta.
  • Four JS/TS test suites ran in no workflow at all (#3201). mcp-gateway/ — the paid Worker — carried 17 test files and 187 passing cases that no CI job ever executed, and one of them had silently inverted: it asserted a tool count the public dashboard had outgrown, so it stayed green while the page was wrong and only turned red once the page was corrected. The three vertical MCP packages with their own package.json were in the same state. A new mcp-tests.yml now runs all of them on every pull request that touches mcp/ or mcp-gateway/.
  • mcp/'s own suite was covered by accident, and nothing said so. Until the local harness was removed in #3244, quality-gate.sh ran it behind [ -d "mcp/node_modules" ] from ci.yml's Quality gate (full) job, and that directory existed only because ./.github/actions/setup-mcp sat in the job for the other MCP work. Nothing declared the dependency, the guard's false branch printed nothing — so "did not run" and "passed" were the same output — and because the invocation went through a wrapper script, no reachability audit could see it. That job and that script are gone; mcp-tests.yml is now the only thing running the suite on a PR, invoked by name, on a declared trigger.
  • The path filter for the gateway job covers mcp/** too. mcp-gateway/ imports mcp/src/tiers.ts, mcp/src/tools/index.ts and every mcp/packages/*/src/tools.ts as sources, so a change confined to mcp/ can break its suite; filtering on mcp-gateway/** alone would have reproduced the original gap one directory over. Coverage follows the dependency, not the directory name.
  • check-test-suites-reachable.sh makes the class of defect unrepeatable. It runs as its own job in mcp-tests.yml (the repo-hygiene job it was first written for was removed in #3244), on every PR touching a workflow or a test file. It enumerates every versioned *.test.* / *.spec.* file from disk, folds each to its owning package, and must then say something about every one — so a package added tomorrow and forgotten cannot pass by being absent from a list, which is how a whitelist would have failed in exactly the original shape. It accepts __tests__/ as well, a jest default matching neither infix: that arm finds nothing here today and exists because a suite the enumeration misses is the one failure the rest of the gate cannot report — it does not turn red, it leaves the listing, and "0 unreachable" quietly comes to mean "0 found". Reachable means both invoked by a workflow step and triggered when the package changes; a suite that runs but cannot block a merge (|| true, continue-on-error, or no pull_request trigger) is reported ADVISORY rather than counted as coverage. That third verdict caught the gate's own first false green: release.yml really does run npm test, but only on a v* tag, and with no path filter to fail it had made mcp read as covered.
  • The gateway job needed mcp/src/generated/*, and the first CI run is what proved it. mcp/src/generated/{llms-txt,symbols}.ts are deliberately not committed; mcp/'s own prebuild/test lifecycle regenerates them, which is why every other MCP job was unaffected. The gateway follows ../mcp/src/symbols.ts into them, so on a fresh checkout tsc failed with TS2307 on two modules and 8 of the suite's 17 files could not import. Local verification had passed because those gitignored files existed on disk — the same false-green shape this fragment is about, one directory over, in the change that fixes it. The job now runs the three generators first (they import only node: builtins, so no npm ci in mcp/), and both failure modes were reproduced by moving the files aside before the fix and re-measured after.
  • Nine fail-open holes in the gate's own parser, each closed with a fixture and a mutant. Seven of them are one shape: a trigger filter the parser fails to read collapses to an empty glob list, and an empty list means "no filter, fires on everything" — so a path-scoped workflow reads as gating a merge it never runs on. Flow-style paths: ['x/**'], a quoted 'on': key (YAML's Norway problem), paths-ignore dropped instead of inverted, and paths: merged across triggers instead of read from pull_request: alone each produced that list. The other three are neighbours: unordered continue-on-error:, a run: glob word-split against the repo root, and a runner matched as a bare substring, so cat vitest.config.ts counted as running tests. The eighth is the same shape in the function that decides whether a suite blocks at all: fires_on_pr grepped the whole file for a pull_request: line, which a JOB named pull_request satisfies — so a tag-only workflow read as gating merges. It is scoped to the on: block now, at trigger level, with both halves pinned: a job key outside the block, and a workflow_call input named pull_request inside it. The ninth is a filter read correctly and then evaluated wrong: **/*.md reduces to an empty literal prefix exactly as ** does, and the prefix comparison waved both through as covering everything — but * never crosses /, so a markdown-only filter cannot fire on a .test.ts change. An empty prefix is now decided by matching the pattern against the suite's own test files, so ** still answers yes by measurement instead of by assertion. None is reachable from any workflow in the repo today, which is exactly why each needed a fixture rather than a red run — the reasoning for each is in the script beside the code it guards.
  • The bounds that remain are stated in the script's own BOUNDS block rather than left to be discovered, including the exclusions and the one deliberate over-approximation; every run also prints the three most load-bearing of them and points at the block for the rest. Two are worth naming here: a suite launched through a wrapper script is reported MISSING (fail-closed, loud, fixed by naming it in an explicit step), and || true stays an unanchored match where continue-on-error: could be anchored — one is a YAML key with a fixed position, the other is the exact shape being looked for, so the cost is a spurious ADVISORY and never a missed gap. Two other bounds were removed rather than written down: the bare-scalar on: pull_request trigger form, and colon-suffixed npm scripts (npm run test:coverage). Neither could produce a false OK — both are fail-closed — but both are ordinary YAML the gate would have judged wrongly, and a bound nobody can trip is cheaper removed than documented.
  • 29 cases and 24 mutants, and the mutation harness itself was rebuilt after it reported a vacuous catch. Comparing exit codes let the || true mutant "pass" while proving nothing, since its fixture exits 0 either way. Each mutant now names a marker that must be present before the mutation and gone after; a marker missing beforehand is VACUOUS, an absent literal INVALID, and one occurring twice AMBIGUOUS rather than silently mutating an arbitrary occurrence. All three verdicts have fired for real on this branch. The bugs they surfaced include a printf | grep -q that under pipefail answered "not found" precisely when it found something (SIGPIPE, 141) — twice, once in the gate and once in its own test.
  • The Scene Mesh and Streetscape demos say why they are not working when the ARCore Cloud API key is missing or rejected (#3210). Both detected a missing key and then showed nothing about it, and neither had any branch for a key ARCore rejects — so a fork without the secret, or a Play build whose key restriction names the upload SHA-1 instead of the App Signing one, looked like a demo that simply did not work. They now sample Earth.EarthState every frame and show the same error-toned banner the Cloud Anchor, Rooftop and Terrain demos already had: "key not configured" when it is absent, ERROR_NOT_AUTHORIZED with the SHA-1 + billing hint when it is present but rejected. The Streetscape pill moves onto the shared DemoStatusBanner, so it no longer reads "all good" in primary blue while blocked. The Rooftop and Terrain banners gain the same rejected-key branch instead of sitting on "Waiting for VPS lock".
  • One source of truth for the ARCore Cloud API key wording. The manifest probe and both messages were copied into five demos; they now live in common/ArcoreCloudApiKey.kt, and ArcoreCloudDemoGuardTest fails if a demo enables Geospatial or Cloud Anchors without using them. StreetscapeGeometryNode now derives per-vertex normals (as tangent quaternions) and a UV0 slot for the ARCore mesh, so lit materials such as rememberMaterialInstance(color = …) shade buildings and terrain by their real surface instead of a fallback normal, and Filament no longer logs missing required attributes (0xb), declared=0x1 for every geometry (#3215). render-tests.yml now runs :sceneview:connectedDebugAndroidTest on a pull request, path-gated to sceneview/**, sceneview-core/**, arsceneview/src/** and the Gradle build files, so an instrumented test added or broken by a PR fails before the merge instead of on whichever push to main came next (#3216). The four advisory legs (demo screenshots, render goldens, AR playback, iOS, web) stay push-to-main + nightly only. On main, render-tests.yml and device-qa.yml no longer cancel the previous run on every merge — each run has its own concurrency group, so every merge reaches a verdict instead of the ~50% that previously ended cancelled (#2917). Cancellation remains only for superseded pushes to the same pull request.
  • Depth Collider balls are thrown in front of the user instead of dropped at their feet (#3217). The spawn point was 50 cm along the camera's view ray plus 50 cm of world-up lift, so with the phone aimed at the floor (as the demo tells you to) the ball started above the user's own position and fell past them. Balls now start 75 cm along the view ray, never closer than 50 cm horizontally, and are thrown at 1.5 m/s along the camera's forward direction towards whatever is being aimed at. Physics and the depth collider are unchanged; DepthColliderSpawnTest pins the geometry on the JVM. The iOS demo still uses the older geometry.
  • sync-versions.sh no longer sweeps website-static/index.html with a blanket find-and-replace. Each version carrier (softwareVersion, the iOS // Version: snippet, the sceneview-web@ CDN pin, Maven coordinates) is rewritten by its own anchored sed, the GA4 stream id check asserts the exact value 14357002837, and a carrier-invariant guard aborts --fix (exit 2, file restored) if anything other than a version string changed in a synced page. The unescaped sweep had rewritten the analytics stream id on every release for 25 releases (#3234).
  • The demo app's light mode no longer renders a dirty grey field (#3237). The animated particle backdrop cleared its render target to black and let a white scrim fade over it, which composites to a flat grey seamed against the status bar. It now clears to the theme surface colour.
  • Demo screens keep their brand palette (#3237). Material You dynamic colour was on by default, so on Android 12+ the wallpaper palette replaced the SceneView blue everywhere and the brand scheme never ran.
  • Every model credit is reachable again (#3237). The credits sheet listed 29 CC-BY entries in a non-scrolling column, so only the first 8 could be read — an attribution requirement, not a cosmetic one. It scrolls, and clears the navigation bar.
  • Status pills, banners and HUDs no longer collide with the status bar, the app bar or each other (#3237). DemoScaffold gains a topOverlay slot that owns the top edge the way bottomOverlay already owned the bottom one, and 27 demos were migrated onto it. 3D and AR content still renders edge to edge.
  • Particles no longer drift across the search field and the source chips in dark mode (#3237). The backdrop's scrim opened up at 30 % of the screen height, which is above the last control on both tabs, so a drifting particle field showed through the controls. The scrim now holds nearly solid down past the "Trending models" header.
  • The (i) button in the Samples app bar works (#3237). It announced "About" to screen readers and did nothing at all: the callback defaulted to a no-op and the only call site never passed one. The argument is now required, so the same omission cannot compile.
  • The status-bar strip uses the SceneView surface colour (#3237). Scaffold paints its container with colorScheme.background, and that was the one surface role the app's colour scheme never passed — so it fell through to the Material3 baseline (#FEF7FF light, #141218 dark) against a #F9F9FF / #111318 page and drew a line across the top of every screen. The XML theme was missing the same roles for the window it draws before the first Compose frame; both are bound now.
  • Smaller demo-app fixes (#3237). Sample subtitles ellipsise instead of being cut mid-word; the internal "In review" badge no longer ships in release builds; the samples top app bar is drawn instead of being invisible; the Geometry demo's plane no longer disappears every half turn; the Settings chip retracts after three seconds as its documentation always claimed; and the About footer has its missing space back. DESIGN.md no longer contradicts itself on radius-lg, which it gave as 24px in its token table and 28px in two prose sentences. The published token bundle had taken 24px and the Android demo app's shape theme had taken 28, so the two disagreed with each other through a source that agreed with neither. A new gate, check-design-token-coherence.py, refuses any inline token value in DESIGN.md that contradicts that token's table row. codex-delegate.sh --new-worktree now provisions a safe local.properties in the worktree it creates, so a delegated agent can configure an Android build without anyone copying the developer's own file across. Only sdk.dir is carried over; every other key keeps its name and loses its value, which is what Gradle needs to configure and what a secret must never be given.
  • android-demo: ar-body-tracker now actually detects a body. The CPU camera image ARCore hands back is in raw sensor (landscape) orientation regardless of the device's display orientation, but the demo's YUV→bitmap conversion never corrected for it — on a portrait phone, MediaPipe's pose model was handed a person lying sideways and almost never found one. PoseLandmarker.detect is now called with an ImageProcessingOptions rotation hint (the same rotation-degrees mapping ar-ml-object-label's ML Kit pipeline already used), so the model sees an upright frame and the existing 2D skeleton overlay — plus the "point the camera at a person" hint shown while nothing is tracked — now actually render the detection (#3266).
  • android-demo: ar-ml-object-label no longer crashes after a handful of detections. The ML Kit success listener runs on the main thread while ARCore's Session/Frame belong to the render thread — the listener was calling frame.hitTest on a Frame reference that was already several updates stale by the time a ~30–80 ms detector pass completed, a cross-thread access to ARCore's non-thread-safe native session. Anchor creation now happens back on the render thread, in onSessionUpdated, against that frame's own current Frame. The demo also now explains, once a label is anchored, that ML Kit's bundled detector classifies only five broad categories (Home good, Fashion good, Food, Place, Plant) — so an indoor scene landing almost entirely on "Home good" reads as expected behaviour, not a bug (#3268).
  • ar-orbital's off-screen arrow now stays up for every orbiting object, continuously, with its distance (#3269). The directional indicator only ever tracked one of the eight planets (the chase target) and went permanently quiet — for that target and every other object — once a first-launch onboarding timeout elapsed or the user had turned toward it once. Off-screen state is now computed per planet every AR frame, so each currently off-screen object gets its own edge arrow, labelled with the live distance to it (metres, one decimal, locale-aware), for as long as — and only as long as — it stays outside the camera frustum. The world-to-screen-edge projection is now a pure function (projectOffscreenTarget) with unit coverage. android-demo: ar-point-cloud no longer renders a silent black screen when tracking fails or the cloud stays empty. Unlike its ar-raw-depth-point-cloud / ar-scene-semantics siblings, it had no onTrackingFailureChanged wiring at all — a lost-tracking session (or a cold-start still resolving its first feature points) rendered nothing, with no on-screen explanation, which read to users as "nothing rendered" (#3270). It now shows the same tracking-failure banner as the other AR demos, plus a "still scanning" hint once the cloud has sat at zero points, while tracking, for more than two seconds. The stuck-at-zero gate is a pure function pinned by a JVM test (PointCloudFeedbackTest). android-demo: ar-raw-depth-point-cloud no longer scatters its points across the wrong part of the screen in portrait. ARCore's raw-depth image is handed back in the camera-sensor frame, which does not follow the display — RawDepthCloud.buildCloud mapped x/y straight onto the screen-space Canvas overlay with no rotation correction, so in portrait the cloud landed 90° off and most points fell outside the visible view (reported as "rotation error... points are not visible enough", #3271). This is the same class of bug already fixed for the false-color depth visualization in #3184; buildCloud now takes a rotationDegrees parameter (derived from the live display rotation, same as ARDepthVisualizationDemo) and re-indexes each point into the rotated output frame. Pinned by four new JVM tests in RawDepthCloudTest (0°/90°/180°/270° plus the invalid-rotation guard). android-demo: ar-scene-semantics now explains itself when the scene classifies as almost entirely UNLABELED — ARCore's Scene Semantics model has no indoor training data, and the overlay shader paints UNLABELED fully transparent, so an indoor session silently showed the plain camera feed with zero on-screen indication of why (reported as "nothing... rendered", #3274). A guidance banner now appears whenever the frame's dominant label is UNLABELED at ≥90%, pointing the user outdoors. The gate is a pure function, SemanticsOverlay.isOutdoorSceneUnclassified, pinned by four new JVM tests.
  • Point & Ask no longer sends Gemini a blank frame, and its in-scene answer stays readable from anywhere in the room (#3276). The composited PixelCopy capture could report SUCCESS while a compositor quirk (already tracked for the bug-report screenshot, #2654) left the Filament SurfaceView — camera plus placed AR objects — out of the read-back: an alpha == 0 hole exactly where the augmented scene should be, silently sent to Gemini as "the model sees nothing". The same transparent-hole guard now runs before every ask; a hole fails the round instead of asking about a blank image, and a warning is logged so a future report can be correlated with logcat. Separately, the world-anchored answer card kept the fixed orientation of the tap that pinned it, so it went edge-on — effectively unreadable — the moment the user moved around it; it now billboards toward the camera every frame and scales with distance (clamped) so the text stays legible whether you're standing close or across the room, over a DESIGN.md scrim instead of a theme-relative surface that could land low-contrast against an unpredictable camera background. 42 npm audit findings resolved across mcp/, mcp/packages/{gaming,interior,rerun}, mcp-gateway/ and telemetry-worker/ (55 → 13); 7 more require a react-native/wrangler major bump and are left for a follow-up.

Tests

  • DemoScaffoldTopBandTest pins the top band at three font scales, and the demo overlay gate now refuses a hand-anchored overlay at either screen edge, in every Kotlin file of the demo app rather than in a list of directories — the app-wide update banner sits in MainActivity.kt, which no earlier version of the gate looked at.
  • DemoBackgroundRoleTest pins background to surface in both schemes, which is what actually keeps the status-bar seam away, and DemoXmlThemePaletteTest pins the XML palette that no static check could see: colorSurface and android:windowBackground must resolve to the SceneView tokens in both light and night, and specifically not to the Material3 baseline.

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.md nobody reads from a phone. The card heads the grid and opens a ModalBottomSheet listing the last three releases' Added / Fixed / Changed / Performance / Removed headlines, plus every demo still marked InReview.

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-depth was 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-filter on push runs 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, so filter: blob:none plus sparse-checkout: .github produce a byte-identical diff from a 3.5 MiB .git. The job's timeout-minutes goes 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 bare Slider, 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 dark surfaceContainer. samples/common now defines LabeledSlider (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) and DemoCategoryAccent (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.py fails the quality gate on a demo that re-invents either — it keys on the palette's shape rather than its hex values, because 0xFF6446CD is 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.swift now 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; DynamicSkyDemo and MovableLightDemo are deliberate compositions rather than copies of the shared control and are left as they are. check-demo-design-system.py now refuses the two Swift shapes as well as the Kotlin one, inspecting the block above a track instead of grepping for Slider(, 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.html is 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, so 4.5.0 — meaning 4+any+5+any+0 — matched 43570 inside Stream ID: 14357002837 and rewrote it to 14.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 to 14.31.0. The id is restored to 14357002837, the sweep skips Stream 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.html is written by docs.yml after the tag is pushed, so during a release it correctly holds the previous version; demanding the in-flight one made sync-versions.sh exit 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-*.md instead of leaving them drifted (#3234). Those four files are generated from llms.txt, which sync-versions.sh --fix rewrites, so every release used to fail its own pre-push gate on a drift whose only correct answer was always "run the generator". --fix now runs it (sourcing nvm when node is off a non-interactive PATH, and saying so rather than silently skipping when it is genuinely absent).
  • The static site's .filamat blobs are compiled for the runtime that loads them (#2783). The three website-static/materials/*.filamat blobs rode the Android filament pin 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 first createMaterial() added to that page would have failed at load (latent, since the site renders through gltfio ubershaders). They now compile with a dedicated filamentWebsite pin 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 in website-static/js/filament/RUNTIME.json, and .claude/scripts/check-web-filamat-abi.sh fails the quality gate if any web blob, pin, runtime or label diverges again.
  • ViewNode now forwards touch events to its embedded View (#2845). The picked hit point is converted to a view pixel and the whole DOWN → MOVE → UP stream is dispatched into the embedded hierarchy, so Button.onClick, press states, ripples and inner scrolling finally work inside a ViewNode — 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 an ACTION_CANCEL instead of a stuck press; set isTouchForwardingEnabled = false on 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 its ViewNode content consumes — a Material Surface/Card consumes touches even with nothing clickable inside. Apps relying on onSingleTapUp = { _, 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, so hitTest only 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 a ViewNode (and now clicking inside it) is accurate over the whole quad.
  • A ViewNode built with invertFrontFaceWinding = true no 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 visible Cancel in a Row { Button("Cancel"); Button("OK") } no longer fires OK.
  • iOS: turning "Spin scene" off no longer blanks the Multi-Model demo (#2935). SceneView's turntable ran in an un-keyed .task, so it read autoRotate(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 whole SceneView with SwiftUI's .id(_:). That is the renderer-teardown anti-pattern contentID(_:) was introduced to avoid: a rebuilt RealityView on 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: pass 0 to 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.py wrote one file, assets/CREDITS.md, and ci.yml → repo-hygiene plus pre-push-check.sh checked that same one — while the repository tracked five CREDITS.md files. 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.glb and threejs_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.md from the full catalogue, the APK copy from the files genuinely present in samples/android-demo/src/main/assets/, and the two demo audio credits as byte-for-byte mirrors of assets/audio/CREDITS.md (hand-written on purpose — bell.wav is 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, and test-generate-credits.sh — new, wired into repo-hygiene, so pre-push-check.sh leg 19 discovers it too — pins all of that by mutation, including a case that fails if a sixth CREDITS.md is added to the repository without being named in the generator.
  • iOS: ReflectionProbeNode.intensity is applied as the linear multiplier it documents (#2956). The authored value went straight into RealityKit's ImageBasedLightComponent.intensityExponent, which scales by 2^x — so the documented 1.0 default rendered at ×2.0, the same defect #2897 fixed for SceneEnvironment.intensity. It now converts through the same intensityExponent(forMultiplier:). A second bug went with it: a multiplier set before environmentTexture(_:) — including every box(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 derives autoAnimate from QA mode (#2958). Both subject nodes were mounted without autoAnimate, inheriting ModelNode's true default, while QA mode only freezes the orbit yaw — so an animated subject would have made the section's golden screenshots drift. They now pass autoAnimate = !DemoSettings.qaMode like ModelViewerDemo, 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,headRefName emits its whole array as ONE line, and the guard read it with a line-oriented grep -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 --force past it, and --force is 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; without jq the 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 same gh --json | grep shape repo-wide: it found exactly one other instance, issue_has_label in this same file, and the measured verdict is that it is not exploitable — its pattern carries its own quotes, so "in-progress" matches neither in-progress-blocked nor not-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.sh covers 18 scenarios in repo-hygiene, extracting the shipped function out of claim.sh at 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.sh only talks to gh on 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's biome script was invoked by no workflow (grep over .github/workflows/: only rn-ts-check.yml mentioned Biome at all) and took no path argument, so it swept everything in biome.json's files.includes — including, since #3052, the React Native sources, which it then linted with mcp/'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_scene and get_platform_setup handed their callers io.github.sceneview:sceneview:${LATEST_SCENEVIEW_RELEASE} verbatim as the dependency to add, and Android's status line read Stable (v${LATEST_SCENEVIEW_RELEASE}). noTemplateCurlyInString had been reporting it the whole time, to nobody. The baseline is now at zero errors, the scripts are renamed lint / lint:fix and scoped to mcp/src mcp/scripts to match the RN package, and the new mcp-ts-check.yml runs Biome plus tsc on every PR touching the server — with the same file-count assertion as rn-ts-check.yml, because Biome exits 0 when a path silently drops out of the config. Five tests pin the interpolation fix directly: noTemplateCurlyInString is a warning, so the lint job alone would not fail on a relapse. Two things stay open on purpose: the 113 remaining warnings, and website-static/js/sceneview.js — in biome.json's includes, reached by no script now that mcp'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.yml performed 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 single breaking-change-guard job 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.sh wrote 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.log could 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.txt truncated the list under the running loop's open descriptor so the gate printed ✓ 35 gate self-test(s) pass over 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), still 0700 and now 0700 on its parent too, and test-pre-push-log-dir.sh replays the truncation with a control that reproduces the old collapse.
  • A glTF can no longer buy unbounded WebP decode work on Android (#3136). WebPTextureTranscoder already bounded each image's size with MAX_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 @MainThread createModel entry points. A MAX_TRANSCODED_IMAGES = 256 budget now caps it — far above any real model — and everything past it is reported through the existing onUnsupported path, the same contract as an oversized image rather than a silent drop. This mirrors the cap sceneview-web gained in #3133, so the two twins no longer diverge.
  • Agent review no longer reports a claude-code-action startup refusal as a blocking review failure (#3140). When main's copy of pr-review.yml changes 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 red REVIEW_INCOMPLETE pointing 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 explained NOT_EVALUATED that 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, not failed (#3141). A single 900 s bound wrapped the whole catalog.yaml aggregator, so a full android or ios leg could only ever end at rc=124 — three of seven flows on android, one of eight on iOS, every executed step COMPLETED — and that clock verdict was reported with the same word as a real demo crash. lib/maestro.sh now expands an aggregator flow and runs each per-category flow under its own MAESTRO_FLOW_TIMEOUT, and device-qa.sh records a distinct timeout status naming the flow it stopped on, graded with exactly the weight of failed everywhere (a required leg on timeout still 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 the POST /v1/appStoreVersions 409 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 include IN_REVIEW, and withdrawn the previous release from review.
  • workflow_dispatch App Store submissions no longer crash resolving the version. With no tag to read, the program falls back to VERSION_NAME in gradle.properties — but that branch opened with os.environ.get("GITHUB_WORKSPACE", os.path.join(os.path.dirname(__file__), ...)), and Python evaluates a .get() default eagerly. Fed to python3 on stdin as a heredoc, __file__ was undefined, so every manual dispatch raised NameError there even though GITHUB_WORKSPACE was 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.md states "never set EMU_LEASE_TAKEOVER=1. A blocking hook refuses it"; guard 2 of hook-dispatch.sh listed 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.sh pinned the behaviour as deliberate 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 mirror lib/emulator-select.sh exactly" 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-map listed five hooks where settings.json declares two (the other three post-edit hooks and the post-push hook were removed on 2026-08-11) and required a quality gate that contradicted CLAUDE.md's own first rule; cross-platform was loadable for "assessing parity" without naming a single parity tool and pinned every platform at v3.3.0; device-qa hardcoded demo counts that were already wrong and advertised two subjects that live in android-tooling; and CLAUDE.md opened on a link to a gitignored STATE.md, dead in every worktree. sync-versions.sh no longer emits a permanent SKIP row for a CLAUDE.md coordinate 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 main where Detect changed paths never finished now turns the run red instead of reading as "nothing to build" (#3148). Nine ci.yml jobs declare needs: changes; when that job hits its own timeout-minutes it concludes cancelled, all nine report skipped, and GitHub grades skipped as passing. On a PR the CI Gate aggregator catches it, but ci-gate.yml is on: pull_request only — so two pushes to main (266eabc044, f8d7868bfc) landed with nine legs never executed and nothing red, on the branch that deploys to the stores. A new push-side Path filter completed job fails unless changes concluded success. It is guarded on !cancelled(), not always(), so a main run 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 the trackable it was passed and returned getUpdatedTrackables(T::class.java), so if (frame.hasUpdatedTrackable(plane)) never compiled and the obvious reading of the name was wrong. It now returns Boolean — whether that exact trackable was updated this frame. Use getUpdatedTrackables() (or getUpdatedPlanes() and friends) when you want the collection. Source-breaking for any caller that used this misnamed helper as a collection getter — it is an inline extension, so it never appeared in arsceneview.api and 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.log full of w: 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 green apiCheck over foreign code would have passed silently. Two consecutive runs on the same unchanged tree also disagreed (2 CHECK(S) FAILED, then 0 failed, 1 could not run), and a verdict that depends on what else is running is not a verdict. lib/gradle-run.sh now 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 shared gradle_run choke point rather than in the apiCheck leg, 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/caches jars 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 by test-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_mode fallback; the first pass on real hardware (Pixel 4a, 2026-08-14, all 34 AR demos) turned up three. (1) ARSession.configure called super.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 with UnsupportedConfigurationException from ARCore's lighting_estimation_hdr.cc, three lines above the front-camera guard written to prevent exactly that, and ar-face rendered a black viewport behind "The front camera could not be started on this device". The guards now run before the super call. (2) Frame.semanticLabelFraction documented that it returns 0f when SemanticMode.ENABLED is not set, but caught only NotYetAvailableException — ARCore reports "this session has no semantics" as AR_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: 44 FatalException stack traces in nine seconds in ar-people-occlusion, seven more in ar-scene-semantics, while the session itself stayed healthy because configure had already fallen the mode back to DISABLED. It now returns 0f as documented; a genuinely dead session still surfaces through the next session.update(). (3) ARMLObjectLabelDemo throttled 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 (28 ResourceExhaustedException in 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, LightEstimatorConcurrentDestroyTest asserted that no reader may see a non-null texture field after observing isDestroyed — the exact opposite of what destroy() deliberately does, latching the gate before freeing the textures so a late update() bails out instead of racing engine.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. Task has a third terminal state. Without addOnCanceledListener, "released on every exit path" rested on an ML Kit implementation detail — that process(InputImage) exposes no CancellationToken and reports teardown as a failure rather than a cancel — instead of on the Task contract 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 LightEstimator teardown, and the reason three test suites all missed it. The two cubemap-texture setters freed the old Texture and wrote the new one as two separate steps — runCatching { field?.let { engine.destroyTexture(it) } } then field = value. That reads as null-safe, and destroy()'s KDoc leaned on exactly that word to claim idempotency. Null-safety is not atomicity: it makes a repeated sequential destroy() cheap, while leaving two concurrent callers free to read the same non-null texture and both hand it to engine.destroyTexture. LightEstimator.destroy() is called from DisposableEffect.onDispose whose 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): SIGABRT in scudo::reportHeaderRace beneath Java_com_google_android_filament_Engine_nDestroyTexture, via LightEstimator.destroy → setCubeMapTexture, on the first run of the existing many_threads_calling_destroy_inParallel_isIdempotentAndCrashFree stress test. Both setters now swap through AtomicReference.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 Filament Engine and so never runs in CI; the pure-JVM mirror models the setter, and its model already used getAndSet — 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 single macos-15 runner, Build Swift Package (iOS) failed at 14:07:45 with no concrete simulator in the available-destination list — only My Mac / Any iOS Simulator Device placeholders — and 16 seconds later Build & test iOS sample demo resolved 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 to IOS_SIM_WAIT_SECONDS, default 180) and then hands xcodebuild a UDID — replacing six independent name=<model> pins across three entry points: three destinations in ios.yml, two in render-tests.yml (on the older iPhone 16, so that non-blocking post-merge leg had more room to rot), and SIMULATOR= in ios-device-qa.sh. Resolving by UDID is the smaller half: ios.yml's own Select Xcode step 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 in repo-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 as echo "IOS_DEST=$(…)" >> "$GITHUB_ENV", and under set -e a command substitution failing inside another command's arguments does not abort — echo exits 0, so the step wrote an empty destination and passed, making the fail-closed guarantee inert in CI. On render-tests.yml, where every xcodebuild is || 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 from caches/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.md stated 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 counting lib/) — 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 two CLAUDE.md lines were its only occurrences — which is the point: it carried no decision, only the cost of being wrong. automation-map remains 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 -q exits on its first match and closes the pipe; a producer still writing then takes EPIPE and returns non-zero, and under set -o pipefail the 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.sh flagged cyberpunk_car.usdz — declared in assets/catalog.json with its CC-BY-4.0 licence and author — as undeclared on one run of pre-push-check.sh, printing printf: write error: Broken pipe on 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 only grep's own status governs. The consequence was not uniform, which is why the form is pinned centrally rather than per script: in validate-demo-assets.sh a 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); in test-context-budget.sh it reports an indexed skill as an orphan; in cleanup-branches-worktrees.sh it means a branch that does have an open PR is not classified OPEN, i.e. the wrong answer lands on the deletion side, and the odds grow with the branch list. test-pipefail-membership.sh pins 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.yml pinned the base SHA before the fan-out, while claude-code-action resolves 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 on M CLAUDE.md, a file the PR never touched. assert-review-tree-clean.sh now 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-visualization demo 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. The llms.txt depth-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 PlaneRenderer applies plane_renderer_shadow.filamat to that same mesh. That material is a shadowMultiplier shadow catcher, and Filament adds TANGENTS to a shadow-multiplier material's required attributes even though the shader is unlit and never names a normal: its MAT_REQA chunk reads 0x3, against plane_renderer.filamat's 0x1. Filament does not fail a build for the mismatch — it logs missing required attributes (0x3), declared=0x1 and 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-frame setMaterialInstanceAt, saturating the 30-line log capture that in-app bug reports attach. The mesh now declares TANGENTS alongside POSITION. 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). updateGeometry pushed the new bounding box to Filament — which is what culling and rendering use — but collisionShape was separate state that kept whatever box the node was built with, and nothing refreshed it. Assign a CubeNode's size after construction and it rendered large while hitTest still reported the old box: a ray through the grown corner found nothing. All eleven updateGeometry overloads were affected (GeometryNode, PlaneNode, CubeNode, SphereNode, CylinderNode, ConeNode, TorusNode, CapsuleNode, LineNode, PathNode, ShapeNode), and ViewNode worst of all — its quad is sized from the measured view, so the collider stayed at Plane.DEFAULT_SIZE's 1 × 1 × 0 forever and a 410 × 420 px card lost its outer ~30 % to hitTest, which is where the buttons usually are. The collider is now re-derived in RenderableNode.setGeometry, the single choke point every overload passes through — hoisting it into GeometryNode.updateGeometry instead would have missed every shape-specific node, since those call setGeometry directly. Assigning Node.collisionShape by hand still opts a node out of the automatic refresh, including a deliberate null, 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() passed getVertices(center, size) while the declaration — and the builder — are (size, center). Size and Position are both Float3 typealiases, 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; Cube was the only one, and a unit test now pins every geometry's update() 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, so samples/android-demo/src/main/.../GeneratedDemos.kt — a line the gate writes itself — yielded /android-demo/src/… and graded the repository's own clean run COULD 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 found and 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 loads nvm.sh, so which node returned nothing while node was installed. Resolution order is $NODE_CMD → PATH → Homebrew → nvm's default alias → its newest install, version-sorted so v9 cannot outrank v22, 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:testDebugUnitTest and :samples:android-tv-demo:testDebugUnitTest were 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, and test-ci-parity-gradle-tasks.sh derives 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 written CI-PARITY-EXCLUDE: line.
  • A ViewNode's hand-assigned collisionShape now survives a resize. ViewNode.updateGeometrySize() ended with an unconditional updateCollisionShape(), 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 with collisionShape = null. The refresh now lives in RenderableNode.setGeometry for 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, SceneActionBar at 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: on ARTerrainAnchorDemo the 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 a Row, and DemoScaffold's bottomOverlay slot is now a bottom-aligned Column whose receiver is a ColumnScope, 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. SceneActionBar gains a ColumnScope overload 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 shared DemoStatusBanner replaces the per-demo colour guesswork that had been giving "ARCore is initialising" the same red as "no API key configured". check-demo-bottom-overlay.py refuses 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 on ar-measure and ar-terrain-anchor at 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: peekHeader lets a demo put its own sentence on that chip, and ar-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 left ar-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-in AnimatedVisibility and FEEDBACK_FAB_RESERVED_SPACE are 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.swift file carried // @available false and a destination of AnyView(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.ViewNode is 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 honest DeepLinkPlaceholder through SceneViewDemoApp.onOpenURL's unregistered-id fallback instead of claiming a catalog entry that does not exist. DemoRegistryGuardTests.testNoSamplesCardIsADeadEnd fails the build if an @available false card is ever added back, and parity-manifest.yml records the 20 android-only ids so check-demo-id-parity.sh keeps 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 bundleChangelogAsset task that copies the repo-root CHANGELOG.md into the demo's assets, and registered the output directory by appending it to sourceSets.main.assets.srcDirs. A srcDirs entry 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 missed lintVitalAnalyzeRelease. Gradle refused the build with "uses this output of task bundleChangelogAsset without declaring an explicit or implicit dependency", and Deploy Demo to Play Internal went red on main.

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 a FloatArray, a boxed List<Double> and then a DoubleArray; FloatArray.toLinearSpace() went through a boxed List<Float>. Both now fill their result array directly — one allocation each instead of three and two.

Removed

  • iOS/macOS: CameraControlMode.gimbal is 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 of gimbal. SceneView nonetheless exposed a public .gimbal case documented as "Equivalent to RealityKit.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.py gained 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. PlaneVisualizerAttributeContractTest parses MAT_REQA out of each blob a plane renderer loads, parses the VertexAttribute set 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/test holds TvModelListTest, 5 JVM tests asserting every entry in TvModelViewerActivity.models resolves to a file bundled in one of the module's two asset source dirs — written after five models/*.glb paths shipped without the files. Its own KDoc names :samples:android-tv-demo:testDebugUnitTest as the task that "fails fast on any PR that adds a model entry without bundling the file", but no workflow invoked it: enumerating every gradlew line in .github/workflows/*.yml, the TV module appeared only under assembleDebug. 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 the android path filter gating the unit-test job, so the module was compiled on every relevant PR while its tests were not run — the fix is one task on the existing ./gradlew invocation, 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 regenerated gpt/knowledge-api.md, docs/docs/cheatsheet-ios.md and the sceneview-ios agent skill + cheatsheet all advertised .gimbal as 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. The CameraControlMode block now states outright that RealityKit's CameraControls has no .gimbal, and that .pan / .orbit are the two Apple modes SceneView deliberately implements itself for Android parity. A SceneView.swift TODO(#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.sh already enforced it, but neither the versioning skill nor /version-bump said so — a session that "fixed" the WARN row would point flutter_sceneview: ^X.Y.Z at a version pub.dev does not serve yet, which resolves to nothing and fails flutter 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, and Engine.renderableGeneration(). Destroying a light component through the Engine helper (rather than lightManager.destroy directly) is what keeps every cached LightManager handle honest. renderableGeneration() exposes the renderable-side invalidation counter for code outside sceneview that caches a RenderableInstance of 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-mcp is verified at the version in mcp/package.json — it stays on its own release track.
  • The Maven leg covers sceneview-compose too, 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, and sceneview-compose was 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. An INCONCLUSIVE exits 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 ${{ }} inside run:. 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 correct env: 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 honest INCONCLUSIVE when it lapses, rather than manufacturing a red release out of an OSSRH sync delay.

Fixed

  • Depth occlusion had no visible effect at all (#1617). ARCameraStream built its depth texture with no width/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 — as PixelBufferDescriptor'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 — every ARSceneView remount, 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 through safeRecycleEntity, matching the attachedScene pattern already used by Node.
  • Node.transformInstance and Node.parentInstance no longer go stale after rapid node destroy churn (#2977). Both cache a TransformManager EntityInstance handle on the assumption it's stable for the node's lifetime, but TransformManager compacts 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 a ModelNode's glTF asset) hit this reliably, freezing worldPosition/getWorldTransform reads on nodes whose cache went stale mid-lifetime — parentInstance staleness was worse, since it also feeds the setParent write path. Both caches are now invalidated via an Engine-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 declared permissions: id-token: write but no step ever spent the token: the pub client does not perform the OIDC exchange itself — dart-lang/setup-dart does — and this job uses subosito/flutter-action, which does not. flutter pub publish therefore found no credential, fell back to interactive OAuth and waited on a browser that does not exist on a runner, so flutter_sceneview stayed 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 in failure. The job now exchanges the Actions id-token for a pub.dev credential explicitly (audience https://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_sceneview has 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.log wrote into the plugin directory pub was about to archive — the v4.29.0 run log lists publish.log among the uploaded files. It now goes to $RUNNER_TEMP.
  • Web: WebP-textured glTF/GLB models now render textured (#3085). Filament.js registers no image/webp texture provider, so an asset using EXT_texture_webp — or plain image/webp images — loaded silently untextured. sceneview-web now re-encodes the embedded WebP images to PNG in the browser before the model reaches Filament, exactly like the Android WebPTextureTranscoder does. 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 LightManager and RenderableManager handles no longer go stale after an unrelated component is destroyed (#2991, #3123). LightNode.lightInstance, RenderableNode.renderableInstance and ARCameraStream.renderableInstance cached their handle for the component's lifetime, but LightManager and RenderableManager are the same packed-array store as TransformManager: 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 invalidation Node.transformInstance got in #2978, so the hot read path stays a single Int compare.
  • Engine.safeDestroyEntity now 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.destroyModel likewise now bumps all three, since a glTF asset carries renderable components on every mesh and light components whenever KHR_lights_punctual is present.
  • A release could be blocked by a string no file in the repo is allowed to contain. release-checklist.sh graded an absent io.github.sceneview:sceneview: coordinate in CLAUDE.md as a FAIL, and FAIL is a release blocker. CLAUDE.md carries no dependency snippet and is held at its current size by test-context-budget.sh, so the coordinate is not coming back and the blocker could not be cleared by any change the repo permits — while sync-versions.sh, the single source of truth for all 30+ version locations, grades that same input SKIP. Two gates, one predicate, opposite verdicts. An absent coordinate is now skipped; a CLAUDE.md that does carry one is still compared and a stale one still fails, and README.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: new test-release-checklist-version-checks.sh now runs in ci.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.py pins 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 live release.yml and 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 of pre-push-check.sh, because release.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 a grep for 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.sh is covered by a stubbed, mutation-proven suite (#3021). npm and curl are stubbed on PATH, 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 against latest breaks the backport case, removing the reachable/unreachable distinction loses the UNREACHABLE verdict, 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 checks no longer fails on its own clock. The job was capped at 10 minutes, of which actions/checkout took 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-action refuses to run on a PR that modifies pr-review.yml (an anti-exfiltration guard), and the only trace left on the PR was a red Agent review check 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 the gh 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 says NOT REVIEWED rather than naming the mechanism. test-selfmod-guard.sh asserts 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 clean compares 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 the tee publish.log one fixed in #3130. The file now lives under RUNNER_TEMP. Its marker-comment lookup no longer depends on gh api --paginate --jq returning a single id either: --jq runs once per page, so a match on several pages emitted several ids and built a malformed endpoint. test-selfmod-guard.sh now 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 Gate can no longer pass green over a real failure hidden behind a name collision (#3033). The #2492 latest-run-per-name collapse resolved by name only, so a check run from another workflow sharing a job name could displace a genuine failure. Check runs are now attributed to their workflow file (via the Actions API check_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_core whenever no core check had ever been observed, which is indistinguishable from a docs-only PR — so a read that arrived before ci.yml registered its check runs could conclude green over checks that had not started. The gate now asks the Actions API whether a ci.yml run exists for the SHA and, once one is seen, keeps requiring the core checks. A PR whose paths are all in ci.yml's paths-ignore produces no such run and is unaffected; an unreadable Actions API degrades to the previous behaviour, never looser.
  • A fork-controlled check name starting with -n no longer blanks the gate's diagnostics (#3047). echo "$list" ate such a name as an option; every check-name list is now printed with printf '%s\n'.
  • The CI Gate poll loop is now tested end to end (#3047). The #3018 suites stayed 21/21 green with the fix's own wiring deleted from ci-gate.yml, because they only tested the helper scripts. test-ci-gate-loop.sh extracts the workflow's own poll loop and runs it against scripted Checks API reads with gh, sleep and date stubbed. 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.sh no longer dies without printing a verdict. Under set -euo pipefail a grep that matches nothing exits 1, pipefail promotes that to the command substitution and set -e kills 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 touching sceneview/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 there git diff HEAD is empty and the enclosing guard is false. Pinned by test-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

  • troubleshooting no 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 named destroyRenderable and safeDestroyEntity as "the only two calls that can reindex the array", omitting ModelLoader.destroyModel — which reindexes it natively through AssetLoader.destroyAsset without ever touching RenderableManager from Kotlin. Undercounting the reindex sites is exactly what made the stale-handle bug hard to see in the first place.
  • Engine.safeDestroyEntity no 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 calling withFrameNanos at 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 to true, 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. SceneIsRenderingTest discriminates 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 Scene alias, and documented with the one thing that makes it easy to misuse: while it is false nothing 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: a System.nanoTime() deadline never recomposes when it elapses, so it would pin isRendering to true forever 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 already false at 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. SceneViewSwift has no equivalent either — RealityKit owns its own render loop and exposes nothing to park — and sceneview-web already ships the capability imperatively as startRendering() / stopRendering(). Recorded as a row in llms.txt's "Android-only — no port planned" table so an AI does not generate ARSceneView(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 a DepthPoint, then the depth image directly via Frame.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.md documents 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 PostToolUse reminder hooks that fired after the action they advised on, and were redundant with either a blocking gate or CLAUDE.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. triptych was in that list until a check found /review high invokes it; it stays, and the workflow README now states the rule it was deleted against: live means something invokes it.
  • /release no 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.md 263 → 121 lines and .claude/workflows/README.md 204 → 90, both re-sent on every turn of every session. The Claude Code statusline now shows unsafe work: ●n uncommitted files and ↑n unpushed 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 Gate went red on roughly one PR run in four, with nothing failing (#3005). ci.yml's Detect changed paths job 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 reports cancelled, the eight jobs gated on it report skipped, and the run read as "the path filter matched nothing" on the one required check on main. The checkout now runs only on push, 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 than true/false, so a blank verdict can no longer skip every job and pass as green.
  • check-doc-drift.sh no 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 merged main since 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 / worldToScreen now return null for 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-space w <= 0, which yields a finite, mirrored coordinate on the wrong side of the view — never NaN, so no downstream isFinite check 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, pure io.github.sceneview.math.worldToView(worldPosition, projectionMatrix, viewMatrix) in sceneview-core, which the Android Camera.worldToView and View.worldToScreen extensions now delegate to) guards w <= 0 and reports null, the honest "this point has no view-space position". Source-compatibility note: Camera.worldToView and CameraComponent.worldToView change return type from Float2 to Float2?; View.worldToScreen was already Float2?. 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 per changelog.d/README.md a nullability widening on a public return type is declared breaking and forces a MINOR release, never a patch. worldToScreen callers 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, nodeName was 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, null on React Native) instead of reporting a host.
  • React Native's 3D SceneView.onTap does fire on iOS. It was measured on an iPhone 17 Pro Max simulator with a model rendering: 5 taps on the model, 5 dispatches, nodeName naming the model every time. Every surface that called it unverified or probably broken now states the measured result — llms.txt, its gpt/knowledge-* mirror (regenerated with node tools/generate-gpt-knowledge.js, never hand-edited) and the unpublished .well-known/ copy, the module's README and onTap JSDoc (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 onTap KDoc, the demo README and About tab, llms.txt with both mirrors, and the MCP Flutter snippet. The guidance on those surfaces is unchanged — Flutter's iOS onTap still does not fire.
  • The React Native demo now builds and runs on iOS for the first time. Its podspec declares SceneViewSwift (a pod cannot see the host app's SwiftPM packages), the demo Podfile resolves it from the repo root and re-pins IPHONEOS_DEPLOYMENT_TARGET to 18.0 after react_native_post_install lowers it to React Native's 13.4 floor. The module's README iOS section documented the Swift Package Manager route as the supported one — it never worked, since the module compiles inside Pods.xcodeproj, which cannot see the host project's packages — and now gives the Podfile coordinate the unpublished SceneViewSwift pod needs — pointed at main rather than a tag, because no released tag carries SceneViewSwift.podspec yet and a tagged raw URL 404s. That closes the React Native half of #3072. Three @react-native/* dev dependencies Metro needs were missing. A khronos_fox.usdz is bundled so the Animation tab renders on iOS at all: the demo passed remote .glb URLs 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, and model-viewer is 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 Gate is the single required context on main and a skipped run never reports it, so gh pr merge --auto waited 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.sh now fails any workflow that commits with a CI-skip marker and then asks for gh pr merge --auto. The pair is the bug; either half alone is legitimate. Matched case-insensitively and including the skip-checks: true trailer, 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 review went green on a PR no reviewer had opened (measured on #3109). The step now fails, which is honest rather than blocking: Agent review is advisory, so a red mark there does not stop the merge; it only stops the run from being mistaken for a clean review. review-fanout no 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, taking confirmedErrors to 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 to REVIEW_INCOMPLETE.

Tests

  • DemoRenderingScreenshotTest now 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 the qa_mode badge 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 .glb now 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. saveToDeviceForReview threw FileNotFoundException: EACCES from 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 test covers only the Dart tree, and tools/rn-android-compile is 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.
  • DemoRenderingScreenshotTest validates 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 substring QA, which demo copy and control labels can also contain. Review follow-ups to #3100.
  • pre-push-check.sh now mirrors the blocking CI gates it used to omit: Android ↔ iOS demo-id parity, assets/CREDITS.md drift, the android run content gate, workflow shell-block validation, every repo-hygiene gate self-test (list derived from ci.yml, not copied), and the full quality-gate.sh offline 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 LEGS comment 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 -c prints 0 and exits 1 when it matches nothing, so || echo "0" made the count 0\n0, the numeric test died, and the || branch reported a THREADING VIOLATION on every clean local diff. It was also blind to the multi-line withContext(Dispatchers.IO) { modelLoader.createModel… } shape it exists to catch, since it required both on one line — and it read green in CI, where git diff HEAD is empty and the whole block is skipped. Detection now lives in lib/detect-filament-bg-thread.py with a 10-fixture self-test pinning both directions, wired into ci.yml → repo-hygiene.
  • The same grep -c … || echo defect had a second, worse symptom in the same script: TOTAL=$((TOTAL + N)) on 0\n0 is a shell syntax error, which under set -e kills the enclosing if [ -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 against origin/main with a .kt probe.
  • Because that failure lives in the counting helper rather than in any one detector, the normalisation moved into lib/as-count.sh (sourced by quality-gate.sh) with its own falsifiable guard, test-as-count.sh: it pins every input shape a failing grep -c can produce and both consumption shapes — the comparison (must return PASS, not the false red) and the arithmetic inside a set -e subshell (must not abort the block). Wired into ci.yml → repo-hygiene.
  • The same defect is fixed where it also fed numeric comparisons: ANDROID_DEMO_COUNT in the Android ↔ iOS demo-id parity gate, and the !! count plus six diagnostic counts in quality-gate.sh.
  • A lost checkout no longer reports 40 false gate failures in repo-hygiene. Every gate in that CI job carries if: always() so a PR author sees all hygiene violations in one run — but always() also fired when the job's own actions/checkout never completed, and 40 gates then "measured" an empty working directory and reported failure (run 31516160366: checkout hung, consumed the 10-minute timeout, was cancelled). The gates now carry always() && 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.py evaluates every if: expression in repo-hygiene under seven simulated job states, so the guard is verified by execution rather than by reading the YAML. It pins both directions: a bare always() is refused, and so is a success()-based guard, which would restore the fix-them-one-at-a-time behaviour always() exists to prevent. It also refuses a guard naming a step id: 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 by test-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) whenever git diff HEAD itself failed, because the failure propagated through the pipeline under pipefail; and cross-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.sh now shares lib/as-count.sh with quality-gate.sh instead of carrying its own two counter idioms, only one of which was correct.
  • A bad invocation of lib/detect-filament-bg-thread.py prints its usage text instead of a blank line, pinned by a new assertion in its self-test.
  • automation-map now documents lib/as-count.sh, test-as-count.sh, lib/detect-filament-bg-thread.py and test-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's publish-rn job derives the npm version straight from the git tag, so tagging v4.26.1 would publish a source-breaking @sceneview-sdk/react-native change as a semver patch — the one version class a consumer's caret range picks up without review. .claude/scripts/check-breaking-change-bump.sh refuses that combination. A fragment declares a breaking change with a <!-- breaking --> line or simply by saying so in its public prose (non-breaking and groundbreaking do not count; <!-- breaking: false --> opts out). The check is category-independent — a removed public symbol is as breaking as a changed one — and runs from collate-changelog.sh, from release-fast.yml right after the version input is validated, and from release-checklist.sh §6 — every path that creates a release tag. A tag pushed by hand, bypassing collation, still reaches publish-rn unguarded: the guard reads the fragments, and collation is what consumes them, so there is nothing left to read afterwards.

Changed

  • Flutter: SceneView / ARSceneView now claim taps on Android too. Adding TapGestureRecognizer to 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 a GestureDetector(onTap:) or an InkWell to catch taps around the scene will find those taps now going to the platform view instead. Move that handling to SceneView's own onTap.

Fixed

  • glTF/GLB models whose textures are WebP-encoded (EXT_texture_webp) now load with their textures on Android. Filament's Android prebuilt ships no image/webp decoder and offers no seam to register one, so ModelLoader re-encodes embedded WebP textures to PNG — using Android's own decoder — before handing the asset to Filament, instead of letting it render untextured with only Missing texture provider for image/webp in Logcat (#2305). A model without WebP textures is passed through untouched. WebP kept in separate .webp files beside a .gltf still cannot be converted, and now logs an actionable SceneView error 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 compile for all four Gradle legs (measured 2026-08-10, on a run whose only error was Could not load compiled classes for script '…/init.d/…'). gradle_infra_reason now 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-script is 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.sh was the one Gradle step that never went through gate_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 golden and 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 was Timeout has been exceeded — the per-task 25-minute timeout from samples/android-demo/build.gradle firing, which kills the task before it renders any verdict; re-running the same task on a healthy host gave BUILD SUCCESSFUL in 18s. The leg now uses the same triage as every other Gradle step, and gradle_infra_reason() gained a Timeout has been exceeded row (fixture + mutation case in test-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.sh intercepted exactly one comment shape — the single-line <!-- category: X --> tag — and copied every other line of a fragment into CHANGELOG.md verbatim, 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-demo could not be built for iOS at all — it shipped no Podfile, so flutter build ios generated one targeting iOS 13, below what the plugin required. The demo now commits a Podfile, targets iOS 18, and consumes SceneViewSwift as 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-level SceneViewSwift.podspec for 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 while SceneViewSwift/Package.swift has 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 on SceneViewSwift (pinned ~> 4.27), which is not on the CocoaPods trunk: host apps must add a pod '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 with git cat-file -e vX.Y.Z:SceneViewSwift.podspec on 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 from main while 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's Podfile changed in the same breath or rn-ios-compile.yml's real pod install turns 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 .glb URL on every platform, but RealityKit reads only .usdz/.reality and ModelNode.load(_:) resolves a bundle resource, not a URL — so every iOS load threw into a swallowed NSLog. Sample models now carry a per-platform source; a bundled khronos_fox.usdz renders 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's ModelLoader takes either. The 3D path routes it by setting SceneViewerModel.urlString instead of assetPath — the shared host reads exactly one of the two and checks assetPath first — and the AR path, which has no shared host, routes it through ModelNode.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/ARSceneView declared only pan and scale recognizers, so Flutter kept every tap and the native hit test never ran. (onTap still does not fire on iOS for a separate, documented reason — see the plugin README.)
  • sync-assets.sh addressed a directory that does not exist. Its Flutter paths pointed at samples/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.0 while 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 onTap that iOS never delivers (#3045). llms.txt, its generated gpt/knowledge-*.md mirror, samples/flutter-demo/README.md and the demo's own "Bridge Coverage" page all stated that the 3D onTap is 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.txt is the file an AI reads to generate Flutter code, so the claim shipped as generated onTap handlers 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:collection extension it never declares. viewer_page.dart called Iterable.firstOrNull, which resolves only through a transitive re-export — the shape depend_on_referenced_packages exists to catch. Replaced with plain dart:core.
  • The MCP's Flutter setup guide no longer hands out a pubspec.yaml line flutter pub get cannot resolve. mcp/src/platform-setup.ts interpolated LATEST_SCENEVIEW_RELEASE — the in-flight SDK version — into flutter_sceneview: ^X.Y.Z, emitting ^4.26.0 while 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.js now also emits LATEST_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 to VERSION_NAME is 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 onTap reaches iOS. That bridge routes its iOS 3D tap through the same hostView.onTapEntity hook 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 3D onTap does fire, and every RN surface now states that measured result.
  • The React Native README no longer says SceneViewSwift ships 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 defaultTargetPlatform instead of dart:io's Platform, which made the file uncompilable on Flutter web.
  • sync-versions.sh no longer bumps llms.txt's flutter_sceneview: ^X.Y.Z to VERSION_NAME at 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 the 4.27.0 release (fe4d30b42) proved it is not theoretical: the --fix sweep rewrote the caret to ^4.27.0 while pub.dev's flutter_sceneview had 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 with Unable to find a specification for 'SceneViewSwift'; they now carry the pod '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 TypeScript import into a Kotlin block. It now shows WireframeCubeViewer(), the only public entry point, and states plainly that no io.github.sceneview:sceneview-desktop artifact is published.
  • The Flutter plugin's podspec floor on SceneViewSwift is now enforced. It sat at ~> 4.26 through the 4.27.0 release because sync-versions.sh watched only s.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.27 and registered as a checked, autofixable row.
  • React Native's quickstart stopped implying the iOS 3D onTap works 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.md now carries an explicit <!-- breaking --> marker. It describes a behaviour break in prose without ever using the token breaking, so the patch-level guard would have let it ship in a patch release.
  • The Flutter demo's About tab read v4.26.0 while the SDK shipped 4.27.0. sync-versions.sh only checked that its two slots agreed with each other, so a pair that drifted together stayed green. Both now track VERSION_NAME and the row reads OK rather than WARN.
  • llms.txt and its two mirrors asserted the React Native iOS onTap from 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.txt taught a Flutter install that cannot pod install. The mandatory pod '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.txt taught modelPath: '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's swift_version lagged at 5.9 while the root podspec that declares the sync invariant sets 5.10.
  • Three --fix handlers in sync-versions.sh read a version through an unguarded grep | grep | head pipeline. Under set -euo pipefail a 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's onTap topic 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-release VERSION_NAME. Both the check ($FLOOR.${SOURCE_VERSION##*.}) and its autofix (${SOURCE_VERSION%.*}) took the last dot-segment as the patch, so 4.27.0-rc.1 produced the expected value 4.27.1 — a blocking MISMATCH on a floor that was correct — and --fix would then have written 4.27.0-rc as the floor. Both now strip the pre-release suffix before slicing; table-tested across X.Y.Z, X.Y.Z-rc.N and X.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 onTap payload arrives "on both Android and iOS" from source alone. Both the onTap and TapEvent.nodeName doc comments were walked back pending measurement, then restored to the measured Android+iOS result by #3086 in this same release, in src/index.tsx and the packaged lib/typescript/**/*.d.ts.
  • The MCP Flutter 3D guide branched its asset path on dart:io's Platform.isIOS — the exact import this PR removed from viewer_page.dart, because it makes a Flutter file uncompilable on web. It now uses defaultTargetPlatform, matching both the demo and the guidance this PR added to llms.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 canonical sceneview/sceneview snippet. On the tree that reported the blocker: [FAIL] 15 file(s) → [PASS] 17 tracked file(s) scanned.

  • Discovery runs on git ls-files and 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, and spm guide.md:1 is 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), and CHANGELOG.md / MIGRATION.md are excluded at any depth — as is changelog.d/, which inherits its destination's exemption: a fragment that was a blocker until collate-changelog.sh moved 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.sh pin 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.sh no longer blocks a release note for describing the retired SPM mirror — and no longer lets one ship a pin to it (#3068). The allowlist covered CHANGELOG.md but not changelog.d/, even though collate-changelog.sh merges each fragment into CHANGELOG.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: any sceneview-swift URL outside the allowlist still fails and is named — and now *.sh is scanned at all. It was in no glob, which quietly made the two .sh entries 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.git carries 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 in test-check-sceneview-swift-urls.sh pin 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.txt AI-context file is now served from docs/docs/llms-full.txt instead of a hand-maintained duplicate under website-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.yml no 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 — only device-qa-report.json is actually gitignored, which is one too many — and under bash -e that killed the run before the release branch was ever pushed. The artifacts are now unstaged with git reset instead.

Tests

  • test-collate-changelog.sh gains the confidentiality contract the collator never had a test for: internal notes in three comment shapes must not reach CHANGELOG.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.sh pins 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 a CHANGELOG.md section 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 the DESIGN.md file 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, its website-static/.well-known/ mirror, the regenerated gpt/knowledge-*, the React Native quickstart, the plugin README, src/index.tsx (with the bob-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 in react-native-sceneview.podspec are 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 the RealityView and builds a new one, and a re-created RealityView on 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.
  • SceneViewer gains an onError callback, plus the SceneViewerError type 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 answering null without throwing — the second matters because the threading fix above changed which one a malformed model produces (createModelInstance threw, the suspending loadModelInstance returns null), 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 unvalidated entry.linkName. Both defects are real in the filament-kmp 0.3.0 build-logic, and both are build-time code execution the moment something compiles it. The tree was removed from main by #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 a settings.gradle include lands, and fails from that moment naming both fixes. The remediation is also written into docs/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 into repo-hygiene and pre-push-check.sh, and its failing path is driven on synthetic trees by test-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 matched include("<path>") and was blind to the projectDir = file(...) form Gradle actually uses, so wiring the tree left the gate green.
  • Compose Multiplatform support — new sceneview-compose module exposing a single SceneViewer composable from commonMain, 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 Filament SceneView { }; 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 — SceneViewerBridge lets an iOS app supply the RealityKit renderer, since a KMP module cannot depend on a Swift Package. Gestures are written back into CameraState, so reads stay truthful about what the user did. The reusable @objc UIView wrapper around SceneViewSwift is not written yet; without a registered factory SceneViewer draws a visible notice rather than an empty viewport.
  • ModelSource.Url now rejects non-http/https URLs in commonMain, 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 no settings.gradle referenced, 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 on main. Restoring it is one command, documented in docs/docs/desktop-filament.md.
  • SceneViewerHostView — the reusable @objc UIView around SceneViewSwift. This is the missing half of the sceneview-compose iOS bridge shipped in #3009: the Kotlin side declared it needed a UIView factory, and every app had to write that UIView itself. It now ships in SceneViewSwift, driven entirely by primitives on SceneViewerConfiguration, so a SceneViewerViewFactory is 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. See sceneview-compose/README.md.
  • Four additive SceneView modifiers 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's realityViewCameraControls, which CameraControlMode.none does), and onEntityTapHit(_:) (tap plus a world-space position). The distinct base name is deliberate and was arrived at the hard way: an overload distinguished only by a hit: label does not protect existing call sites, because an unlabelled trailing closure ignores the label — measured, every published .onEntityTapped { entity in } snippet stopped compiling.
  • CameraState is 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 — SceneViewSwift has 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 to pod install, where it names its own cause. Host apps must set platform :ios, '18.0' in their Podfile and build with Xcode 16+.
  • A React Native Android model tap now carries a name — on SceneView and on ARSceneView. nodeName went from always null to 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 read nodeName == null as "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, ARSceneView still reports null for every tap, because SceneViewSwift's ARSceneView exposes no entity hit-test hook (#2051). The type change that accompanies all this is a separate entry.
  • TapEvent.nodeName is now typed string | null — no longer optional (React Native). It was string | undefined in the type and null at runtime (Android has always used putNull), and ARSceneView on iOS built its payload from onTapOnPlane and left the key out entirely: nodeName === null meant "the tap hit no model" on three dispatch paths and undefined meant 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 single rnTapPayload seeded with "nodeName": NSNull(), matching Android's putNull, and all four paths — Android SceneView/ARSceneView (one shared TapEvent.getEventData), iOS SceneView, iOS ARSceneView — always emit the key. One nodeName == null check is now correct everywhere. Dropping undefined from the type is source-breaking under strictNullChecks for code that narrowed with === undefined or assigned nodeName into a string | undefined binding; == null, truthy checks and ?. are unaffected. Because it is source-breaking, it ships in a minor release, never a patch.
  • Flutter's onTap is unchanged, and its "no model" value is still '', not null. The Dart callback stays void 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 a null through. 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 testing nodeName.isEmpty; React Native code should use nodeName == null. On ARSceneView under iOS the Flutter callback does not fire at all, because SceneViewSwift.ARSceneView exposes 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 SwiftUI SceneView inside a UIKit view, then load models into it imperatively" — two independent UIHostingController wrappers, two content roots, two model reconcilers, drifting apart. The 3D path of each now builds a SceneViewerConfiguration and hands it to the same host that sceneview-compose uses; each bridge keeps only what is genuinely its own, its method channel or its prop bag. Their AR paths are untouched: ARSceneView is 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 the nodeName entries in this release.
  • SceneViewerConfiguration gained the four things a bridge cannot do without. models (a list — Flutter appends one at a time, React Native replaces the lot; a per-entry identity is what keeps two copies of one path as two models), cameraControlMode and autoCenterContent (both bridges expose them publicly), and cameraPoseAuthored (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: false detaches the pose rather than merely stopping it from being updated: SceneView applies the first non-nil request it sees, so handing it a default pose still frames the scene, at elevation 15° where CameraControls' 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, so sceneview-compose is unaffected: a configuration with no models is 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 @objc onTap that hands over the SceneTapHit rather than five primitives, plus the model root the hit entity sits inside — the direct child of the content root, which is the entity SceneViewerModel.nodeName was written on, and nil when 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 InputTargetComponent fix, and it is a behaviour change to code that did not ask for it: NodeGesture handlers (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.
  • SceneCameraPose write-through clamps to RealityKit's dolly envelope (1…50 scene units) and to ±85° of elevation, and reports the clamped value back through onCameraChanged. 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.json is harness output, but it was committed by accident in #3050 and never gitignored — and release-checklist.sh takes 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 whose ios leg 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.sh no longer announces a cause it did not establish. A Gradle step that dies because the host is not set up (no local.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 .api diff. apiCheck additionally requires a positive comparison cue from the Kotlin binary-compatibility validator, so a build that dies inside apiBuild is 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) via script_report_failure.
  • Agent review (#3076): the PR reviewers could be handed a two-dot diff — everything main gained since the branch point, reversed — and report it as the author's work. A --depth=1 fetch inside the job grafted .git/shallow onto its own fetch-depth: 0 checkout, origin/main...HEAD stopped 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 by test-pr-diff.sh (hermetic git repos, plus a mutant carrying the old fallback so the assertions have to discriminate).
  • RerunBridge no longer drops the first event after a reconnect (#2777). The bridge shared a single CONFLATED channel across connections, so a disconnect() → connect() cycle could hand the next connection's first event to the writer it had just cancelled: a writer parked in receive is still a registered receiver until its cancellation is actually processed, and with no onUndeliveredElement hook 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 by connect() 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-standing RerunBridgeTest > bridge can be disconnected and reconnected flake (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.yml creates the App Store version, syncs the listing text, then submits for review — and Apple locks the metadata on submission. That left app-store-screenshots.yml with 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 correct promotionalText and a screenshot set four releases stale — the window was real, and nothing was writing in it. The release now calls asc_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.sh now 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.sh reports 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.sh reported 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-level CLAUDE.md and the one-line skill/command/workflow descriptions that ship in every preamble whether or not a body is ever opened. STATE.md and workflows/README.md moved 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 Gallery and Model Viewer no longer go permanently black when you change the model. All three now keep their SceneView mounted — spinner as an overlay rather than an if let that unmounts the scene — and swap subjects through .contentID(_:). Measured on QA-iPhone16-c (iOS 26.3): a subject change used to build two fresh RealityView instances and now builds zero.
  • iOS demo: the asset-source pill no longer tears the scene down the first time it appears. assetSourcePill(_:) branched between overlay(…) and self, which are structurally different views, so the first transition from no-pill to pill discarded the modified subtree — RealityView included. It now applies the overlay unconditionally and drops only the pill. This was measured re-creating AnimationDemo'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.md loaded a model asynchronously into a scene with no .contentID(_:), so the content closure — which runs once, at scene creation, while the model is still nil — never ran again and the viewer stayed empty. It now carries the key plus a model-swap section.
  • flutter_sceneview publishes to pub.dev again, and cannot fail silently (#3011). flutter pub publish --force does not fail when the OIDC credential is missing: it falls back to interactive OAuth, prints an accounts.google.com URL and blocks on Waiting for your authorization… until the job timeout kills it. The job then lands as cancelled, 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 with timeout so a hang is attributable to the step instead of surfacing as a job cancellation, and treats the interactive banner as a hard failure whatever pub exits with afterwards. A new step then verifies the registry actually serves the tag's version, so a future failure mode that ends 0 without 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.md and published 124,999 — the cap minus one — ending mid-sentence inside ### Fixed, with all of ### Tests and ### Docs gone. It is a function of fragment count, not of anything unusual about that release: ### Fixed alone was 73,275 characters. create-release now measures the extracted section and, when it overflows, drops whole trailing ### subsections until it fits and appends a pointer naming what was omitted and linking CHANGELOG.md at 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 Write grant, 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 a DO_NOT_MERGE naming an "uncommitted revert" nobody had made — three times across #3009 and #3015. git restore, git apply and git clean are 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 failed git status left 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 five sv-ci-* agent types whose tools: frontmatter grants Read, Glob, Grep and no shell, so contamination is impossible rather than forbidden: the diff and the verdict file moved out of the repository into RUNNER_TEMP, and the clean-tree assertion demands a checkout byte-identical to HEAD (refined in #3057, which carves out — and asserts — the eight config paths claude-code-action itself restores from the base branch). Measured — dropping Write alone would have changed nothing, since a subagent that still has Bash overwrites a tracked file with one echo. Closes #3016.
  • CI Gate no 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 to GET /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 left pending so the loop broke, left observed_names so the core-check guard read its docs-only signature and disarmed, and left the aggregated set so the cancelled never reached ci-gate-aggregate.sh. The single check that branch protection requires went green over a Compile KMP core that had concluded cancelled, 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 as status: vanished, which the existing pending selector treats as not-completed, so the gate waits instead of concluding; when the check returns its fresh record replaces the remembered one, so a genuine cancelled → re-run → success still 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 own cancel-in-progress restart 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 superseded cancelled is resolved before the ledger ever sees it.
  • sceneview-compose no longer reads model assets on the main thread. ModelSource.Asset went through ModelLoader.createModelInstance(assetFileLocation), which is @MainThread and reads the file on the calling thread — and the caller here is produceState, whose producer runs in the composition's context. The whole asset landed on the main thread. It now uses the suspending loadModelInstance, which reads through Dispatchers.IO and hops back to Main for the Filament JNI call alone. Sibling resolution is preserved, so a multi-file .gltf still loads its external .bin and textures.
  • SceneViewerSpec (iOS) now compares by value, and its model bytes by content. It is the recomposition key the iOS SceneViewer publishes through rememberUpdatedState, 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 through CameraState, therefore handed the Swift renderer a new spec carrying the same model and asked it to apply it again. ModelSource.Bytes already compared its array by content precisely to avoid this; the guarantee was lost the moment the array was unpacked into a ByteArray field, whose own equals is 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 every EnvironmentSource.Color scene paid a synchronous neutral_ibl.ktx asset 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.sh now checks the generated GPT knowledge base. gpt/knowledge-*.md is generated from llms.txt and gated in ci.yml → repo-hygiene, but no local gate ran it — not pre-push-check.sh, not quality-gate.sh, not impact-check.sh. Editing llms.txt therefore 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 to llms.txt turns it red, restoring it turns it green.

  • ModelSource.Asset now rejects any URI scheme, and this closes a hole the threading fix above had just opened. loadModelInstance dispatches on URI scheme, where the replaced createModelInstance(assetFileLocation) went straight to AssetManager.open. So for one commit an app resolving a deep link or a server-supplied id into ModelSource.Asset could be handed content:// (reading a private ContentProvider under its own uid), file:// (an arbitrary local read) or https:// (bypassing the timeouts and the 64 MB cap that ModelSource.Url enforces). Url's KDoc already argued this case — "a file:// 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 in commonMain, 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.

  • onError is now always called on the main thread. runCatching sat inside withContext(Dispatchers.IO) on the download path only, so a handler that worked for a failed asset crashed for a failed download with Can'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 in llms.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.sh ran ./gradlew <task> --quiet 2>/dev/null and translated any non-zero exit into one hard-coded diagnosis — so a Gradle build daemon disappeared unexpectedly (daemon contention on the host) was reported as "Android screenshot regression detected", and 2>/dev/null had deleted the one line that said otherwise. Measured 2026-08-06 and reproduced identically on a pristine clone of main, with no golden and no source change involved; re-running the task alone returned BUILD 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 back verifyRoborazziDebug 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's results-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.png images.
  • The CI leg that actually gates merge had the same false green (#3029). ci.yml's unit-test job invoked verifyRoborazziDebug bare, 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.sh names 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 with assembleDebug never having run.
  • React Native (iOS): the pod install of a host app no longer fails on this module's podspec. s.homepage was fed package["repository"] — an object, which CocoaPods rejects outright (Unacceptable type 'Hash' for 'homepage') — and s.platforms claimed iOS 17.0 while SceneViewSwift requires iOS 18.0, so CocoaPods could not resolve the module at all. Both are corrected, and samples/react-native-demo's Podfile, 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.usdz in the Flutter demo on an iOS 26 simulator reported skin0 — the name of an internal mesh — while the same tap on Android reports black_dragon. SpatialTapGesture hands 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 reported hit.entity.name raw, with no walk at all. Android cannot reproduce it, so it was never the reference: the only collider a loaded model owns there is the ModelNode root (glTF child renderables get no collision shape), so its hit-test can only ever resolve to the model. The resolution now lives in SceneViewerHostView, 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 reported nodeName: null for every model tap because nothing ever named the ModelNode, now names each model after its file, so both platforms emit the identical string.
  • nodeName no longer leaks a URL's query string. A model source may be a URL — ModelLoader loads https:// on Android and SceneViewerModel.urlString takes 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.2 derived robot.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, z is the tapped model's world position on both platforms, matching Android's node.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.sh tracked 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 at 4.14.0 while VERSION_NAME reached 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/SceneViewSwift returns Repository not found; SceneViewSwift is a product of the monorepo's root Package.swift, which is what the root README and SceneViewSwift/README.md have 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.1 was latest and the 4.0.x line unpublished, pending #924 and #962 — both closed, with npm latest now 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 root package.json, so npm install github:sceneview/sceneview could never resolve the module under react-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.Z is 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 to VERSION_NAME. pub.dev's newest is 4.24.0 against a 4.26.0 VERSION_NAME, and ^4.26.0 there matches nothing and fails flutter pub get outright — 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 --fix handler, 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 sceneview and sceneview_flutter on pub.dev were "unrelated third-party uploads". Only the second is: pub.dev's sceneview carries this repo's own repository URL 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-existent sceneview/SceneViewSwift URL 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.0 and earlier the package name was sceneview_flutter", but the rename commit (#2735) is first contained in v4.25.0: v4.24.0's pubspec still reads name: sceneview_flutter. Since "the dependency key must match the name at the ref", a git-pin consumer at v4.24.0 following that sentence got a failing pub get. Corrected to "at tags v4.24.0 and 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 across SceneViewManager.kt, ARSceneViewManager.kt, SceneViewEvents.kt, SceneViewModule.kt and ARRecorderModule.kt — was in no CI job and not in the root settings.gradle. It is the same exposure rn-ios-compile.yml closed on the iOS side (#2067), made worse by the fact that the module builds against io.github.sceneview:sceneview:4.7.0, the last published release, which lags VERSION_NAME on 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 new rn-android-compile.yml compiles 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 resolve io.github.sceneview:* against local source and prove the wrong thing. The gate does not trust a green Gradle exit either: it asserts compileReleaseKotlin genuinely executed (not NO-SOURCE, UP-TO-DATE or FROM-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) and compileReleaseKotlin (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 / jvmTarget are 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.json declared "lint": "eslint \"src/**/*.{ts,tsx}\"" while eslint was in neither its devDependencies nor anywhere else in the repo: after a clean npm ci, npm run lint failed with sh: eslint: command not found, exit 127. No workflow invoked it either — the only RN npm script any job ever called was npm run build, inside release.yml's publish-rn, at publish time on a tag. npm run typescript and npm test worked 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__/** and example/src/** are now listed in the root biome.json's files.includes, and lint / lint:fix run Biome from the repo root the same way mcp/ does. The new rn-ts-check.yml runs lint on all three directories, tsc --noEmit on src (that is what tsconfig.json includes) 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.json is dropped silently, and the run still exits 0 as long as any other argument matched — so deleting just the src/** line from files.includes left the job green while the actually-shipped source went unlinted. The job now counts the .ts/.tsx files on disk and requires Biome to report exactly that many. Mutation-tested in both directions: dropping any one of the three includes lines fails the job, and all three passed green without the assertion. Same defect class the Kotlin-side rn-android-compile.yml guards against by asserting its compile task really executed.
  • Kept React a 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 — useImportType on src/index.tsx, noUnusedImports on example/src/App.tsx. Neither is safe here: tsconfig.json sets "jsx": "react", the classic runtime, so every JSX element lowers to React.createElement(...) — verified in the published lib/commonjs/index.js, and independently against both the tsc path (TS1361: 'React' cannot be used as a value) and the babel path (which emits a bare undefined React with no import, a silent runtime ReferenceError). Biome sees the React.FC annotations, 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 under biome.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 disposable macos-15 runner otherwise — for pull_request_target as well as pull_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. The github.event_name terms are equally load-bearing in the other direction: github.event.pull_request is null on push, workflow_dispatch, schedule and workflow_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-action reverts 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 — so git status was 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.sh forgives a restored path only when its bytes and mode equal origin/<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_lints now actually runs on the published Flutter plugin, and a warning in it reddens CI (#3064). flutter/sceneview_flutter — the package published to pub.dev as flutter_sceneview — declared flutter_lints: ^3.0.0 in its dev_dependencies but shipped no analysis_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 (mirroring samples/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: a prefer_const_constructors violation injected into lib/, test/ and example/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 were example/pubspec.yaml declaring models/ and environments/ asset directories that do not exist, which is a hard build failure and not a style nit (flutter build bundle exits 1 with "unable to find directory entry in pubspec.yaml"). They are removed rather than backfilled, because that example is source-only — it has no android/ or ios/ runner, so flutter build apk there 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, the flutter analyze (published plugin) step in the Flutter plugin + demo APK job drops --no-fatal-warnings. That tightening was mutation-tested rather than assumed: an injected asset_directory_does_not_exist warning 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-infos is 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: only flutter/**/.dart_tool/ was gitignored while flutter pub publish ships every non-ignored file, so a single example build put example/build/flutter_assets/* into the publish dry-run tarball and took it from ~1 MB to 16 MB — .gitignore now covers the build output, .flutter-plugins-dependencies and the example's pubspec.lock.
  • CI: pr-review.yml now restores .claude/, .mcp.json, CLAUDE.md and the other five sensitive config paths from the base branch when it runs on workflow_dispatch. claude-code-action performs 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.
  • bytesFileExtension is validated before it reaches the filesystem. The value is public @objc on SceneViewerConfiguration and on the new SceneViewerModel, and it was appended to a temp file name unvalidated. Anything that is not a short ASCII alphanumeric run is now refused back to usdz rather 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.
  • setEnvironment on the Flutter plugin and environment on 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's cameraOrbit prop stays deliberately inert — cameraControlMode supersedes it and wiring both would make them contradict each other — and is now documented as deprecated rather than left looking functional.
  • samples/flutter-demo could not run pod install at 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 unblocks pod install only: the demo still cannot complete an iOS build, because the plugin's Swift is compiled inside the Pods project, which does not see the SceneViewSwift Swift package — the structural gap bridge-ios-compile.yml already documents and works around with a type-check.
  • Entity tap and every NodeGesture handler never fired on iOS. Nodes generated collision shapes — ModelNode.load's enableCollision parameter is documented "for hit testing" — but SwiftUI's targetedToAnyEntity() gestures additionally require an InputTargetComponent, 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 own CollisionHitTestDemo had never been tappable. SceneView now applies it to the whole content subtree (so GeometryNode, MeshNode, TextNode, ImageNode, ShapeNode, ViewNode and PhysicsNode are covered, not just loaded models), ModelNode.load applies it under enableCollision, and NodeGesture registration applies it to the entity it registers on. Measured on the iOS 26.3 simulator: a tap on a loaded .usdz and on an inline GeometryNode.cube produced no callback before and fired on the first try after. This also repairs the Flutter bridge's onTap (#2051).
  • ModelNode.load(from:) accepted any URL scheme. Its documentation says "remote HTTP/HTTPS URL", but URLSession honours file:// — measured: it returns the bytes of a local path, with a response that is not an HTTPURLResponse and 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. Use load(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. timeout is 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 oversized Content-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 SceneView re-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. testBlackViewportProbe drives the AnimationDemo subject 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 unless SV_BLACK_PROBE=1 is set, so it never runs in CI; it also deliberately does not pass -qa_mode 1, because a zero auto-rotate speed short-circuits SceneView'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.sh pins the disappearance case, the returning-check replacement, an unsuperseded cancelled end-to-end through ci-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 three REQUIRED_CHECKS into observed_names and reached column 0 of the Actions log as a workflow command) and one starting with - (which blanked the whole "still running" diagnostic through grep). The suite EXTRACTS the pending selector, REQUIRED_CHECKS and the name-normalisation filter out of ci-gate.yml rather than copying them, so it cannot keep passing against a workflow it no longer matches.
  • Kotlin/Native unit tests now actually run in CI. iosSimulatorArm64Test was 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 an iosTest source set was unexecuted code. A macOS job (self-hosted when awake, macos-15 otherwise) now runs them, gated on a new narrow compose path filter rather than the broad kmp one. SceneViewerSpecTest is 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-compose is now documented in the sceneview agent skill, with the scope boundary (viewer subset, no AR), the ModelSource rules and the per-platform status — a published module absent from the skills is a module future AI sessions do not know exists.
  • The sceneview-compose detekt 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-targeted SpatialTapGesture, which fires only when an entity is hit, so a tap on empty space dispatches no onTap event at all rather than a {0, 0, 0, nodeName: null} one. nodeName == null is 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 on src/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/*.swift builds on SceneViewerHostView, which landed after v4.26.0 — no SceneViewer* type exists at that tag or earlier — so a host app must pin v4.27.0 or newer. Both READMEs now also state that no CI job here catches a stale pin: bridge-ios-compile.yml and rn-ios-compile.yml type-check the bridges against the SceneViewSwift sources 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 though onTapEntity had merely gained a parameter at v4.26.0.
  • sceneViewerModelFileName no longer presents its Kotlin↔Swift divergences as a closed set of two. Measured, at least five inputs derive differently (models/, .hidden, /, .., robot. — the last because deletingPathExtension does 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-drift job runs store-sync/asc_listing.py --dry-run — the first CI caller of the ASC read-only path — and prints a sourceFileChecksum provenance 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 "is sourceFileChecksum really 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 is confirmed. A repo-MD5 match alone reads unattested-match, reported with the display type it was found in; promoting it to confirmed requires 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 --rosetta provisions and boots a separate Pixel_7a_x86 AVD (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 id 0 — so that numbering comes from the emulator's camera HAL, not from the guest ABI; (b) installing the 82 MB ARCore APK kills system_server (Broken pipe), reproduced via both streamed and --no-streaming installs, 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's ARCloudAnchorDemo now runs on iOS: tap a detected plane to drop a single ARAnchor carrying the bundled khronos_lantern, Host it through CloudAnchorNode.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 in onDisappear, the direct analogue of Android's DisposableEffect { onDispose { future.cancel() } } billing hygiene (#1768). ArCloudAnchorsScene flips from stub to @available true / @status knownIssue — matching Android, which is KnownIssue for this id too.
  • Honest unavailable state, no fake success. SceneViewSwift deliberately does not vendor Google's arcore-ios-sdk, and neither does the demo app, so there is no GARSession to 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_KEY path — 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's ARPoseDemo: 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's Axes3DNode) marks it, and three X/Y/Z sliders nudge the bundled khronos_lantern model relative to that anchor with a live coordinate readout. ArPoseScene flips from stub to working.
  • iOS port of the ar-depth-collider demo (#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 via SceneReconstructionNode.enablePhysics (ARKit scene reconstruction / LiDAR), the RealityKit analogue of Android's DepthCollider. 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 own KnownIssue status 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-scene demo — the "one-line tap-to-place AR" showcase for PlacementScene's batteries-included bundle (coaching overlay, a placement reticle, an instant-placement-style raycast, and a contact shadow under each model). PlacementSceneScene.swift wires ARSceneView's equivalent flags (showCoachingOverlay, showPlacementReticle, groundingShadows) and drops a single bundled khronos_damaged_helmet model per tap, with a "models placed" counter and a "Clear All" control — distinct from the existing low-level ar-placement demo, 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, since ARSceneView's plane overlay isn't reactive after scene setup (#2839).
  • iOS port of the wall-placement demo (#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 native ARPlaneAnchor.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.Wall pool is not mirrored. RealityKit's GroundingShadowComponent only projects downward onto a surface below an entity, and SceneViewSwift has no ContactShadow equivalent yet, so the iOS panel ships with no shadow behind it rather than a faked one. Lands inReview, matching Android's own status for this demo.
  • iOS port: AR Placement Reticle Preview (#2841). Ports Android's placement-reticle-preview demo to iOS — a non-AR SceneView (RealityKit, no ARSession, 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 placed khronos_damaged_helmet model grounded with a RealityKit GroundingShadowComponent contact 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.0 puts the content's bounding sphere exactly tangent to the frustum; below 1.0 the subject fills more of a tall portrait viewport. Stay at or above ~0.95 on an autoRotate scene, 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 a showSkybox environment showed none of its sky at any framing (#2896).
  • Blocking CI gate on assets/CREDITS.md. .claude/scripts/generate-credits.py --check regenerates the credits in memory and compares them against the committed file; ci.yml → repo-hygiene now fails when a catalog.json edit lands without regenerating them, so a catalog entry can no longer reach a release uncredited in assets/CREDITS.md. Deterministic regenerate-and-compare, same class as the existing gpt/knowledge-*.md gate. The APK-bundled samples/android-demo/src/main/assets/CREDITS.md stays hand-maintained and outside this gate (#2941).
  • .claude/scripts/context-budget.sh reports the standing context a session pays before doing any work, per file, against each file's documented spec. It complements agent-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.sh gates the committed half in repo-hygiene: a CLAUDE.md ceiling, 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-wide grep for the skill name still passes after its index row is deleted, because the name also appears in the hard-rules pointers.
  • The automation-map skill 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 at versioning, 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 via pr-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 the review-fanout saved 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.sh DECIDES, deterministically, mirroring review-fanout.js so both paths reach the same verdict. It fails closed: a missing verdict file, unparsable JSON, or a dropped reviewer is REVIEW_INCOMPLETE (blocking), because a crashed fan-out produces no findings and would otherwise be indistinguishable from a clean review. A confirmed sv-impact-reviewer error remains the maintainer gate. Fork PRs cannot be reviewed (GitHub withholds secrets, and pull_request_target is deliberately unused) and say so loudly instead of reporting a silent green.
  • Agent token use is now measured — agent-cost-report.sh aggregates 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 on requestId: a transcript writes several records per API call carrying the same usage, 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.yml gains a triage job that runs after the deterministic labeller (never replacing it) and comments duplicate/reproducibility/location/cross-platform findings on newly opened issues; maintenance.yml gains digest-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 a run: step or into the prompt, the agent fetches it with gh and is told explicitly that what it reads is data, not instructions. digest-to-tasks is 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"). The deploy-ios job'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 included preReleaseVersion and selects the iOS build (the build-side twin of the #2731 version-hijack fix, which only filtered the version lookup). (#2731)
  • The contact-shadow-preview demo (Android) now gives the wall-mounted TV its own on-screen beat: the Floor/Wall/TableTop preset 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).
  • ContactShadowControls and the new WallShadowBeat are covered by ContactShadowControlsSnapshotTest (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-check module added by #2808 compiles every ```kotlin block in llms.txt, but the DemoScaffold signature listing added by #2780 references DemoBottomOverlayScope — a type that lives in samples/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 on Build libraries & samples through 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.format call sites across 21 demo files formatted against the device default locale, so a French phone showed Camera distance: 1,5 m, Density: 0,25 and Trajectory 1,80 m on an otherwise English screen. Every format string carrying a locale-sensitive conversion (%f, %e, %g, %d) is now pinned to Locale.US, matching the locale the app's SimpleDateFormat sites already used. Purely textual %s formats 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 onSingleTapUp was bumping the counter without checking the hit node, so empty-space taps counted too. The embedded Compose button cannot count them itself: a ViewNode never receives touch events (#2845). (#2819)
  • iOS demo: two PBR demos now render through the wrapper's studio IBL for catalog + Android parity (#2842). TextureStreamingDemo and OcclusionMaterialDemo built their PBR entities on a raw RealityView, outside the .environment(.studio) path that SceneViewSwift.SceneView installs. They were not unlit — a non-AR RealityView receives 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 in MaterialsDemo with studio_2k.hdr + skybox. Both now build their entities inside the wrapper's content closure and carry .environment(.studio) — the same environment as ModelViewerDemo / MaterialsDemo (#2114) and Android. Because that closure runs once (RealityView's make:), the reactive material swaps (the preset picker, the occluder toggle) now mutate a stashed entity reference from onChange, the pattern already used by MultiModelDemo / MovableLightDemo. Also removes a structural oddity in TextureStreamingDemo (a RealityView overlay stacked on an empty SceneView). 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: ViewNode now documents that its rendered view is not interactive — the hosting window is FLAG_NOT_TOUCHABLE and no touch is dispatched into it, so an embedded Button.onClick never fires. KDoc and llms.txt both show the supported alternative (pick the node from the scene via onSingleTapUp), 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.md described ViewNode as "buttons, lists, animations, all interactive" in two feature tables, and docs/docs/nodes.md recommended 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-preview demo (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-preview demo moved from the Augmented Reality category to Lighting & Environment: the feature lives in sceneview (not arsceneview) 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-preview placeholder 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-sky is the strongest frame (a lit drone against a procedural sky, a theme no other slot carries); multi-model is the only non-helmet, non-sky frame, a photoreal-foliage fidelity shot. Five candidates were captured then dropped after inspection: double-pendulum renders as a tiny linkage in a ~95%-black frame and ignores reframing (its own auto-fit); fog stayed a low-contrast grey helmet even pulled fully in to 1.6 m (centre-variance ~3.6k, under the 4k ship bar); plus the earlier materials (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) and animation (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 via ALIAS_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.sh frames its hero-orbit slots through the camera_distance extra (#2652) rather than their interactive default: model-viewer goes 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, and multi-model is 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-sky shows no sky — with no camera_distance lever 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-check compiles every ```kotlin block of llms.txt, but it only ran transitively inside ci.yml's Build libraries & samples job — which is paths-ignored for llms*.txt, so a PR editing only llms.txt never compiled its snippets and a broken block could reach main (this is how #2871's own fix went un-verified by CI). A standalone snippets-check.yml now 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-facing samples/ios-demo/distribution/app-store/en-US/release_notes.txt instead of deriving it from the technical, cross-platform CHANGELOG.md (which left the field near-empty for Web/Flutter-heavy releases). An empty required whatsNew is rejected by App Store Connect with HTTP 409 ENTITY_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 previous CHANGELOG.md extraction 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 .warm photo studio as a backdrop instead of .studio's living room and frames tighter under qa_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-model is 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_mode now actually freezes auto-rotation. DeepLinkRouter has advertised -qa_mode 1 / ?qa_mode=1 as 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. ModelViewerDemo and MultiModelDemo now honour it; two independent capture runs are byte-identical (measured: 0 differing pixels) (#2896).
  • autoRotate(speed: 0) no longer starts a rotation loop. It set enableAutoRotate = true regardless 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.sh refuses to keep a frame with a system banner in it. simctl has 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.intensity is applied as a 2^x exponent (intensityExponent:), while the presets are authored as linear multipliers (.night 0.4, .nightSky 0.5, .sunset 0.8, .outdoor 1.2) and Android's Environment intensity is linear. That defect pre-exists this change, but it was latent while the IBL never loaded at all; now that it does, .night brightens ×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 the 2897- 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.md is 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 call adb directly, 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 AssetSourceProbe the 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 adds impact-check.sh and 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-qa and android-tooling skills both claimed "QA on an emulator" and neither said which was which. device-qa is the scripted harness and the release gate; android-tooling is 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 rememberFileModelInstance produced a ModelInstance through produceState, which cancels its producer on a key change but never destroys what it already produced — so every chip switch left the previous streamed Model GPU-resident in ModelLoader.models until the section's engine was torn down. It now mirrors the library's rememberModelInstance disposal contract (DisposableEffect(instance) → destroyModel), registered before the consuming ModelNode so 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 isTracking flag that was never assigned, so every tap silently hit-tested nothing. llms.txt and samples/recipes/point-and-ask.md now set it from frame.camera.trackingState in onSessionUpdated; the recipe also declares latestFrame, isTracking and nextId, which it used without ever declaring.
  • contact-shadow-preview peek 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 one shadowVisible value (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.bootanim as a progress signal while its own boot command passes -no-boot-anim, which pins that property to stopped for 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 with pidof system_server + service check activity, and a registered ActivityManager is now accepted as boot success alongside sys.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 in device-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 -u rejects expanding an empty array before bash 4.4): device-qa.sh died with LEGS[@]: unbound variable whenever the disk gate skipped every leg (turning the honest advisory skip into a bogus exit 1 on the self-hosted Mac), and lib/maestro.sh died on device_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, and run_ios additionally requires the positive [ios-qa] PASS marker — an exit-0 harness abort can never grade green again.
  • Demo bottom overlays no longer collide with the Settings FAB (#2779). DemoScaffold gains a bottomOverlay slot 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 same controls != null condition 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-web npm publish no longer fails on a missing npm auth token. actions/setup-node writes an .npmrc containing _authToken=${NODE_AUTH_TOKEN}; the Kotlin/JS :kotlinNpmInstall task shells out to yarn, which expands that file and aborts with Failed to replace env in config when the variable is unset. The publish-web job is the only one combining registry-url with a Gradle task, so its build step now exports NODE_AUTH_TOKEN too. Surfaced by the setup-node v6 → 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.sh gained a --form-factor phone|tablet7|tablet10 path, 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, and play_listing.py's test suite rejects any file there that no imageType claims (#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-viewer re-opened Picking & Collision. The script now does a one-shot pm 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 size override (Override size: 1080x2424 on a 2560x1600 tablet) shrinks every tablet capture to a phone-shaped viewport; the script resets any display override before reading the physical size.
  • android run can no-op the install and still exit 0, so the existing || adb install fallback never fired and the run died on the first am start with no output at all (set -e). The script now verifies pm path <pkg> actually resolves after installing, retries with adb 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-pendulum came out uniform enough that the variance guard rejected it outright. The rotation is derived from wm size rather 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, and check-demo-id-parity.sh loads the file with yaml.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 to iosStatus: working in place, without moving them out of the stub section 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 own iosStatus (the in-place flip that makes both tallies wrong at once), and the preamble's own Of 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 strict androidStatus → iosStatus correlation "with zero exceptions": genuine ports have since landed non-Working Android demos in iOS's working bucket, 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.yml was one-directional — five of them open or refresh a tracking issue daily while their condition holds, and gh issue close appeared 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 shared close-maintenance-issue.sh, which will only ever touch an issue that is open, filed by app/github-actions, labelled maintenance and title-matched. The clear signal is deliberately not the inverse of the open signal: for the two store-drift jobs a 0 exit also means "credentials absent", and a failed npm view yields the same "no lag" as a genuine match, so closing on a bare 0 would 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, while samples/ios-demo/appstore-screenshots/README.md explicitly forbids dispatching it until the frames are re-captured — they predate #2897 and were shot while SceneEnvironment.intensity was fed to RealityKit as a 2^x exponent. Nothing enforced that (the dispatch is manual and asc_listing.py compares 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, with multi-model excluded because a keyless capture build substitutes bundled stand-ins).
  • capture-play-store-screenshots.sh no longer captures a stale build. android run was observed printing No matching components found for type ACTIVITY and still exiting 0, so the script's adb install -r fallback 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 package lastUpdateTime rather 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's EntityManager, 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 entity argument omitted). ModelNode wraps modelInstance.root and its children wrap gltfio node entities, all owned by the AssetLoader — recycling those would let Filament reissue an id a live asset still uses.
  • SplatNode also recycles the per-batch renderable entities it allocates.
  • Node.destroy() now removes its entities from the Filament Scene it 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 optional entity constructor parameter) and Engine.safeRecycleEntity(entity). Both are additive — no existing signature changed, and Node(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.sh leased by pid and dropped the lease in its EXIT trap, but the emulator it provisions deliberately outlives the script — so the next device-qa.sh / qa-android-demos.sh run was handed an AVD another session was actively driving. Leases are now reserved per session and survive the provisioning script (--release hands 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.sh and ar-replay-qa.sh used to pick a running emulator without acquiring it, so a second standalone run drove the same one; they now emu_lease_acquire it (or adopt this session's sticky reservation), refuse one a peer reserved, and release it on exit. ar-replay-qa.sh also 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.md tells agents to drive the emulator with adb install / input tap directly, and no amount of leasing inside the scripts stops that — measured during this work, a sibling session's adb install killed 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). Raw adb is now blocked by a separate mechanism, the #2924 PreToolUse hook, for commands a session issues — not by this change. (#2862)
  • device-qa.sh now grades its android leg on the positive [qa] PASS marker in addition to the exit code, as the iOS leg already did. Holding the pool lease means qa-android-demos.sh installs 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 as passed. Preserving $? inside the trap does not help: the || has already reset it. (#2862)
  • setup-ar-emulator.sh now 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: the geometry demo 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's orbitHomePosition was 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 while autoCenterContent = true has already translated the content onto the world origin, so targetPosition never enters the distance (documented on main in #2930). (0, 0.2, 1.2) against a target at z = -1.5 therefore 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: the geometry demo now honours the camera_distance launch lever (#2652). The extra is read by rememberHeroOrbitCameraManipulator, 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: new GeometryLayout + GeometryLayoutTest pin 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 materials demo 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 declares KHR_materials_clearcoat, KHR_materials_sheen and KHR_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 to khronos_damaged_helmet.glb, which declares no KHR_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 the studio_2k skybox, which — despite its neutral / studio / product tags in assets/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 own scaleToUnits (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 by MaterialsSubjectsTest on 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 the SceneViewSwift build in rn-ios-compile.yml and bridge-ios-compile.yml, ended in | xcpretty … || cat, which defeated set -o pipefail: cat reads 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 an Array first. Surfaced once iOS CI stopped masking the failing tests.
  • CameraNode far clip plane now defaults to a deterministic 1000 m (#2878). PerspectiveCameraComponent() ships with far = .infinity, so the farClip getter's ?? 1000 fallback 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() sets near/far explicitly. Note: geometry beyond 1000 m is now clipped by default on CameraNode; 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 CFBundleVersion and 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 #2885 platform-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 reviewSubmission in 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 signature store-preflight.sh reports 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 a 5xx/408 a 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 a ConnectionError on 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-whatsNew warning names the real state of release_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 SceneEnvironment preset is a Radiance .hdr, and EnvironmentResource(named:) cannot load one — it threw resourceLoadFailure on studio.hdr / outdoor_cloudy.hdr / every other preset, and SceneEnvironment.load() swallowed that into "scene continues with default lighting". So every iOS scene carrying .environment(…) ran with no custom IBL and no skybox: the ImageBasedLightComponent was never set, so the scene fell back to RealityView's own default environment lighting (dim, not unlit — see #2842/#2868), and showSkybox had 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 reads public.radiance natively) and building the resource from the equirectangular CGImage. The named: path is still tried first, so .exr, asset-catalog and Reality Composer Pro resources are unaffected (#2896).
  • iOS/macOS/visionOS: SceneEnvironment.intensity is applied as the linear multiplier it is documented to be. It was passed straight to RealityKit's ImageBasedLightComponent(intensityExponent:), which scales the IBL by 2^x, so every bundled preset rendered at the wrong exposure: .studio 1.0 at ×2.0, and .night 0.4 at ×1.32 — brightening where its authored value asks it to dim to ×0.4. The defect pre-dated #2896 but was latent, because EnvironmentResource(named:) could not load the Radiance .hdr presets and no ImageBasedLightComponent was ever set; #2896 made the IBL load, and with it the wrong unit. The value is now converted with log2 at apply time, so 1.0 is a true no-op and the presets keep their linear authoring. The result is clamped finite for every Float, including NaN and ±infinity, which RealityKit rejects. The KDoc and llms.txt state the unit explicitly (#2897).
  • Note for anyone reading this as a parity fix — it is not one. Android's Environment has no intensity member; its IBL level is Filament's IndirectLight.intensity in 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 that SceneFactories.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.png on .warm (intensity 1.0 → ×2.00, now ×1.00) and 02-dynamic-sky.png on .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 dispatching app-store-screenshots.yml (#2897).
  • dynamic-sky on iOS now demonstrates the sun with a subject that can show it (#3003). The demo built a stylised skyline from five systemGray cubes — 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 loads khronos_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 the framingMargin split model-viewer already had — looser at 0.75, because a helmet is nearly as tall as it is wide and the 13" iPad frame clips it at model-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.intensity was still applied as a 2^x exponent. 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 Jul vs 09:41 Mon 3 Aug), because simctl status_bar override --time does 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-mcp no longer advertises a one-release-old SDK pin to AI agents. mcp/src/generated/version.ts is auto-generated but, unlike its gitignored llms-txt.ts / symbols.ts siblings, committed — and the v4.25.0 release bumped gradle.properties without regenerating it, so LATEST_SCENEVIEW_RELEASE (and the analyze-project android-ok test fixture's SDK pin) stayed at 4.24.0, the version the MCP hands out in its install snippets. Both are regenerated to 4.25.0. The MCP's own npm version (PACKAGE_VERSION) is on an independent track and is left untouched (#1705, #2906).
  • Tooling: sync-versions.sh now verifies LATEST_SCENEVIEW_RELEASE against VERSION_NAME (CRITICAL) and regenerates version.ts + the fixture in --fix. A future SDK bump that forgets the MCP regeneration is now caught by the release pipeline (release-fast.yml re-runs the check for zero residuals) instead of silently shipping a stale pin. PACKAGE_VERSION stays 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.md still 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 --demos sample, 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.sh resolves its demo set per form factor and drops multi-model from 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, because camera_distance moves 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) and double-pendulum, all shot from a 4.23.0 build — are removed, so the next listing sync stops uploading them: play_listing.py selects screenshots by glob, not by count.
  • capture-play-store-screenshots.sh now 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's autoCenterContent pass 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 via DemoMath.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-JVM DemoMathTest cases, 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 stock rememberCameraManipulator, which reads no DemoSettings, so the store script's --ef camera_distance 6.0 never 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-model is captured on tablets again — capture-play-store-screenshots.sh had 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.sh warns when multi-model is captured without a Sketchfab API key. With a key the demo streams the photoreal park oaks; 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 ViewNode WindowManager, 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 ViewNode KDoc (mirrored in llms.txt) claimed there is "no parent to measure against", so fillMaxWidth() has "nothing to fill". The window is WRAP_CONTENT, so the content is measured AT_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-WindowManager sizing rule is now documented alongside it.
  • Device-QA emulator pool: the hold-the-lease guard now also runs on the path setup-ar-emulator.sh itself documents. Both drivers treated a pre-set ANDROID_SERIAL as proof that the caller held the lease, and the script's own printed next-steps tell you to export ANDROID_SERIAL=… — so in the exact workflow it advertises, the guard never ran. qa-android-demos.sh and ar-replay-qa.sh now verify it instead, via a new emu_lease_ensure. (#2921)
  • The obvious fix here — "just call emu_lease_acquire" — is a regression, and the hermetic self-test now pins that. Measured with device-qa.sh as 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_ensure therefore 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.sh now requires one, and keeps the two green paths distinguishable: a real [ar-replay-qa] PASS versus the new GREEN-NO-OP a 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.sh stop sending emu_lease_release_all to /dev/null — that discarded the only evidence a release did the right thing, and || true alone already makes the trap safe. A mis-release is now diagnosable from the artifact bundle. (#2921)
  • Still open, stated plainly: device-qa.sh acquires its own emulator on a best-effort basis (emu_lease_acquire … || true), so the orchestrator itself can still proceed unleased. Raw adb typed by a session is blocked separately by the #2924 PreToolUse hook, 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.sh in a shell that never exported EMU_LEASE_SESSION ends at the third step. Export the token the provisioning script prints — the scripts say so on the refusal path. (#2862)
  • Demo: the materials demo 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 one ModelNode call site an instance that swaps when the chip changes; that re-keys remember(engine, modelInstance) in SceneScope.ModelNode, and the outgoing node's DisposableEffect runs node.destroy(), which walks childNodes and calls engine.safeDestroyEntity on the entities the ModelInstance only borrows. ownsEntity is false, 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 with isVisible.
  • 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.usdz contained 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 in Entity(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 because loadSlot never 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-identical extent arrays — so framing and composition are unchanged. Because assets/ is the source of truth that platform copies are derived from, and the shared original still carries the 2 712 prims, sync-assets.sh would 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 Lamp ar_placement slugs — 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 the tree_scene.usdz fix 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 displayName instead of four hardcoded nouns, on Android and iOS. They read "Tree" / "Bench" / "Dog" / "Bird" while the park registry 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 in SketchfabAssetResolver.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 of cache/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. New SketchfabAssetResolver.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 ModelNode call site entirely, which shifted every later node onto the preceding composition group with a different ModelInstance, re-keyed remember(engine, modelInstance) and ran node.destroy() — destroying the Filament renderable components that the instances only borrow, with no reload possible because they come from a produceState whose keys never change again. Every slot now stays mounted and is hidden with isVisible, 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 via SketchfabAssetResolver.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_placement slugs in SampleAssets.swift all declared fallbackBundledPath: "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.usdz for 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.usdz for Wooden End Table and khronos_damaged_helmet.usdz for 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. The park "Oak Trees" slug, whose tree_scene.usdz fallback 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 fallbackBundledPath is 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. BundledAssetPrimBudgetTests now walks every distinct fallbackBundledPath read 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.md had drifted from its source of truth assets/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 second web-demo record in the catalog (Chronograph Watch itself was already credited under its twin). toy_car also 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. SketchfabAssetResolver staged the offline fallback under a path keyed on uid alone 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 fallbackBundle call sites; Bundle caches 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 from CameraNode.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. ARCameraNode now 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.yml now grants the orchestrator the Task and git tools it needs. They are not in claude-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 as REVIEW_INCOMPLETE with no review-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/checkout defaults to github.ref, which on a workflow_dispatch is whatever --ref said — main — and not the PR named in inputs.pr. The reviewers would have diffed main...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 out refs/pull/N/head, which resolves on the base repo even for fork PRs. The pull_request path 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-action validates 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_INCOMPLETE and 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 new Diagnose a missing verdict file step 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 via SketchfabAssetResolver.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. DemoScaffold gains an opt-in bottomOverlayReservesScene that insets the scene by the measured height of the bottomOverlay band, 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 SketchfabAssetResolver silently 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 an AssetSourcePill — "Streamed (cached)" / "Streaming…" / "Offline model" — driven by AssetSourceProbe, 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 baked SkelAnimation, 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_METERS no 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.sh reassigned the category on each tag line but accumulated the whole fragment into a single buffer, written once at EOF — so a Fixed + Tests fragment 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 to Changed). Two uncollated fragments already carried the pattern and would have misfiled at the next release. Multi-tag fragments are now documented in changelog.d/README.md.
  • The merge grader was being loaded from the code it was grading. pr-review.yml checks out the PR's tree and then ran .claude/scripts/grade-pr-review.sh from 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 the pull_request_target footgun in different clothes, and the self-modification guard did not cover it (it watches pr-review.yml only). The grader is now read from the default branch with git 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 with No such file or directory at 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 allowlisting Bash — 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, no review-verdict.json. A reviewer reads a diff with more than five git subcommands, and every other command was refused.
  • Bash is now bare, and the #2431 constraint it used to encode moved to where it belongs: --disallowedTools denies git 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, no review-verdict.json. The prompt now requires run_in_background: false on 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's max over an empty array is null, and // 0 turns that into 0 — so a scan that found permission_denials_count nowhere 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 reports unknown, 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 so unknown can no longer fall through to "the reviewers ran fine".
  • pr-review.yml now uploads the fan-out's run record as an artifact (7 days) when a review produced no review-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. stagedLooksComplete gated on length() > 0 plus the glTF magic, 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 on boundsAreSane, 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() > 0L makes it fail with the 4-byte file served instead of refused.
  • android-demo: the six ar_placement slugs no longer share bundled fallbacks — khronos_lantern.glb was claimed by three of them and khronos_damaged_helmet.glb by two, so on a keyless build ARPlacementDemo / 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 new SampleAssetsTest case that derives the slug set from the registry by category, mirroring the iOS guard (#2940, #2355, #2973).
  • android_cli_install_and_launch can no longer report success without installing anything. It used to return $? from android run; measured on a real emulator, that command printed App loaded: and Debuggable: 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's lastUpdateTime moved, falls back to adb install -r when 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 by test-android-cli-install.sh against stub binaries — no emulator, no lease — with a mutation test on the stamp check (#2990).
  • impact-check.sh no 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 — .git is 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.sh now 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.sh stops 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_request runs get no secrets, so pr-review.yml structurally cannot spend anything on an outside contributor's PR — but issues: opened and issue_comment fire in the base repo, where secrets are available, and concurrency is 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: most claude.yml runs are skipped triggers 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/COLLABORATOR are 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 .swiftmodule guard — when xcpretty is absent or crashes. Piping xcodebuild into a missing xcpretty gave 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 detect xcpretty first, run xcodebuild raw when it is missing, capture xcodebuild's real exit code in a variable, and test it — never exit inline — so a good build stays green (even on the self-hosted sceneview-mac runner without the gem) and a broken one fails loudly. Same hardening #2878/#2865 gave ios.yml, extended to the two bridge workflows an advisory cross-vendor review flagged.

Tests

  • CI now compiles every Kotlin snippet embedded in llms.txt and the agent-skill references (tools/extract-doc-snippets.js + the new :snippets-check module): 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/WallPlacementScene usage examples passing the placement lambda in the content slot, a nullable rememberModelInstance passed straight to ModelNode, non-generic TrackableNode, v3 arSceneView.frame phrasing, 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 — slerp pre-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: LightManager component count back to baseline (light churn), no surviving Transform component (node-tree + reparenting churn) or Renderable component (geometry churn via CubeNode), 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, no readPixels), so unlike the render package it genuinely executes on the SwiftShader CI emulator instead of skipping. Advisory, not a hard gate: it lands in render-tests.yml's existing continue-on-error job, 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 LightManager and TransformManager probes) fails loudly if a Filament upgrade ever turns the probes into no-ops — so the suite cannot pass vacuously on a dead instrument. CONTRIBUTING.md gains 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 to EntityManager, 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 on main, with the first real compile happening only during an App Store archive. ios.yml now adds a build-only generic/platform=iOS step with CODE_SIGNING_ALLOWED=NO, which needs no certificates and so runs on forks too (#2852).
  • Realigned stale SceneViewSwift tests exposed by the iOS CI fix (#2878). With failures no longer swallowed, CameraControls position tests were updated to the v4.4.0 orbitRadius = 2.0 default (they still asserted the old radius-5 values), and the SceneEnvironment preset 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, in repo-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 dispatching app-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 that AssetInputStream.available() equals what a copy of the same asset writes to disk. If those disagree nothing fails — the fast path simply never matches and every resolve re-copies megabytes on a hot demo path. Mutation-tested too: +1 on the expected length turns it red, which is what proves it measured rather than skipped.
  • test-collate-changelog.sh pins the fragment→category contract in repo-hygiene: single-tag, multi-tag, untagged, unknown category name, and a tag with odd spacing/casing, plus --dry-run immutability. 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.sh pins the above: it fails if pr-review.yml ever runs the grader straight out of the checkout again, or stops reading it from origin/$DEFAULT_BRANCH. Mutation-tested — restoring the checkout-relative invocation takes the suite from 14 passed to 13 passed / 1 failed.
  • test-grade-pr-review.sh fails if the denial scan ever collapses "field absent" into 0 again, or stops reporting an unreadable count as unknown. Mutation-tested: restoring max // 0 takes the suite from 15 passed to 14 passed / 1 failed.
  • iOS demo: repointing an ar_placement fallback back onto a shared asset is now caught by a test (#2940). #2962 split three ar_placement slugs off the shared tree_scene.usdz, but nothing held that fix in place: the registry-driven guard in BundledAssetPrimBudgetTests de-duplicates fallbackBundledPath into a Set — 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. testARPlacementFallbacksArePairwiseDistinct now asserts the category's fallbacks are pairwise distinct, reading the slug set from SampleAssets.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 deliberately ar_placement only — the one category whose demos put several different slugs on screen at once — because other categories share fallbacks legitimately (the four solar butterflies 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 — placedAnchors is 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 to Models/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.sh and test-agent-cost-report.sh pin the two new guards in ci.yml → repo-hygiene, both with a mutation test. Removing the reviewer-count check makes a 3-of-4 review grade MERGE; re-keying the cost dedup from requestId to uuid inflates 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 the continue that feeds the duplicate counter.

Docs

  • ViewNode: documented the gotchas its off-screen window creates — the content inherits no CompositionLocals (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.md and the sceneview agent skill gain the world-anchored variant (hit-test → anchor → ViewNode card, explicit content width, anchor detach contract), including why the "no facing rotation" rule holds only for horizontal-plane and Point hits — 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.js 1.52.3 usably binds only EntityManager.get()/create() — its runtime destroy() is a no-op on the id pool (verified by an in-browser probe: 2000 create/destroy/create yields zero id reuse) and isAlive() is unbound. So the id-recycling that Node.destroy() gains on Android (#2859) has no working Web equivalent until the filament.js pin is bumped. Corrected a comment in the Web SceneView.destroy() that wrongly claimed the camera entity's id was reclaimed, and added a guard note on the EntityManager binding so no future change naively calls the no-op destroy().
  • Fixed the MeshNode / GeometryNode KDoc example, which referenced an undefined renderable variable 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 / CloudAnchorFuture wrapper is real, but Host/Resolve require the app to add Google's arcore-ios-sdk and supply GARSession through the operation: closure — the ios-demo app deliberately does not link it, so its ar-cloud-anchor screen shows a "Preview" badge with Host/Resolve disabled (plane detection + tap-to-place stay live). Replaced a misleading bold "Available" label in cheatsheet-ios.md with "Wrapper only (app supplies GARSession)", added a Cloud Anchor rule to the iOS agent skill, and recorded a capability-caveat reason: on the ar-cloud-anchor parity-manifest.yml row — 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: orbitHomePosition is 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's OrbitManipulator assigns the value verbatim as the eye (mEye = mProps.orbitHomePosition, default (0, 0, 1)) and never re-bases it on targetPosition; and SceneView's default autoCenterContent = true translates 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, and targetPosition (the orbit pivot / initial look-at point) does not enter into it. (0, 0.2, 1.2) against a target at z = -1.5 frames 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 passes cameraNode.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. SceneView never calls Filament's home/bookmark API, and onDoubleTap is a plain callback forwarded to user code. orbitHomePosition is 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, and contentRoot's parent is the scene root. The two coincide only when targetPosition is 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 in llms.txt was wrong about its own platform too. Corrected in both SceneView.kt KDoc blocks, llms.txt, docs/docs/migration.md and the generated gpt/knowledge-*.md.
  • Fixed on every surface an AI reads: the KDoc in SceneView.kt, llms.txt (helper table + a new "How far orbitHomePosition actually puts the camera" section), the generated gpt/knowledge-*.md, website-static/.well-known/llms.txt, and the camera recipe in docs/docs/recipes.md.
  • The CC-BY indicate-changes note names the artefact that actually changed. assets/catalog.json attached the modification record to models/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-identical extent arrays).
  • 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+ (ContactShadowNode from #2817 and SplatNode joined the inventory after the last alignment in #2594), the doc site, the website, the MCP docs and the checked-in marketing/ copy included. .cursorrules and .windsurfrules also named node types that do not exist — GeospatialNode, DepthNode, InstantPlacementNode, absent from the sources and from both public .api dumps — while omitting the real additions; both lists are now generated from the node sources and are exhaustive (26 3D + 20 AR). The website's unqualified 26+ Node types stat is relabelled 3D 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 bare N+ node type, so 41+ built-in node types and 42+ composable node types evaded 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.md and the three marketing/ files, none of which were in its list. Every branch mutation-tested against the files' real content (#2987).
  • The android-tooling skill no longer recommends calling android run directly; it documents the measured misbehaviour and points at the helper. Measured on CLI 1.0.15498356: --no-metrics is 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 flagship docs/docs/try.md quickstart, samples/README.md, samples/android-demo/README.md, samples/android-demo/AR_TESTING.md, the advice setup-ar-emulator.sh prints after provisioning, and stale comments in try-demo.sh, render-tests.yml and maintain.md. A first pass claimed completeness after finding three — it had grepped for android run --apks, and five docs write android 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.sh now 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.sh no longer retries a bare adb install -r when 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.sh was invisible to shellcheck. Two prose comments began with the word shellcheck, which the tool parses as a malformed DIRECTIVE (SC1073) and then stops analysing the rest of the file — so the script that lints every workflow run: 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.sh into its own check-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.md and AR_TESTING.md had headings promising Google's android CLI and an "atomic install + launch" directly above the plain adb commands the first pass substituted — the code changed, the prose around it did not. Fixed, along with a leftover AR_TESTING.md note 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.sh says which half broke.
  • check-android-run-not-taught.sh enumerates tracked files with git ls-files instead of recursing the working tree. --include filters names but does not stop grep -r descending into node_modules/ or build/, 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's grep is ugrep, 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_stamp can no longer abort its caller. Its pipeline ran under the lib's set -o pipefail plus a caller's inherited set -e, so an adb failure while READING the stamp aborted the whole helper with adb's raw exit code and an empty stderr — measured rc=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 .kt file, outside the gated set), but the day someone documents Test Lab in a .md the 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 *.yml and 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 run phrasing instead of the word firebase appearing 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 -r to xargs. Without --, a tracked path starting with - is read by grep as an option: measured, one such file aborts the whole batch with unknown --directories option and silently drops every file in it — a gate that evades itself. Mutation-tested with exactly such a file.
  • qa-android-demos.sh always goes through the helper now. Its if android_cli_locate … else adb install -r shape 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 the android CLI as "preferred — atomic install+launch" and said the script "uses android 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.sh no longer gates the verified helper on the android CLI being installed. Both install sites did if <CLI present> then <helper> else adb install -r, so a developer with only adb took an unproven path — the same shape fixed in qa-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_device claimed "either android or adb is fine, the CLI being preferred", then required adb ten lines below — so the friendly first error could never fire, and the advice it gave (install the CLI) unblocked nobody. adb is stated as required; the CLI is optional and explicitly not an installer.
  • The helper's launch contract is symmetric. The android run branch 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 whose am start fails on the CLI path (0 → 2).
  • The content gate also matches the phrase "atomic install", because the drift it kept missing never wrote android run at 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 0 and the cli path case goes red while the adb path one stays green, which is precisely the asymmetry that existed before.
  • capture-play-store-screenshots.sh's section-3 header still opened with "Use android 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 root Node's worldTransform follows 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 in jsTest — no new embind binding (the write path is the #2024-P1-probed TransformManager.setTransform) (#2024 P5a).
  • Web: Node.smoothTransform / Node.smoothTransformSpeed — smooth transform animation on the retained web node tree, with the Android core semantics and the same 5f default speed (no isSmoothTransformEnabled gate, no onSmoothEnd on web). Setting a target local Transform starts 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 to null; setting null cancels in place. The repaint hook (onInvalidate) moved up from SplatNode to Node, is wired subtree-wide by addNode (and inherited on attach) and released by removeNode, so animations keep the on-demand render gate awake from idle scenes. CameraNode/SplatNode onFrame overrides call super, 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 same NodeHandle instances the add*Node factories handed out (===-comparable), nearest-first. Kotlin/JS gains SceneView.hitTest(x, y) / hitTest(ray) (→ List<HitResult>) and the Android-mirror Node.collisionShape override. 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-publish job in the release workflow (idempotent, honest-red until pub.dev-side activation) + PR-time flutter pub publish --dry-run preflight in CI (#2735)
  • ContactShadow / ContactShadowNode — a procedural contact shadow that grounds an object on any surface, at any light angle (#2740). Unlike ShadowReceiverPlane, 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 / TableTop presets 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: Plane does not rotate its geometry to match its normal, so a wall quad is built in the XY plane and a pos.y += offset would slide it up its own face instead of off the wall.
  • New contact-shadow-preview demo — 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-placement AR demo now grounds its mounted TV with a ContactShadowContext.Wall pool, so the panel reads as mounted rather than floating on-device.
  • MCP validate_code v2 — symbol-existence checking against the real public API (#2760). A symbols.json-style index is generated at build time from the committed binary-compatibility .api dumps (sceneview, arsceneview, sceneview-core — zero Gradle in the chain, kept honest by the blocking apiCheck CI gate) and embedded in sceneview-mcp. Four new rules reject the #1 AI failure mode — APIs that do not exist: unknown io.github.sceneview.* imports, made-up *Node/*Scene types, nonexistent loader members (modelLoader.createModelInstanceAsync → did you mean loadModelInstanceAsync?), and invented remember* 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-run diffs 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: DemoStatus grows from 2 states to 4 — .working / .knownIssue / .inReview / .comingSoon — mirroring Android's DemoStatus (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 (5 KnownIssue + 2 InReview demos, verified by grep). The collator (collate-ios-demos.sh) gains an optional @status directive alongside @sceneId/@available, defaulting sensibly when omitted (working for an @available true scene, comingSoon for one that isn't) so none of the 51 existing *Scene.swift files needed an edit, and cross-validates @status against @available so the two can't contradict each other. SamplesTab renders a small StatusBadge capsule per status ("Preview" / "In review" / "Soon"; .working shows no badge) — the iOS mirror of Android's DemoListScreen.kt status 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.sh wraps headless Blender (already installed, ships a glTF 2.0 importer and a native USD/USDZ exporter — zero extra install versus Apple's usdzconvert, which needs a multi-hundred-MB download) into an idempotent GLB→USDZ pipeline: every conversion runs under /tmp and 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-known KhronosGroup/glTF-Sample-Assets reference 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 in project.pbxproj, declared in assets/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 uses KHR_draco_mesh_compression on ~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 (was sceneview_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 directory flutter/sceneview_flutter/ is unchanged
  • Unified the store-screenshot capture across Android and iOS: both capture-play-store-screenshots.sh and capture-appstore-screenshots.sh now 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. Refreshed branding/ICON_AUDIT.md (stale iOS status + pre-Stitch #1A73E8 palette → 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 iOS appstore-screenshots/README.md: the unified demo set, which files the capture script can and cannot regenerate, and the pinned-ANDROID_SERIAL rule. Auditing it surfaced that the 12 committed tablet PNGs are byte-identical duplicates across the 7"/10" slots, light-mode, advertise a stale v4.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 SECONDS override (#2773).
  • App Store listing tooling: symmetric offline guard for screenshotDisplayType (#2794 follow-up) — the Play-side fix pinned Google Play's AppImageType enum so a bogus imageType is caught offline instead of 400-ing against the live store; asc_listing.py had the identical exposure with no equivalent guard — its DISPLAY_TYPE_MAP values were correct, but only a dir→row coverage test protected them, so a future row with an invented screenshotDisplayType would have surfaced only on the first real App Store Connect call (set-creation on the --apply-screenshots write path, after an earlier display type's live set may already have been replaced). Added VALID_DISPLAY_TYPES, transcribed verbatim from Apple's App Store Connect API OpenAPI spec (v4.3 ScreenshotDisplayType, cross-checked against fastlane spaceship's AppScreenshotSet::DisplayType — 33 values), plus unknown_display_types() enforced before any network call in main() and again at the write boundary in apply_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 to APP_IPHONE_67 and 13" iPad captures to APP_IPAD_PRO_3GEN_129 (Apple never minted APP_IPHONE_69/APP_IPAD_13). (#2794)
  • iOS demo: the deep-link registry is now generated. collate-ios-demos.sh emits GeneratedScenes.allowedIds and GeneratedScenes.destination(for:) from the same @sceneId directives that already drive the Samples tab, so the three deep-link surfaces (list, allowedIds gate, id→view resolver) can no longer drift apart — the root cause that silently dropped 12 ids (#2769). DemoDeepLinkRegistry shrinks from a hand-maintained 66-id allowedIds + 43-case switch to 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-formed sceneview://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 — the Unit tests job swaps :samples:android-demo:testDebugUnitTest for :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 via pre-push-check.sh). A failed verify now also uploads a roborazzi-diff-report artifact with the actual/diff PNGs.
  • sync-versions.sh --fix now auto-prepends the missing ## X.Y.Z stub entry to the Flutter plugin's CHANGELOG.md when it lags VERSION_NAME — a bumped pubspec without a matching CHANGELOG entry made the pub.dev publish preflight (#2735) fail the Build flutter-demo APK job 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.png was assumed to need a manual Play Console upload; in fact the automated sync ran on every release and failed. play_listing.py declared two imageType values Google Play's AppImageType enum does not contain (tabletScreenshots / tabletScreenshots10 instead of sevenInchScreenshots / 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-listing is continue-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, plus unknown_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 despite continue-on-error. (#2794)
  • iOS demo ids that diverged from Android's canonical DemoRegistry slugs 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 in DemoDeepLinkRegistry.allowedIds so 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 a DemoDeepLinkRegistry.legacyAliases entry 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 in DemoDeepLinkRegistry.residualIds with 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.swift with an honest comingSoonTitle — residualIds is 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 @androidOnlyReason Scene directive so their card reads "Android-only: " instead of "Coming soon", which would dishonestly imply a future port (ComingSoonScreen + DemoItem gain the matching optional field, nil by default — zero behavior change for every other demo). parity-manifest.yml moves 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 the ModelViewerDemo (#2114), MaterialsDemo and Scene Gallery/Multi-Model (#2805 predecessors): AnimationDemo (bundled cyberpunk_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), and CustomMeshDemo (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 under Views/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 (ARSceneView lights 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 on SceneView, so it cannot reach a raw RealityView. TextureStreamingDemo's visible PBR sphere (the demo's entire point — Gold/Silver/Copper/Ceramic/ Plastic/Rubber presets) lives in a RealityView overlay entirely separate from its own (empty) SceneView; OcclusionMaterialDemo's metallic reference sphere is also built directly on RealityView; DebugOverlayDemo has 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: xcodebuild compiles 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) asserting GeneratedScenes/DemoDeepLinkRegistry invariants — 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 orphaned SketchfabAssetResolver+Tests.swift (17 tests, dead since May — never compiled) in the SceneViewDemoTests target. 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 into ci.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.yml gained an ios job (Maestro on an iOS Simulator via ios-device-qa.sh) that routes to the self-hosted Mac when online and falls back to macos-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.sh tags ios advisory in device-qa-report.json / releaseGate (#2803).
  • CI: render-tests.yml's "iOS screenshot tests" job now produces real PNGs. A dedicated SceneViewDemoUITests UI-testing target (XCUITest) launches the demo in a simulator and captures an XCTAttachment screenshot of the launch screen, every tab, and a representative subset of working 3D demos; the job exports the attachments from the .xcresult as PNG artifacts. It uses its own scheme so the per-PR iOS unit-test check stays fast and simulator-free (#2803).

Docs

  • Flutter: flutter_sceneview is 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 the pub-publish release job is promoted into create-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.kt files under samples/android-demo/.../fragments/ minus the DemoFragment.kt base class; breakdown corrected to 18 non-AR + 34 AR) across CLAUDE.md, .maestro/README.md, docs/docs/samples.md, docs/docs/llms-full.txt, docs/docs/try.md, samples/README.md, and website-static/index.html. "24 deep-linkable" → 63 (DemoDeepLinkRegistry.allowedIds counted directly) in CLAUDE.md and .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.yml defines no ios job — the leg is local-only today) and render-tests.yml's "iOS screenshot tests" job captures no PNGs (it runs the existing logic-only SceneViewDemoTests target; no UI-testing target, no XCTAttachment anywhere in the iOS demo). Both corrections cite #2803, which tracks wiring them up for real. docs/docs/cheatsheet-ios.md gains the missing Android-only parity rows for SplatNode (#2768), Collaborative AR (CollaborativeTransport/CollaborativeSession, noting the ARKit-native ARSession.collaborationData advantage once a port lands), and the ar-ml-object-label demo, plus a demo-id cross-reference on the existing ar-scene-semantics row — and surfaces the honest SSAO/Bloom/MSAA render-quality gap table that was previously only in RenderQuality.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-screenshots uploads samples/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 of PlacementScene. 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-placement demo in the Android demo app — mounts a procedural TV on a wall through WallPlacementScene: 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 review status 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-run default: read-only live-vs-repo diff of listing text and per-image SHA-256s, probe edit abandoned). A new asc_listing.py diffs the live App Store listing (text fields + screenshot sourceFileChecksum MD5s) against samples/ios-demo/distribution/app-store/ and appstore-screenshots/. Both scripts SKIP honestly without credentials and are pinned by test-store-sync.sh's offline unit-test suite in repo-hygiene.

Fixed

  • play_listing.py accepted abbreviated flags: --appl expanded to --apply via 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-screenshots upload 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)), matching ModelViewerDemo's #2114 IBL fix. All three display curated PBR models, and a PBR surface is defined by what it reflects — Materials in particular showcases KHR_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-web paired 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 /view page. The default camera now uses the photometric setExposure(f/12, 1/200 s, ISO 200), mirroring Android's SceneFactories default 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/LightNode default 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.setExposureDirect is 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 the directExposure / useDirectExposure properties. 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 JS sceneview namespace and the SceneViewer npm 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 shared SplatParser and renders it through SplatNode, with a gesture orbit that feeds the node's back-to-front painter's sort and a reveal slider driving SplatNode.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 KMP sceneview-core parsers: 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. The splat_web.filamat material is compiled against the web Filament runtime (new filamentWeb pin in gradle/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-prompt beta): the tapped AR camera frame is captured with the existing Frame.cameraImage() helpers, sent as an image+text prompt, and answered in an overlay card. Honest AICore gating (download CTA with progress on DOWNLOADABLE devices, 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 ModelSource abstraction, 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-validator into the root build for the three published library modules — sceneview, arsceneview and sceneview-core. Each module's public ABI is now dumped to a committed <module>/api/<module>.api file, and a blocking apiCheck CI 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 apiDump and commit the .api diff, making the public-API delta a first-class part of PR review — the binary-level guard for the exact signatures llms.txt promises. JVM surface only (sceneview-core's jvm("android") dump already covers the shared commonMain API); native/JS klib validation and the npm-published sceneview-web JS module are documented exclusions. See CONTRIBUTING.md → "Public API changes".
  • Automated pub.dev publishing for sceneview_flutter: OIDC pub-publish job in the release workflow (idempotent, honest-red until pub.dev-side activation) + PR-time flutter pub publish --dry-run preflight in CI (#2735)

Changed

  • Filament runtime bumped 1.71.5 → 1.72.1 (#2590). Updated filament-android, gltfio-android, and filament-utils-android, and recompiled all 26 committed .filamat blobs with the matching matc 1.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 (ConnectivityManager active-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 four gpt/knowledge-*.md files uploaded to the "SceneView 3D & AR Assistant" Custom GPT were hand-maintained copies of llms.txt that 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 from llms.txt by tools/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.txt is the single source of truth; run node tools/generate-gpt-knowledge.js after 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, and CLAUDE.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-preview now have their own legs in .maestro/android/advanced.yaml / ar.yaml, and the stale catalog.yaml header (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)), but plane_renderer_shadow's vertex shader forces pos.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 the ShadowReceiverPlaneNode convention. (2) Compositing: plane_renderer_shadow only darkens what is behind it, but the viewer rendered onto an opaque black framebuffer (createSkybox = false leaves no skybox to fill it), so the shadow multiplied black-on-black and stayed invisible. The viewer now renders with isOpaque = false, so the translucent TextureView composites the shadow over the sheet's light Compose surface — the sheet-surface backdrop this viewer was always documented to want (#2581).
  • sceneview-core: SmoothTransform.updateSmoothTransform gained a pre-decomposed TRS-tuple overload — SmoothTransformTRSState / SmoothTransformTRSTarget — that calls the pre-decomposed slerp(startPosition, startQuaternion, startScale, …) overload directly instead of round-tripping through Transform (Mat4), avoiding 6 matrix decompositions per interpolation tick for callers on a per-frame path that already hold decomposed TRS components (matching the Node pattern from #2187). The existing Transform-based SmoothTransformState API 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 an XRHitTestSource leak on a quick tap-to-cancel or early error path. Applied to both WebXRSession.setupHitTesting (guarded on isRunning) and ARSceneView.startSession (guarded on teardown, so a source resolving between onReady() and the caller's start() is still adopted) (#2668 MED-2).
  • EnvironmentLoader: the three suspend loaders (loadHDREnvironment, both loadKTX1Environment overloads) now build the environment inside withContext(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 the create*Environment builder call moves, mirroring MaterialLoader.loadMaterial / ModelLoader.loadModel (#2669, #2670, #2671, part of #2668).
  • ARSceneScope.PoseNode: the composable no longer re-applies the declared pose on every recomposition. The bare SideEffect { node.pose = pose; … } clobbered a pose a drag gesture had just written (PoseNode.onMove with isPositionEditable) — the #2639 defect class. node.pose is now pushed from a DisposableEffect keyed on the pose's scalar components (translation + rotation quaternion, since com.google.ar.core.Pose has no equals()); the visibleCameraTrackingStates / onPoseChanged reference updates stay unkeyed (#2672, part of #2668).
  • sceneview-web: loadEnvironment / loadDefaultEnvironment no longer use-after-free a destroyed engine when destroy() runs while a KTX fetch is in flight — both the IBL and skybox .then callbacks now bail out on a new destroyed flag (set first in destroy()), still settling pendingLoads so the render-gate counter never leaks. Same #1597 Tier-2 guard as loadModel's superseded flag (#2673, part of #2668).
  • sceneview-web: loadModel no longer use-after-frees a destroyed engine when destroy() runs while the initial GLB fetch is in flight — the fetch .then continuation now bails on the destroyed flag before createAsset/addEntities touch the freed WASM engine/scene, still settling pendingLoads so the render-gate counter never leaks. The existing superseded guard only covered the late loadResources/onDone step, not this initial continuation; this mirrors the loadEnvironment KTX 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 main mounts 67 (sceneview-mcp 31 + automotive 9 + gaming 7 + healthcare 7 + interior 7 + rerun 5 + the gateway widget tool); removed the phantom get_started entry from FREE_TOOLS in mcp/src/tiers.ts (it exists in no tool library) and documented in place why the gateway-only view_3d_model entry must stay (the gateway tier gate defaults unknown tools to pro); corrected mcp/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_TOOLS entry (mcp/src/tiers.ts): the 5 rerun tools, get_ev_charging_station_viewer, get_car_paint_shader, and the 4 validate_*_code tools — 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_MESSAGE now says 5 vertical packages / 35 specialized tools (was "4 / 24"), mcp/mcpize.yaml advertises 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 PlaneNode in the gallery model viewer was a raw flat (zero-Y) quad with no isShadowCaster = false, isShadowReceiver = true, setCulling(false), or non-degenerate bounding box — the exact latent Filament Level-2+ cascaded-shadow crash the ShadowReceiverPlaneNode (#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.updateDisplayInfo when closing a SceneView screen (#2709). SceneRenderer.onDetachedFromSurface now detaches the DisplayHelper (unregistering its display-changed listener) before destroying the swap chain and flushAndWait(), 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 after detach() had nulled the helper's renderer, crashing inside Filament's unfixed 1.71.5 DisplayHelper (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, and store-preflight.sh gained an open-reviewSubmissions probe that WARNs on assembled-but-never-submitted releases (#2731).
  • Fixed the nightly-CI failure reporter that was blind to cancelled runs 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.yml parses the ### Platform / ### Module sections a newly opened issue-form submission renders and applies the matching existing platform:* / module:* label — no LLM, no external calls, deterministic, safe against body injection (parsed via actions/github-script, never a shell). Created the pinned and roadmap labels that maintenance.yml's stale job already referenced but that did not exist, so its exempt-issue-labels protection is now real instead of partially phantom. Fixed bug_report.yml's relative ../MIGRATION.md link (404 on the issue-form render) to an absolute GitHub URL.
  • Saved workflows: guard against JSON-stringified args. All 8 remaining .claude/workflows/*.js scripts now parse a stringified args (or fail loudly on non-JSON) instead of silently ignoring it — a stringified {"issues":[…]} made fix-issue-batch fall back to auto-selecting issues, twice on 2026-07-16, picking maintainer-gated work. Same guard review-fanout.js and parity-audit.js already had.
  • sceneview.haptic (light/medium/heavy taps, notification patterns, continuous, pattern) was live at runtime but missing from the npm TypeScript declarations — now typed in sceneview-web.d.ts (found by the new #2736 drift gate)
  • device-QA harness: setup-ar-emulator.sh now 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 id 0, which arm64 AVDs never expose). --check gains 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 a 0.15 threshold 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 PoseLandmarker init was wrapped in runCatching { … }.getOrNull(), silently swallowing any failure. Added an onFailure that 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 .gltf only, 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 omits Content-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 task to the demo APK's noCompress set. The MediaPipe .task bundle is a ZIP that MediaPipe memory-maps at runtime; re-compressing it in the APK made PoseLandmarker.createFromOptions fail 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-free renderMarkdownLite (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 ws memory-exhaustion DoS (GHSA-96hv-2xvq-fx4p / CVE-2026-48779) across all 4 open Dependabot HIGH alerts via version-scoped npm overrides — mcp-gateway and telemetry-worker lockfiles move to ws 8.21.1, react-native-sceneview pins 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 the feedback-worker/ directory and its quality-gate.sh test block, and reworded the Play Store DATA_SAFETY.md + PLAY_STORE_SETUP.md to 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: RerunBridgeTest can no longer hang indefinitely. Its socket helpers wrapped blocking ServerSocket.accept() / BufferedReader.readLine() calls in withTimeout, 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:testDebugUnitTest job until the outer CI timeout (observed >30 min, then passed on retry). Every accept/read now goes through wire-level Socket.setSoTimeout (acceptWithin / lineReader helpers), so a missing line surfaces as a SocketTimeoutException within the test budget instead of blocking forever; withTimeout is 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 Test task now emits a started event 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 the Unit tests job'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-core commonTest suite (shared KMP math/collision/animation logic) now gates every PR via the blocking unit-test CI job — previously only the informational jsTest ran it (#2733)
  • sceneview-web.d.ts is 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 honor ANDROID_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_run now forwards the leased serial explicitly, keeping the emulator-first rule true on multi-device hosts.

Docs

  • Refreshed docs/docs/desktop-filament.md into the durable decision record for #2540: corrected the stale claim that upstream Filament still ships a desktop Java build (FilamentCanvas/FilamentPanel were removed in 2021, google/filament#4263), documented the community filament-kmp FFM 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, multimodal generateContent, emulator QA note), an llms.txt "Recipes" entry with the condensed working code and its gotchas, and agent-skill reference #14 pointing at the shipped PointAndAskDemo.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.sh probes App Store Connect for the account-side blockers that used to stall releases silently: an expired Apple Program License Agreement (REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED canary), an App Review rejection (latest appStoreVersion state), and a distribution certificate / provisioning profile inside 30 days of expiry. It signs the ES256 ASC JWT with openssl only (no PyJWT), reuses app-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 under GATE_HARD=1. Wired into release-checklist.sh (new §16), the /store-status known-gap note, and a daily maintenance.yml store-preflight job that posts to the run's step summary. Self-tested offline by test-store-preflight.sh (runs in repo-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 new surfaceMirrorer parameter on SceneView and ARSceneView (+ rememberSurfaceMirrorer()). Attach a MediaRecorder input surface with startMirroring(surface) / stopMirroring(surface) and get an MP4 of exactly what the scene renders — in AR, camera feed + virtual content composited. No system consent dialog, no mediaProjection foreground service, no overlay UI in the frame. Multi-surface capable, letterboxed, startMirroring thread-safe/JNI-free, both calls idempotent. New video-recording demo + llms.txt "Record the scene to MP4" section.
  • Binary-compatibility note — recompile required: adding the surfaceMirrorer parameter changes the JVM method descriptor of the @Composable SceneView and ARSceneView functions, 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 to null), 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 absolute centerOrigin(_:). centerOrigin(normalized:) takes a bounding-box point in normalized AABB coordinates (-1...1 per axis, 0 = box centre, ±1 = box faces) and aligns it with the node origin via -(center + origin * extents/2) — identical semantics to Android's ModelNode.centerOrigin(Position). centerOrigin(normalized: SIMD3(0, -1, 0)) now bottom-aligns exactly like Android's Position(0, -1, 0) (the model sits on the origin), so an Android snippet ports verbatim. The absolute centerOrigin(_:) overload is unchanged (source-compatible). Replaces the former manual grounding workaround centerOrigin(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 ModelSource abstraction (search / feeds / streaming download + attribution & license metadata) with three implementations: the existing Sketchfab client wrapped as SketchfabSource, plus new keyless IcosaGalleryService (the open-source Google Poly successor, glTF-native CC assets) and PolyHavenService (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.fromSpz and the auto-sniffing SplatParser.parse decode INRIA-style PLY (binary_little_endian, property-order-agnostic — normals and higher-order f_rest_* SH bands tolerated) and Niantic SPZ (gzip container, versions 2 first-three and 3 smallest-three) into a shared SplatCloud (count, positions, scales, rotations, SH degree-0 colors, opacities) with activations applied. Includes a dependency-free pure-Kotlin gzip + DEFLATE inflater, so SPZ decodes on every target (Android, Apple, Web) with no expect/actual and no native zlib. Malformed, truncated, ASCII/big-endian PLY, and unsupported SPZ v1/v4 inputs are rejected with a SplatParseException (#2646)
  • SplatNode — 3D Gaussian Splatting rendering on Android (#2646, P1). New SceneScope.SplatNode composable (+ rememberSplatCloud and the underlying io.github.sceneview.node.SplatNode) renders a SplatCloud — the flat-array 3DGS data model shared in sceneview-core commonMain — 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 (new splat.filamat, compiled with the pinned matc 1.71.5 toolchain). Clouds above Filament's 65535 instances/draw cap are split into batches transparently; an optional cameraPositionProvider enables an off-main-thread back-to-front painter's sort keyed on camera motion, and splatCount truncates the draw for LOD/reveal effects. P1 scope: isotropic billboards + SH0 colour — anisotropic 2D-covariance ellipses and the .ply/.spz file loaders land in the follow-up #2646 workstreams.
  • sceneview-mcp: new generate_3d_model tool — Tripo BYOK text/image→GLB (#2647). Closes the agentic asset loop next to search_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 for rememberModelInstance and 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 via TRIPO_API_KEY, mirroring the SKETCHFAB_API_KEY pattern — 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): TapToPlaceArSession now runs the full #2241 Sprint-1 stack — PlaneDiscoveryGuide onboarding replaces the static "Scanning…" affordance, the reticle is the smoothed PlacementReticle (same PlacementHitPolicy acceptance via its new predicate parameter), and every tracked plane hosts an invisible ShadowReceiverPlane so placed models ground with a real contact shadow. PlacementReticle/PlacementReticleNode gain the predicate acceptance hook, and snapToPlane = false now means free placement (feature-point hits accepted, planes stay in-polygon) instead of accepting nothing.
  • Binary compatibility: the new predicate parameter added to the already-released public PlacementReticle composable and open class PlacementReticleNode is 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 of arsceneview must recompile against the new artifact — a stale .class bound to the old signature would NoSuchMethodError at 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's URLSessionDownloadDelegate resumed its continuation with the delegate's temporary location URL, 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, so SketchfabAssetResolver silently 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.parent setter now guards the engine write after destroy() — re-parenting a retained, destroyed node no longer reaches TransformManager.setParent on 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 #2024 P1 browser probe also asserts the detach-sentinel invariant: the sentinel entity never gains a transform component, so nullParentInstance() stays native instance 0.
  • Restored the Maestro 3D zoom-QA coverage that had silently stopped running (#2633). The reusable flows/demo.yaml subflow declared its own env: CAMERA_DISTANCE: "" default which — contrary to its comment — masked the value the caller passes via runFlow → 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 now typeof-guarded, so the zoom section runs when a caller sets CAMERA_DISTANCE (proven: demo-model-viewer-zoom-near/-far.png produced) and skips cleanly — with no undefined-variable error — when it does not.
  • ModelNode no longer re-applies its declared rotation (and position/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/android device-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 with exit 2 before 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 --ci selection, degrades to an honest skipped (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 ar device-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-minute am instrument process; the sustained Filament/GL pressure severed the am instrument -w adb 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 raw java.io.File to /sdcard/Download/, which scoped storage silently discards for a targetSdk 36 app — it now publishes via the MediaStore API (the same pattern the QA-screenshot tests use), so adb pull can actually retrieve it; (2) ar-replay-qa.sh now shards the sweep into AR_SHARD_COUNT (default 6) separate am instrument runs with an am force-stop between 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 recorded skipped with 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 (missingShards in the merged summary) — unaccounted demos can never grade as a pass; and a severed shard's inProgress demo 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, plus LightNode's position) no longer re-apply their declared position/rotation/scale on 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-keyed DisposableEffect idiom as the ModelNode fix 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.SUCCESS while leaving the Filament SurfaceView out of the read-back — an alpha == 0 hole 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: the groundShadows contact-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 = true used to stack the V1 plane renderer's built-in shadow receiver and a ShadowReceiverPlane — two exactly coplanar shadowMultiplier quads that z-fight and darken the contact shadow twice (0.4 × 0.4 ≈ 0.16, near-black) — for the whole session. PlacementScene now 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. TapToPlaceArSession kept the V1 plane renderer's shadow receiver and a ShadowReceiverPlane stacked on the same plane — two coplanar shadowMultiplier quads 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 — mirroring PlacementScene's fadePlaneOnFirstPlacement contract (#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 over adb 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 --fix exits 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 CancellationException instead of the socket-abort SocketException, so a query superseded by fast typing can no longer flash the "Sketchfab unavailable" error banner. Fixes the flaky SketchfabServiceTest cancellation test. (#2665)

Tests

  • samples/android-demo: the camera_distance QA extra is now honoured regardless of the sender's Bundle type (#2652). MainActivity only read getFloatExtra, but Maestro's launchApp delivers 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 --ef reframed, the Maestro-style string was ignored). The raw extra is now coerced type-agnostically via the new DeepLinkRouter.coerceCameraDistanceExtra (Float/Double/Int/Long/String all funnel through the same validateCameraDistance clamp; garbage still resolves to null = 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 let waitForAnimationToEnd return early on the settled black viewport — so -zoom-near/-zoom-far came 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 an optional extendedWaitUntil on 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.swift now stubs the CDN transfer with a URLProtocol that 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 (the removeItem branch); 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 the SceneViewDemoTests target, 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 for EXT_texture_webp glTF textures (Logcat Missing texture provider for image/webp, renders untextured), linking to the existing full writeup in docs/docs/troubleshooting.md and the workaround (re-encode to PNG/JPEG/KTX2). Re-verified upstream as of Filament v1.73.0 (2026-07-07, current latest release, newer than the pinned 1.71.5): FILAMENT_SUPPORTS_WEBP_TEXTURES still defaults OFF in android/gltfio-android/CMakeLists.txt, so the published Android gltfio-android AAR still ships with isWebpSupported() == false and no image/webp provider — 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, but PRO_TOOLS in mcp/src/tiers.ts has 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-BEGIN MotionEvent (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.onMove re-hit-tested the finger-DOWN pixel every frame, so a dragged AnchorNode/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 NullPointerException crash on real devices the instant an ARCore plane was detected with PlacementScene(groundShadows = true) (#2621, regression in 4.21.0, root-caused via the AR device-QA leg failure #2620). The base PlaneNode's init { trackable = plane } invokes update(trackable) — dispatched to ShadowReceiverPlaneNode's override — before the subclass's meshNode field is initialized, so the override dereferenced a null meshNode and 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 mirrors PlaneVisualizer's device-proven flat-receiver recipe (culling(false) + a non-degenerate bounding box).
  • sceneview: ModelNode.centerOrigin now 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 — so origin = (0,0,0) ("center the model") was a silent no-op and origin = (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..1 per axis) exactly on the node origin, whatever the asset's authored pivot — matching what the KDoc always promised. This lines up with iOS centerOrigin(_:) only for the centering case (Android Position(0,0,0) ↔ iOS .zero); for a non-zero origin the platforms deliberately diverge — Android's origin is a normalized -1..1 bounding-box coordinate, whereas iOS's target is an absolute point in metres, so an Android snippet ported verbatim mis-places the model (llms.txt documents the divergence and the iOS SIMD3(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. added position corrections or a scaleToUnits / 2 lift on top of centerOrigin), remove the compensation — centerOrigin = Position(0,-1,0) alone now grounds the model exactly. If you passed centerOrigin = Position(0,0,0) relying on it doing nothing, pass null (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 passed Position(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 groundShadows crash (#2624, audit of #2621). PlaneNode, AugmentedImageNode, AugmentedFaceNode and StreetscapeGeometryNode all run init { trackable = … }, whose setter virtually dispatches the open update() — so any subclass override executed before the subclass's fields were initialized (exactly how ShadowReceiverPlaneNode NPE'd on-device in 4.21.0). Each carrier now gates its class-specific update() tail behind a constructed flag (the proven ShadowReceiverPlaneNode pattern) and re-applies the initial trackable state at the end of init, so the construction end-state is byte-for-byte unchanged — a user callback like onTrackingMethodChanged now 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=web CI 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 iwer 2.3.0 shipped (2026-07-09) — 2.3.0 added a guard that silently skips installRuntime() when a native navigator.xr exists, and headless Chromium ships one (answering isSessionSupported=false), so the shim never took on CI and the AR/VR buttons stayed display: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 an inProgress marker, and the script streams logcat + tees the instrumentation output into the artifact bundle. When the instrumentation host process dies mid-sweep — a demo crashing the shared MainActivity process, 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 bare rc=1 with no summary at all (#2620).
  • device-qa (iOS leg): root-caused the 3d-basics flow failure on demo-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-content ASSERT_TEXT assertion is now optional: true (advisory) with the real crash gates unchanged (FAB re-assert + the simulator-log sweep); documented in .maestro/README.md known limitations.
  • device-qa (Maestro pin): bumped the pinned Maestro from 1.39.0 to 2.6.1 in lib/maestro.sh and — new — actually pinned the CI install in device-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 uses runScript/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.sh now keeps the simulator's unified log as a run artifact instead of a discarded mktemp, and fixes the crash-gate predicate — it filtered on process == "SceneViewDemo", which never matched anything (the built bundle's CFBundleExecutable is SceneView), so the post-run crash sweep had been silently blind. The stream now filters on process == "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 structured Logger calls), and device-qa.sh copies the log into the artifacts dir and attaches its path as a new log field on the iOS platform record in device-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 Node transform graph (Kotlin/JS API, slice 1 of #2024) — Node base class implementing the shared sceneview-core SceneNode contract (pristine TRS state, parent/child hierarchy via Filament TransformManager.setParent, world-space getters/setters, lookAt/lookTowards, recursive idempotent destroy()), plus SceneView.sceneGraph / addNode / removeNode. Not yet reachable from plain JS — the @JsExport NodeHandle surface arrives in a later slice; the published JS API and the builder DSL are unchanged.
  • sceneview-web: ModelNode and GeometryNode (+ 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; the model { } / geometry { } builder DSL delegates to nodes (identical visual result, unchanged Kotlin shape). addGeometry now returns the created FilamentAsset? (additive). Node transform writes after destroy() are guarded (no engine call on a freed entity). Still Kotlin-only — the @JsExport surface is untouched.
  • sceneview-web: in-browser TransformManager.setParent proof (#2024 P1) — a new kotlin-bundle.spec.ts probe 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 JS null parent (BindingError), so detaching/destroying any slice-1 Node would have crashed at runtime — FilamentNodeBackend now detaches through the null instance (a component-less sentinel entity), the JS analog of Android's setParent(i, 0).
  • Web: LightNode and CameraNode land the #2024 node scene-graph slice 2b. SceneView.addLightNode(config) wraps a Filament light in an addressable, transformable node (runtime intensity/color/direction/position mutators push through the LightManager instance bindings); SceneView.addCameraNode() drives the camera from a node's world transform via Camera.setModelMatrix each frame (Android CameraNode parity). The light { } DSL block now delegates to a retained LightNode (visually byte-identical to the flat path, addressable via sceneView.sceneGraph afterwards). All LightManager/Camera embind bindings the nodes depend on are proven in-browser by the kotlin-bundle.spec.ts #2024-P1 slice-2b probes.
  • Web: first exported plain-JS scene-graph surface — NodeHandle (#2024, slice 3 / P4). window.sceneview viewers now expose addNode(), addModelNode(url), addCubeNode(size), addSphereNode(radius), addLightNode(type) and removeNode(handle), returning an opaque NodeHandle you can address after create(): setPosition/setRotation (Euler degrees)/setScale/setScaleUniform/setVisible/addChild/removeChild/getWorldPosition/destroy. The published sceneview-web.d.ts declares the new surface. Library auto-centering is now routed through a single real content-root Node translation (the iOS contentRoot approach) instead of per-asset root-entity offsets — visually identical; node-created content is framed via its own node transform.

Changed

  • arsceneview: PlacementScene gains 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.DISC keeps 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 = true overlays the animated PlaneDiscoveryGuide onboarding while the user finds a surface; groundShadows = true drops an invisible shadow catcher on each detected plane so placed models cast a contact shadow instead of floating. The PlacementSceneDemo in samples/android-demo turns 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 --fix now 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, and sceneview.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 → complete sync-versions --fix bump → changelog collate → auto-merge release PR) + tag-release.yml (tags the merged release commit and dispatches release.yml on 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 methods block in the web agent skill (SKILL.md, references/cheatsheet.md) now lists the six sv.* node factories (addNode / addModelNode / addCubeNode / addSphereNode / addLightNode / removeNode) and the ten NodeHandle methods, 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.md gains an "Imperative node API" section; llms.txt documents sv.addNode / sv.removeNode; references/recipes.md records the new Web parity line. Two stale code comments referencing the removed transformScratch field 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 slerp 0.75, Depth Lab / Android PlacementReticle parity; hidden while the ray misses). Android's PlacementReticle iOS counterpart from the Sprint-1 design (#894).
  • iOS ARSceneView(groundingShadows:) — entities placed synchronously in onTapOnPlane now automatically get RealityKit's GroundingShadowComponent(castsShadow: true), projecting a contact shadow onto the detected surface — the RealityKit analogue of Android's ShadowReceiverPlane (#2580). Opt out with groundingShadows: 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 optional PixelCopy screenshot 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 from play-store.yml / main-internal-deploy.yml.
  • CI: every push to main now deploys the Android demo to the Play Store internal-testing track (#2596). New paths-filtered main-internal-deploy.yml (cancel-in-progress, internal track only, versionName X.Y.Z-main.<sha>) gives the maintainer a minutes-fast real-device test loop; production remains tag-only via play-store.yml. Both Play-uploading workflows now share a single strictly-increasing epoch-minutes versionCode scheme (epoch_seconds / 60) — required because Play versionCodes must increase across all tracks, so the release workflow migrated off github.run_number in the same change.

Fixed

  • AR: placed models rendered too dark / green-tinted under ENVIRONMENTAL_HDR light estimation (the ARSceneView default) (#2483). LightEstimator fed 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 computed maxIntensity had been dead code) — while mainLightIntensity carried 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-normalized mainLightColor) × magnitude (mainLightIntensity = max component), applying the radiance exactly once through the baseline-multiply contract — (c / max) × max == c. Pinned by LightEstimatorTest; 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 the compileOnly Nearby Connections types referenced by arsceneview's NearbyCollaborativeTransport reference implementation. This was silently blocking the Play Store deploy of the demo (the missing-class check only runs during minifyReleaseWithR8, not on the CI compile/unit-test gates).

Tests

  • DemoRenderingScreenshotTest now FAILS (instead of silently assume-skipping) when a slug listed in BASELINED_GOLDENS has 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 cancellable CloudAnchorFuture — call future.cancel() from SwiftUI .onDisappear (the analogue of Android's DisposableEffect { onDispose { future.cancel() } }, #1768) to short-circuit billed ARCore Cloud round-trips when the view goes away. Completion fires at most once and never after cancel() or after the handle is deallocated. To keep the core library dependency-free, the actual GARSession.hostCloudAnchor call (Google's arcore-ios-sdk Swift Package) is supplied by the app through the operation closure; CloudAnchorFuture owns only the portable, fully unit-tested cancellation gate. Mirrors Android CloudAnchorNode.host / .resolve returning HostCloudAnchorFuture / 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). The play-services-nearby dependency is compileOnly, 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 surfaced REQUIRED_PERMISSIONS_* themselves. Android-only for now; the CollaborativeTransport abstraction stays platform-neutral so iOS can map it onto RealityKit's MultipeerConnectivityService (#2008).
  • arsceneview: PlacementReticle composable + PlacementReticleNode (#2241 Sprint-1, PR 4/6) — the Depth Lab OrientedReticle port: 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 custom content is passed; a null hit auto-hides the marker and resets the smoothing.
  • PlaneDiscoveryGuide — AR plane-discovery onboarding overlay (#2241). New io.github.sceneview.ar.PlaneDiscoveryGuide composable, 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 existing sceneview_*_message copy. Pure UI overlay — consumes cameraReady / isTracking / anyPlaneTracked / trackingFailureReason signals the host already produces; no ARCore or Filament dependency. Ships with a headless-testable PlaneDiscoveryGuideState (injectable clock), a stateless PlaneDiscoveryGuideOverlay for custom hosts/previews, and the Canvas-drawn PlaneDiscoveryHandHint animation (no Lottie dependency).
  • ShadowReceiverPlane — invisible AR shadow-catcher ground (#2241 Sprint-1, PR 3/6). New ShadowReceiverPlaneNode + ARSceneScope.ShadowReceiverPlane { } composable in arsceneview: an invisible surface bound to a detected ARCore Plane that 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's ShadowReceiverMeshShader (Blend Zero SrcColor) using Filament's dedicated shadowMultiplier shadow-catcher feature, via a new shadow_receiver.mat material with a runtime-tunable shadowIntensity parameter (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 into shadow_receiver.filamat with the pinned matc toolchain (Filament 1.71.5, profile C) enrolled in the GenerateFilamat.sh drift gate.
  • sceneview-web: wired the model { scale(...); autoAnimate(...) } DSL builder options that were silent no-ops (#2432). SceneViewBuilder.apply() now threads both through loadModel, matching Android ModelNode semantics. 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 Android ModelNode(scale = Scale(value)), not scaleToUnits normalisation). 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.
  • ARSceneView gains a renderQuality: RenderQuality? parameter, closing the 3D/AR API asymmetry the docs previously advertised as symmetric (#2524, #2519 audit). It is nullable and defaults to null — existing AR scenes are unchanged, keeping the camera-feed-tuned createARView defaults (no SSAO/bloom). Pass a preset to opt in, e.g. ARSceneView(renderQuality = RenderQuality.Performance) { } for battery-sensitive overlays or RenderQuality.Cinematic for a hero placement showcase. The Filmic tone mapper that round-trips the AR camera background (#1434) is preserved across every preset, since applyRenderQuality never writes view.colorGrading.

Changed

  • Documented Node.rotation (Euler getter) as deliberately un-cached — no per-frame render/animation path reads it (they read quaternion), so caching would only add invalidation cost to the hot quaternion write 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-rememberEnvironment swap path that proves the #2361 fix (the skybox actually rebuilds on a new HDR). The second HDR reuses the existing in-repo rooftop_night_2k.hdr asset. (#2365)
  • Flutter & React Native Android bridges: tidied the mutually-exclusive rememberEnvironment call sites into a single stable call site (one keyed rememberEnvironment whose factory falls back to the default environment when the HDR path is null), instead of a keyed call plus a separate environment ?: rememberEnvironment(...) fallback at the SceneView argument. Behavior-preserving; version-independent (still uses Compose key {}, not the unreleased key= param). (#2365)
  • Build toolchain: bump Kotlin 2.3.21 → 2.4.0 (the 2026-06-03 stable language release) in gradle/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 at 1.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 + :arsceneview release compile clean, :sceneview-core KMP metadata + JS compile clean, and the Android unit suites pass (:sceneview 494 tests, :arsceneview 709 tests — 0 failures). (#2391)
  • samples/android-demo: the ar-placement demo now renders through the shared TapToPlaceArSession engine instead of its own inline ARSceneView, 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 TapToPlaceArSession engine (#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 TapToPlaceArSession engine (common/placement/) — the foundational shared session behind the AR View tab and the ar-placement demo (#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-tested PlacementHitPolicy. 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 another peer is rejected as spoofed, and a second live connection claiming an already-connected peer id is rejected at initiation. (Per-connection integrity: absent the shouldAcceptConnection out-of-band check, peer ids remain self-advertised names.) CollaborativeState rosters are bounded (MAX_PARTICIPANTS = 64, MAX_NODES = 1024, overridable via constructor) against forged-key memory-amplification DoS. NearbyCollaborativeTransport gains an optional shouldAcceptConnection trust gate exposing the Nearby authenticationDigest for out-of-band pairing, guards the ConnectionsClient.MAX_BYTES_DATA_SIZE BYTES payload limit, fail-closes a throwing shouldAcceptConnection gate, observes sendPayload failures, and documents the same-serviceId auto-accept trust boundary. Wire-format vector parsing early-bails on oversized [...] bodies. Adds the promised NearbyPayloadFramingTest / NearbyPeerRegistryTest plus impersonation-rejection and roster-cap tests.
  • Renamed the Android source files Scene.kt → SceneView.kt and ARScene.kt → ARSceneView.kt so each file matches the primary composable it defines (SceneView / ARSceneView); the bare Scene / ARScene are 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: ARDepthColliderDemo now 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_PROJECTION permission and android:foregroundServiceType="mediaProjection" on FeedbackRecordingService (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 iOS DemoDeepLinkRegistry only wired the unpublished ar-recording id; the canonical ar-record-playback (used by the website QR landing page, llms.txt, and the Android catalog) fell through to the placeholder. Both ids now route to ARRecorderDemo (#2370).
  • Node.worldQuaternion (and worldRotation) now round-trips again on parented nodes (#2392). A 4.15.2 → 4.17.0 regression: setting a child's world-space rotation via node.worldQuaternion = X under a parent with a non-identity world rotation silently produced parentWorldRotation ⊗ X on read-back instead of X, scattering per-frame billboards and mis-placing rotated child meshes. The world-space TRS cache (#2280/#2264) combined with the cached-quaternion fast path in getLocalQuaternion (#2294/#2267) made the world→local conversion that backs the setter trust the parent's cached worldQuaternion, which could be stale relative to Filament's live world matrix. The conversion helpers (getLocalQuaternion/getWorldQuaternion plus the position/scale/transform siblings) now re-validate against the live TransformManager world 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 in NodeWorldQuaternionRoundTripTest.
  • sceneview-web: Guard the on-demand render gate (#2332) against a frozen canvas after a failed model/IBL/skybox load. A failed fetch now 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. Added LoadModelErrorSignalTest regression coverage. (#2409)
  • release-device-qa-gate.sh no longer false-FAILs the release on the advisory ar leg. Per the CLAUDE.md "Release-gate policy for continue-on-error legs (#1651)", only web is BLOCKING; android and ar are ADVISORY (flaky emulator / CI assumeTrue-SKIP when the bundled recording or Play Services for AR is absent). The gate's default graded sets are now REQUIRED=web / ADVISORY=android,ar (matching device-qa.sh's pre-computed releaseGate.verdict), it honours --advisory=/--required= CLI overrides, and a test-release-device-qa-gate.sh self-test guards the policy. (#2433)
  • macOS App Store demo-app upload no longer rejected for a duplicate CFBundleVersion (#2443). The iOS-demo Xcode project pinned CURRENT_PROJECT_VERSION as a static literal (build 366), 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.yml now computes BUILD_NUMBER=$(( github.run_number + 1000 )) in bash and passes CURRENT_PROJECT_VERSION="$BUILD_NUMBER" into the xcodebuild archive invocation for both the iOS and macOS legs — a command-line build setting overrides the project-file literal at archive time, and github.run_number is strictly increasing, so every upload now carries a fresh, monotonic CFBundleVersion. The +1000 offset 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 to project.pbxproj (the static literal is now irrelevant at archive time); the marketing version MARKETING_VERSION = 4.18.0 and the sync-versions.sh check that guards it were already correct.
  • rememberHDREnvironment / rememberKTXEnvironment no longer leak the previously loaded Environment (its IndirectLight + Skybox GPU textures) when the asset path changes. The factories now dispose the prior environment on a key swap via a DisposableEffect, matching the sibling rememberEnvironment(key = …) — previously produceState only cancelled the loader coroutine and the old IBL/skybox stayed GPU-resident until the whole SceneView left composition (e.g. a time-of-day HDR slider leaked one set per swap). (#2458)
  • rememberModelInstance (both overloads) no longer leaks the previously loaded Model (its Filament textures, vertex/index buffers and materials) when the model path changes. The factory now destroys the prior Model (modelLoader.destroyModel(it.model)) on a key swap and on leave-composition via a DisposableEffect — previously produceState only cancelled the loader coroutine and the old Model stayed in ModelLoader.models, GPU-resident until the whole SceneView left composition (e.g. the Sketchfab gallery swap leaked one model per swap). Disposal is ordered after the consuming ModelNode detaches 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 one EntityManager slot per SceneView create→destroy cycle — the exact inverse of the #1700 light-component leak. destroy() now also calls engine.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, since WebXRSession/ARSceneView/VRSceneView create and destroy a SceneView per session (#2045). (#2461)
  • Fixed generateCapsule (shared sceneview-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 a height == 2 * radius capsule 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 Torus rendering 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 Capsule geometry 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: the offset parameter defaulted to weights.size, writing the weights past the end of the morph-target buffer instead of at the start. It now defaults to 0 (matching Filament), so setMorphWeights(floatArrayOf(1f)) correctly drives the first morph target. (#2471)
  • Fixed the shared sceneview-core TorusGeometry generator 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 Android Torus fix (#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 selectedModel val from first composition, so picking Fox/Soldier/etc. updated the pill but never the placement. The tap handler now reads arModels[selectedModelIndex] through state at tap time (mirroring ARPlacementDemo), 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 a cameraPositionProvider, 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× to 1.0× (Filament's stock cinematic strength). The old 2.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 to 6× 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 first onSessionUpdated frame. 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 ARFaceDemo UX 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_AUTHORIZED message 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-mode Manipulator, which does not clamp its polar angle; once the eye crossed directly over/under the model the fixed world-up lookAt collapsed 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 by OrbitEyePitchClampTest. (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 of System.identityHashCode, which is not collision-free — a new plane whose hash collided with an already-reported one could silently drop its onPlaneDetected callback (#2488).
  • sceneview-core: worldToLocalScale / localToWorldScale no longer transform a scale through the Mat4 * Float3 point 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 documented closed parameter now has an effect. Previously closed = false produced byte-for-byte the same fully-closed surface as closed = true; it now leaves the final angular seam unstitched, producing the open lathe the parameter promises (#2490).
  • rememberOnGestureListener no longer freezes its callbacks at first composition. The previous remember(creator) captured every callback lambda once, so any handler that closed over a derived val (rather than reading Compose State inside 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 through rememberUpdatedState, 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 on platforms-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), SceneView never re-pushed the surface viewport to the new manipulator, so Filament's ORBIT pan divided the touch pixel by a stale 1×1 viewport and the pan delta exploded by ~1000×. SceneView now 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 no Frame.createAnchorOrNull extension exists (ARCore's Frame.session field is package-private, so the extension is infeasible). Swept all occurrences to the real Trackable.createAnchorOrNull(pose) form (plane.createAnchorOrNull(plane.centerPose)) across ARSceneView KDoc, the AR codelab, migration/showcase docs, and samples/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.html carried a malformed SVG d path (…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:0 children, and the fallback visual's conflicting fixed height:500px (which imposed an ~889px intrinsic width via aspect-ratio:16/9) now derives from the aspect ratio (#2560).
  • Website: repaired 5 SVG icon paths in index.html corrupted by a historical version find-replace (4.18.0 injected into arc commands), and added a sync-versions.sh guard that fails when a version string appears inside any d="…" 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.0 cache-busters) and pinned each surface in sync-versions.sh so they can't drift again (#2564).
  • Website: 3D was dead site-wide — the pages' CSP script-src was missing 'unsafe-eval', which Filament's Emscripten WASM glue requires, so Filament() 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-visible styles — 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 behind window.SCENEVIEW_DEBUG, dead CSS grids removed, the permanently-hidden duplicate #hamburger button removed from all 9 pages, scroll-reveal consolidated into script.js (single implementation, now honoring prefers-reduced-motion everywhere) (#2568).
  • arsceneview: ARSceneView's reactive LaunchedEffect for flashMode and every typed Config.*Mode param (depthMode, planeFindingMode, instantPlacementMode, geospatialMode, streetscapeGeometryMode, cloudAnchorMode, augmentedFaceMode, imageStabilizationMode, semanticMode, updateMode, focusMode) no longer reverts a sessionConfiguration callback 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-invoking sessionConfiguration. 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.AUTOMATIC with the depthMode param left at its DISABLED default — had that mode permanently reset moments after creation, with no exception or log. A new ChangeGate tracks each param's last-applied value so only a real, later change triggers reconfiguration (#2573). For focusMode specifically, the redundant force-reapply in onSessionResumed (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 in RENDER_CENTER mode (#2504, audit row AR10). The per-frame plane !in updatedPlanes visibility check ran against ARCore's JNI-backed list, an O(M) linear scan repeated for every active plane visualizer — O(N×M) every frame. updatedPlanes is now hoisted into a HashSet once per frame so the membership test is O(1) (O(N+M) total). Behaviour is byte-identical: ARCore returns the same Plane instance per trackable across frames, so identity-based set membership matches the previous list contains exactly. No public API change.

Tests

  • DemoRenderingScreenshotTest now asserts (not silently skips) for 3 more unified demos (#2323). Captured + committed render-goldens for two-d-in-three-d, materials, and custom-geometry, so their per-tab @Test methods now compare against a checked-in baseline instead of taking the first-run assumeTrue-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, and secondary-camera render an empty SceneView under qa_mode on the capture profile (no clean settled frame to bake in), and lighting-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 new demo-render-goldens job (#2587); DemoRenderingScreenshotTest now compares instead of silently skipping for them (#2323).
  • render-tests.yml gains a demo-render-goldens job: 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 into ar.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 the llms.txt mapping table and the website-static mirror; VideoNode(url:size:) → VideoNode.load(...) and LineNode(start:end:color:) → LineNode(from:to:color:) in cheatsheet-ios.md (and the iOS agent-skill cheatsheet, for consistency). iOS ViewNode: added an honest "⚠️ Coming soon (deferred)" note to the primary llms.txt surface — 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: the model(url) { scale(); autoAnimate() } builder options were documented as functional but SceneViewBuilder.apply() never reads them (silent no-ops); the llms.txt web-builder example (and mirror) now note that per-model scale() and disabling autoAnimate() are not yet wired on web (glTF animation 0 auto-plays by default), with wiring tracked by #2432. Docs-only — no library code changed. (The ImageNode/BillboardNode cheatsheet 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, and samples/recipes/ — Android cameraExposure is 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_RADIUS are 1.0f, Size is a Float3); documented missing public API (onPlaybackFailed, onConfigDowngraded/ARConfigDowngrade, playbackDatasetUri, autoFitContent, LightNode(color=), PhysicsNode(floorProvider=), iOS GeometryNode.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 stale llms.txt troubleshooting line that still called rememberARView's tone mapper "Linear" (it is Filmic, #1434 — the same correction #2524 applied to the capabilities row), and documented that the AR renderQuality preset is Android-only (the iOS .renderQuality(_:) modifier is 3D-SceneView-only) in both the llms.txt "Android-only" parity table and the iOS agent-skill cheatsheet, so an AI does not generate an ARSceneView(...).renderQuality(...) call that fails to compile on iOS.
  • Weekly doc↔API drift audit: corrected the SceneView and ARSceneView parameter order in llms.txt to match the actual Kotlin source. SceneView now lists renderQuality / autoCenterContent / autoFitContent right after isOpaque (was misplaced after lifecycle), and ARSceneView lists cameraExposure after cameraNode (was misplaced after isOpaque). No API change — reference accuracy only.
  • Fixed a non-compiling FogNode initializer in the iOS cheatsheets. Both docs/docs/cheatsheet-ios.md and the sceneview-ios agent skill cheatsheet documented FogNode(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 form FogNode.linear(start:end:color:) / FogNode.exponential(density:color:), matching llms.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 tab param (#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 — so sceneview://demo/shape opened 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 existing Bash(git push*) pre-push hook and BLOCKS the push when the canonical STATE.md asserts an affirmative ✅-stamped success-claim that lacks fresh, agreeing evidence on disk. Verifying tools now stamp that evidence: device-qa.sh already writes device-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 via date -u, never Date.now()). The gate FAILS a QA-complete claim when the report is missing/stale, a key-gated sub-leg (sketchfab / arcore-cloud) is skipped (path NOT tested, #2343), or the release verdict is blocked; it FAILS an all-live / "verified live" claim when the probe is missing/stale or verdict != 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 durable feedback_*.md memory rule; /handoff runs the gate against the drafted ## NOW and backstops the ledger promotion. Verified exhaustively against the real STATE.md (zero false-positive) plus a fixture matrix (skipped key-leg, missing/stale/unparseable evidence, version mismatch, honest factual lines, evidence-backed pass).
  • SceneViewSwift gains shared preset-polygon helpers — ShapePresets plus ShapeNode.starPoints(...) / ShapeNode.regularPolygonPoints(...) (#2354). The Shape Extrude gallery's preset outlines (triangle, star, pentagon, hexagon, L-shape, arrow) now live once in the library as ShapePresets, and the two point generators behind ShapeNode.star(...) / ShapeNode.regularPolygon(...) are exposed so callers can get the raw [SIMD2<Float>] vertices without building a node. The iOS demo's ShapeExtrudeDemo and the SceneViewSwift triangulation 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. Existing ShapeNode.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.renderableInstance and LightComponent.lightInstance document the caching contract explicitly (a Kotlin interface property cannot hold a backing field, so the cache must live in the implementer). ARCameraStream — the one production RenderableComponent implementer that was paying the uncached interface default — now caches its renderable-instance handle lazily-once, mirroring RenderableNode/LightNode, so per-frame camera-texture swaps and priority/material reads no longer issue a getInstance JNI thunk each access.
  • ModelInstance.renderableInstances / lightEntityInstances resolve 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 — ModelInstance is an external FilamentInstance typealias with no invalidation hook, and a stale handle list would be the exact silent native-handle bug this audit targets.
  • HitResultNode gains an opt-in refreshIntervalMs (default 0 = run the ARCore Frame.hitTest every frame, byte-for-byte as before) that rate-limits the per-pixel raycast the same way PointCloudNode/DepthMeshNode rate-limit their rebuilds; between hit tests the node keeps its last pose and the smooth-transform interpolation still runs every frame.
  • PlaneVisualizer (V1) and PlaneVisualizerV2 reuse a pre-allocated 2-element list for updateRenderable()'s primitive selection instead of allocating a fresh buildList { } per plane per frame; the shared selectPlanePrimitives helper 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 every RealityView.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's look(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-5 rad / 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 a refreshContentCentering re-fit (which mutates target/orbitRadius then re-calls applyCamera) 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-web now 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 called requestRender() — so an idle static scene no longer runs the full SSAO+bloom+TAA pipeline 60×/second. The requestAnimationFrame loop 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 the requestAnimationFrame callback reference is hoisted. (#2332)
  • Dropped per-setup micro-allocations in sceneview-core geometry/animation: AnimationSequence.currentStepIndex, generateExtrude, and generateLathe now use index loops instead of withIndex() (no per-step/per-point IndexedValue boxing), and generateIcosphere builds its index list directly into a pre-sized list instead of allocating a List per face. Behavior-preserving (geometry/animation output is byte-identical, verified by the existing test suites). Part of #2402.
  • Perf: cache Node parent/local-transform reads and reuse the Pose.transform scratch 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 existing Node._worldTransform / _transformInstance cache pattern: Node.parentEntity no longer calls TransformManager.getParentOrNull() on every read (#2403); Node.parentInstance no longer calls getParentOrNull() + 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.transform no longer calls TransformManager.getTransform() (a JNI round-trip plus a FloatArray(16) + Mat4 allocation) on every read; the cache is populated by both local-matrix write paths with the exact matrix pushed to Filament, so the per-frame node.transform read an animated node makes (NodeAnimationDelegate.onFrame) is served without JNI even while the animation writes every tick (#2405). Pose.transform reuses a per-thread scratch FloatArray(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-backed androidTest (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 .usdz extension; when a model offers no USDZ it surfaces an honest "not available in USDZ" message instead of a generic error. The demo target also pins PRODUCT_MODULE_NAME so the macOS PRODUCT_NAME rename (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_NAME was left at $(TARGET_NAME), so the built bundle, executable and CFBundleName were all SceneViewDemo — 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_NAME is now pinned to SceneView for the app target (the io.github.sceneview.demo bundle identifier is unchanged, so existing installs upgrade in place). Completes the partial #1688 fix, which had only renamed CFBundleName.
  • 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_dispatch submit run that died between POST /v1/reviewSubmissions and the final submitted: true PATCH (e.g. reviewSubmissionItems errored) left an open, unsubmitted reviewSubmission attached 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.yml now lists GET /v1/apps/{id}/reviewSubmissions?filter[platform]=IOS&filter[state]=READY_FOR_REVIEW,WAITING_FOR_REVIEW,IN_REVIEW,UNRESOLVED_ISSUES and cancels each stale open submission via PATCH {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 resolved file:// model path to the two-argument rememberModelInstance(modelLoader, …), which Kotlin binds to the asset-path overload — it tried to open the file:// URI through AssetManager, failed silently, and left the model null. They now load the resolved file (streamed GLB or bundled fallback) through ModelLoader.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 at khronos_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). The ModelNode composable applied centerOrigin in the underlying node's constructor (position += origin * size) but then immediately overwrote node.position with the position parameter — on creation and on every recomposition — so any non-zero centerOrigin (e.g. Position(0, -1, 0) to bottom-align a model) did nothing and the node rendered at the origin. centerOrigin now composes additively with position: the alignment offset survives when position is 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 imperative ModelNode class 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-demo models embedded their textures as WebP (EXT_texture_webp) and rendered black or untextured for weeks, because Filament's Android prebuilt ships gltfio with WebP support compiled out (isWebpSupported() == false, no image/webp provider — 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 fixes android-tv-demo, which shares the same bundled assets. (khronos_toy_car.glb was already re-encoded separately in #2401.) Net APK asset growth ≈ +5.5 MB (PNG is heavier than WebP; khronos_lantern is the driver — KTX2/Basis is a tracked follow-up to reclaim it). General SDK-level WebP support — and the same EXT_texture_webp limitation on the web build (Filament.js registers no image/webp provider either, so the website-static platform models are affected too) — remains tracked on #2305.
  • perf(sceneview): cache the smoothTransform target's TRS in NodeAnimationDelegate — 3 → 0 Mat4 decompositions/frame (#2324). The smooth-transform slerp hot path still re-decomposed the target Transform every frame (target.position / target.quaternion / target.scale — each a polar decomposition plus column-length sqrts), 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 (structural Mat4 equality), 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 (Android ImageNode/TextNode/ViewNode/MaterialLoader) and the AR semantics_overlay + face_mesh materials all used blending: transparent (premultiplied-alpha compositing) but emitted a straight, non-premultiplied baseColor, so a partial-alpha surface composited as color + (1-alpha)*background — the colour was added at full strength and lowering the alpha never reduced it. Each fragment now premultiplies baseColor.rgb *= baseColor.a, so the surface composites as the intended lerp(background, color, alpha). This is the same fix class as the #2224 AR plane renderer. Opaque (alpha = 1) rendering is unchanged. All five .filamat blobs 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.color reads (overridden in LightNode) now reuse a per-instance scratch FloatArray instead of allocating a throwaway one every read; NodeGestureDelegate.onRotate reuses a shared world-up axis constant instead of allocating a Float3 per rotate event; CameraGestureDetector's TouchPair is built directly from the MotionEvent (1–2 Float2 for the common 1–2-pointer case instead of 3–4); and ModelNode.applyAnimations drops its per-frame MutableIterator/in-place-removal in favour of a reused scratch list. All changes are behaviour-preserving — render output and gesture math are identical.
  • arsceneview Phase-2 hot-path cleanups — per-frame AR allocation/JNI wins (#2329). Two behaviour-preserving micro-optimizations from the hot-path audit (umbrella #2263):
  • AR5 — ARCameraNode projection cache. onCameraUpdated rebuilt the camera projection every tracked frame, allocating a FloatArray(16) + a Transform and firing two redundant JNI calls (Camera.getProjectionMatrix + the Filament projectionTransform setter) for an identical result. The projection is now cached and recomputed only when near/far change or the AR display geometry changes (Frame.hasDisplayGeometryChanged() — the same authoritative signal ARCameraStream already 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 — PointCloudNode opt-in rate-limit. update rebuilt the cloud on every tracked frame — allocating a positions FloatArray plus two direct ByteBuffers for the Filament upload — with no rate-limit (unlike DepthMeshNode/#1810). A new refreshIntervalMs parameter (on the PointCloudNode constructor and rememberPointCloud(...)) gates the rebuild the same way DepthMeshNode does. It defaults to 0 = 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 private entityDragGesture into an internal EntityDragState, so GestureSystemTests exercises the same code the gesture runs — a regression in the real current − previous dispatch or the .onEnded baseline reset is now caught (verified by mutation testing). Also added a test for the screen→world worldTranslation scale + Y-flip that was previously untestable.
  • Every ALL_DEMOS id 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 a when (id) mapping ids to composables. A demo in the catalog but missing a branch crashed the harness with error("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. DemoHostActivity now delegates routing to the collator-generated GeneratedDemos.Screen (the same router the main-app DemoRouter uses), resolving retired ids through DeepLinkRouter aliases first, so it covers every catalog id by construction — the hand-written-when() drift class is eliminated. A new :samples:android-demo:testDebugUnitTest test (DemoHostRoutableTest) asserts the pure, non-composable DemoHostActivity.routableId resolver covers every ALL_DEMOS id 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.xcconfig is absent, so local + QA builds were keyless: SketchfabConfig.apiKey was nil, 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 committed Secrets.xcconfig.template (placeholder only) documents the key, the real Secrets.xcconfig stays gitignored (**/Secrets.xcconfig), and a committed Config.xcconfig #include?s it optionally so a keyless checkout still builds silently. ios-device-qa.sh now sources the shared qa-keys.sh resolver (env → repo-root local.properties sketchfab.api.key), passes the key to xcodebuild as a SKETCHFAB_API_KEY user-defined build setting (substituted into Info.plist's SketchfabAPIKey = $(SKETCHFAB_API_KEY)), and adds a --sketchfab-key override 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-web Kotlin/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 during SceneView.create() and, because the error was only console.error-ed, the returned Promise never settled. Root cause was a chain of init-path bugs hidden because the jsTest suite stubs the Filament externals and the demo/website run the separate hand-authored sceneview.js: Kotlin as/companion access against a Filament external class compiled to instanceof <undefined> (the class binding is captured at module-load, before Filament.init() attaches the embind classes) — TypeError: Right-hand side of 'instanceof' is not an object; Camera.setProjectionFov was called with 4 args where embind enforces 5 (the Camera$Fov direction); LightManager.Builder resolved to undefined; and createAsset/createIblFromKtx1/createSkyFromKtx1 were handed a raw ArrayBuffer instead of a Uint8Array (embind BindingError). External Filament types are now resolved lazily through the runtime Filament global with unsafeCast, the Camera$Fov/LightManager$Type enums and Uint8Array views are passed explicitly, and SceneView.create() gained an onError hook so createViewer() 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.ts loads the production sceneview-web.js bundle next to a version-matched filament.js/.wasm and asserts createViewer().then(...) resolves and renders non-blank — the gap that let the init crash above ship invisibly (the Karma jsTest suite stubs Filament). Built + staged + run by .claude/scripts/web-bundle-smoke.sh, wired as a blocking step in the web-desktop CI 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 AppliedCameraState out of the private SceneViewRepresentation to a top-level internal type so approximatelyMatches is directly testable, and added AppliedCameraStateTests (identical-state match, per-scalar/target re-apply above eps, sub-eps still-matches, mode change, nil ↔ non-nil firstPersonEye). Added a web OrbitCameraController idle test under the shipped default (enableDamping = true) asserting a settled camera reports not-moved so the render gate idles. Documented OrbitCameraController.update()'s Boolean moved-signal in llms.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.txt is 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.sh greps the AI-facing surfaces (llms.txt outside the collator markers, docs/docs/recipes/*, samples/recipes/*, the Android + web agent skills) for *Demo class tokens that exist on no platform, dead *Demo.{kt,swift,ts} source links, and retired ids in sceneview://demo/<id> deep-link form. It is precise by design — it does not flag the iOS demo app's legitimately-separate *Demo.swift files that still exist, nor a retired id used as a plain prose phrase (e.g. "the gesture-editing API") — and is wired into ci.yml → repo-hygiene as an advisory (non-blocking) step alongside check-doc-drift.sh, self-tested first by test-check-demo-class-refs.sh. The two currently-known stale Picker-pattern references (AnimationDemo / PhysicsDemo → AnimationPhysicsDemo in llms.txt and docs/docs/recipes/demo-settings-sheet.md) are corrected so the guard runs clean.
  • Documented the new HitResultNode.refreshIntervalMs hit-test throttle in llms.txt (#2328). #2328 added an opt-in refreshIntervalMs rate-limit to HitResultNode (mirroring PointCloudNode/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 / ImageNode signatures that did not exist in the Swift source. The iOS cheatsheets and the cross-platform tables in llms.txt documented BillboardNode(named:width:height:), BillboardNode(text:fontSize:color:), ImageNode(named:size:) and an ImageNode.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:) and ImageNode.load("img.png"), across docs/docs/cheatsheet-ios.md, agents/sceneview-ios/references/{cheatsheet,recipes}.md, llms.txt and website-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-decomposed slerp overload, collision Ray by-ref, camera-manipulator memoization, batched ARCore updated-set lookups, web requestAnimationFrame allocation elimination, and SwiftUI auto-rotate off @State.
  • Correctness fixes: AR PoseNode pose 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-controls and gesture-editing demos consolidated into a single camera-gestures entry 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. Existing sceneview://demo/camera-controls and sceneview://demo/gesture-editing deep links keep working via DEMO_ID_ALIASES.
  • Samples catalog: unified Custom Geometry demo (#2239). The retired custom-mesh and shape demos consolidated into a single custom-geometry entry with a segmented-button toggle between Custom Mesh (composite primitives) and Shape Extrude (2D polygon → 3D mesh) modes. Existing sceneview://demo/custom-mesh and sceneview://demo/shape deep links keep working via DEMO_ID_ALIASES. Batch 1 of the 60 → 23 catalog regrouping (iOS mirror follows).
  • Samples catalog: unified Picking & Collision demo (#2239). The retired collision and view-node demos consolidated into a single picking-collision entry with a segmented-button toggle between Ray Hit-Test (tap-driven highlight) and View Node (Compose UI on a textured quad) modes. Existing sceneview://demo/collision and sceneview://demo/view-node deep links keep working via DEMO_ID_ALIASES.
  • Samples catalog: unified 2D in 3D demo (#2239). The retired text, image, video, and billboard demos consolidated into a single two-d-in-three-d entry with a segmented-button toggle between Text (TextNode labels), Image (ImageNode photo gallery), Video (VideoNode streaming MP4 with surface variants + cinematic camera), and Billboard (BillboardNode vs fixed ImageNode) modes. Existing sceneview://demo/text|image|video|billboard deep links keep working via DEMO_ID_ALIASES.
  • Samples catalog: unified Lighting Lab demo (#2239). The retired dynamic-sky, environment, reflection-probes, and post-processing demos consolidated into a single lighting-lab entry with a segmented-button toggle between Sky (DynamicSkyNode time-of-day sun), Environment (HDR IBL switching), Reflections (ReflectionProbeNode local IBL zone), and Post-FX (SSAO / MSAA / FXAA / dithering) modes. Existing sceneview://demo/dynamic-sky|environment|reflection-probes|post-processing deep links keep working via DEMO_ID_ALIASES.
  • Samples catalog: unified Animation & Physics demo (#2239). The retired animation and physics demos consolidated into a single animation-physics entry 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 (PhysicsNode rigid-body simulation dropping streamed crash-test bodies or bundled spheres). Existing sceneview://demo/animation|physics deep links keep working via DEMO_ID_ALIASES.
  • Samples catalog: unified Materials demo (#2239). The retired texture-streaming and occlusion-material demos consolidated into the existing materials entry 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. The materials id stays a live registered demo; existing sceneview://demo/texture-streaming|occlusion-material deep links keep working as aliases via DEMO_ID_ALIASES.
  • Samples catalog: unified Models demo (#2239). The retired multi-model and scene-gallery demos consolidated into the existing flagship model-viewer entry 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. The model-viewer id stays a live registered demo; existing sceneview://demo/multi-model|scene-gallery deep links keep working as aliases via DEMO_ID_ALIASES.
  • Perf: smooth-transform animation hot path (#2265, part of #2263). NodeAnimationDelegate.onFrame now reads node.transform once 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-cached position/quaternion/scale via a new pre-decomposed TRS-tuple slerp(startPosition, startQuaternion, startScale, endPosition, endQuaternion, endScale, …) overload in sceneview-core — halving the per-call matrix decompositions from six to three. Behaviour and trajectories are unchanged; the original slerp(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 / TransformManager instance handle is stable for the lifetime of the component on an entity, so it no longer pays a getInstance JNI thunk on every read. Following the lazy-once pattern PR #2280 introduced for Node.transformInstance, RenderableNode.renderableInstance and LightNode.lightInstance now 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. A 0 (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 / updatedAnchors membership sets once per frame and shares them with every node, instead of each TrackableNode / AnchorNode calling back into ARCore independently (1 JNI thunk + a fresh JNI-allocated List + an O(n) linear contains() 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). rememberDetectedPlanes likewise maintains its tracked-plane set incrementally from frame.getUpdatedPlanes() (an ARCore delta) plus a mutable cache rather than recomputing session.getAllTrackables(Plane).filter { … }.toSet() every Compose frame. Pure performance refactor — identical callbacks fire for the same trackables/anchors/planes, and the public update(session, frame) signatures are unchanged.
  • ModelNode no longer re-scans static renderables' bounding boxes every frame (#2273, part of #2263). sanitizeEmptyBoundingBoxes() runs from onFrame every frame to disable culling/shadows on empty-AABB renderables (and re-enable them once valid), which previously meant a getInstance + getAxisAlignedBoundingBox JNI call plus a FloatArray(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 runtime valid→empty collapse (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-premultiplied color, so it composited as color + (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 premultiplies baseColor.rgb *= baseColor.a, so the plane composites as the intended lerp(background, color, alpha) — a genuine ~20 % translucent grid. Diagnosed and validated with a new non-AR plane-grid-preview shader-QA tool that renders the exact plane_renderer.filamat on a static surface (debug builds only).
  • Node world-space TRS cache completes the #2187 fix (#2264). worldPosition / worldQuaternion / worldScale / worldRotation getters no longer re-decompose the Filament 4×4 matrix on every read. Cached in _worldTransform / _worldPosition / _worldQuaternion / _worldScale / _worldRotation fields, invalidated through onWorldTransformChanged() propagation. worldRotation extracts its Euler angles directly from the matrix (not via the quaternion) to stay bit-equivalent across gimbal-lock boundaries. ModelNode additionally 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.transformInstance cached 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.onFrame reads node.transform once 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; the slerp(Transform, Transform) TRS-overload migration is tracked as a follow-up.)
  • Perf: PoseNode.pose (and ARCameraNode.pose) now write the world translation + rotation directly from the ARCore Pose components instead of routing through worldTransform(pose.transform), which allocated a fresh FloatArray(16) + Transform and ran a matrix decompose/recompose on every anchor / plane / face / image / camera pose update, every frame. Added an allocation-free Pose.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 underlying getLocalQuaternion / getWorldQuaternion) used to decompose a 4×4 matrix into its rotation component every time. A rotation-only conversion never needs the matrix: getWorldQuaternion is parentWorldQuaternion * localQuaternion, and for an unscaled node getLocalQuaternion is inverse(parentWorldQuaternion) * worldQuaternion. New direct-quaternion overloads worldToLocalQuaternion / localToWorldQuaternion (and the Euler variants) skip the decomposition, reusing the world-space quaternion cache from #2264. Behavior-preserving: getLocalQuaternion keeps the exact legacy matrix path for scaled nodes — inverse(M).toQuaternion() and inverse(M.toQuaternion()) diverge once M carries scale (#2294 review), so the fast path is gated on an unscaled world transform. The legacy Transform-taking overloads are kept for source compatibility but @Deprecated. Part of the hot-path allocation audit (#2263).
  • Web refreshContentCentering no 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 every loadModel/addGeometry) and, for each loaded model, crossed the WASM↔JS boundary to re-fetch the TransformManager, read 16 boxed numbers into a fresh [], then allocated a second 16-element array just to add the centring offset. It now caches the TransformManager once at construction, snapshots each model's base transform into a primitive DoubleArray(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 every setTransform into one GPU upload via openLocalTransformTransaction()/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.copyColumnsInto buffer-fill overloads (#2271). Mat4.toColumnsFloatArray() allocated a fresh FloatArray(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-free Mat4.copyColumnsInto(out, offset = 0) / Mat3.copyColumnsInto(out, offset = 0) overloads write directly into a caller-supplied scratch buffer. The hottest callers — TransformManager.setTransform and the Camera.modelTransform setter — now reuse a main-thread scratch buffer (safe: Filament JNI is main-thread-only and copies the array into native memory synchronously). The original toColumnsFloatArray() overloads are unchanged for cold callers. Part of the hot-path performance audit (#2263).
  • Manipulator.transform no longer allocates ~10 objects per frame (#2272). The camera-manipulator transform getter, called once every frame from the render loop, used to allocate an Array<FloatArray>, three FloatArray(3), and several Float3/Mat4/Float4 objects on every invocation. It now reads the look-at vectors into reused scratch buffers and memoizes the resulting Transform, 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-requestAnimationFrame allocation sources in the web render loop that caused a GC sawtooth (worst on iOS Safari). OrbitCameraController.update(), the sceneview.js orbit/freelook/map lookAt branches, 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's TransformManager instance and batches its updates in a single local-transform transaction. Behaviour is identical — only the allocations are removed. (#2274, umbrella #2263)
  • Eliminated per-triangle Vector3 allocations in ray-vs-mesh collision (#2276, umbrella #2263). Ray.getOrigin() / getDirection() each returned a defensive Vector3 copy, 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-internal Ray.originRef() / directionRef() accessors — zero copies on the hot path. The public getOrigin() / 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 .task loop mutated the @State CameraControls value every ~16 ms, invalidating the entire SwiftUI body and re-running RealityView.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 — CameraControls and all view modifiers are unchanged.
  • Light-slot refresh removes the previous light by cached reference instead of tree-walking (#2278). SceneView's refreshLightSlot (and the visionOS refreshImmersiveSkybox) used entities.root.children.first { … } to locate the entity to remove on a slot change. It now caches the provisioned entity reference on AppliedCache and removes it directly — mirroring ARSceneView's coordinator.main/fillLightAnchor pattern — 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.onDrag no longer flies off-screen (#2283). entityDragGesture dispatched SwiftUI's cumulative drag translation on every onChanged tick, but the documented NodeGesture.onDrag contract promises a per-frame delta ("translation delta in world space"). So the natural entity.position += delta handler double-integrated the offset and the entity accelerated away from the pointer. The gesture now tracks the previous cumulative translation per entity (keyed by ObjectIdentifier, in a reference box so per-frame ticks don't churn the SwiftUI body) and dispatches current − 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 through childNodes, 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 extra lookAt recompute; an epsilon would wrongly skip a genuine sub-epsilon move), and the file regained its trailing newline. (3) TransformManager.setTransform gained a debug-only main-thread assert so an off-main-thread caller fails loudly instead of silently corrupting the shared transformScratch buffer; the guard is gated on BuildConfig.DEBUG and is dead-code-eliminated (zero-cost) in release.
  • ModelNode now evicts a renderable from its sanitize-once latch when its geometry or bounding box is explicitly mutated (#2311, part of #2263). The permanentlyValidEntities latch (#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 calling RenderableComponent.setGeometry / setGeometryAt / the axisAlignedBoundingBox setter with a degenerate box on a latched glTF child renderable — could lead to a Filament "AABB can't be empty" crash. ModelNode.RenderableNode now overrides those three mutators to evict the entity from the latch, so the next sanitizeEmptyBoundingBoxes() 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 = … (and position / scale / rotation) used to route through the public transform = 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 private applyCachedTransform() 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 public transform = 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 instrumented NodeLocalTransformDriftTest (the pure-math NodeTransformDriftTest could not catch a setter that round-trips through the real Filament matrix).
  • Fixed DemoHostActivity (debug deep-link test harness) crashing with Unknown demo id when launched for double-pendulum, spatial-audio, or placement-scene — these three demos are registered in the catalog but were missing from the host's when() routing. They now route to their composables. Real users were unaffected (the normal MainActivity deep-link channel always handled them); the crash only hit the instrumentation/manual-QA --es demo_id path. Found during the #2239 interactive QA sweep.

Tests

  • Added DeepLinkRouterTest coverage 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 both validate and parse resolve each retired sceneview://demo/<id> link to its consolidated demo.
  • Removed the orphaned shapeDemo_default_state render-screenshot test left behind by the #2239 Batch 1 custom-geometry consolidation: it launched the retired shape deep-link slug (aliased to custom-geometry) against the deleted shape_default golden, so it only ever silently assumeTrue-skipped. Custom-geometry render coverage is provided by customGeometryDemo_default_state.
  • Fixed a stale llms.txt reference naming the retired gesture-editing demo as the canonical gesture example; it now points at the camera-gestures demo'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 real Engine/TransformManager — the world cache reads TransformManager.getWorldTransform() (a JNI call) so it cannot be covered by the pure-math NodeTransformDriftTest. 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 #2280 parentInstance setter side-fix closed), and adding a child to an already-moved parent — plus a combined never-stale guard. Wired into render-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 exact Node.worldTransform(position =, quaternion =, smooth = true) path that HitResultNode/DepthHitResultNode route through, on a real Filament Engine, 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 a Node/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-tuple slerp / set whole transform; AR Pose.toTransform(scratch); KMP Ray reuse; 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) and llms.txt so 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-gestures interaction test to also exercise the Camera Modes tab (the absorbed camera-controls half), not just Node Gestures.
  • Doc↔API drift audit (manual run): corrected the stale "version 4.15.0" Maven-artifacts label in llms.txt to match the current 4.16.10 install snippets, and fixed two recipes (samples/recipes/procedural-geometry.md, samples/recipes/physics.md) plus samples/README.md that called rememberMaterialInstance(materialLoader) with no color argument — no such single-argument overload exists (every rememberMaterialInstance/createColorInstance overload requires a colour), so the snippets did not compile. Replaced with the SDK-level remember(materialLoader) { materialLoader.createColorInstance(...) } pattern. (The rememberMaterialInstance helper itself lives in samples/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.sh runs per-PR (advisory, in ci.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 weekly doc-audit.yml workflow (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 by test-check-doc-drift.sh.
  • Clarified the rememberMaterialInstance note in llms.txt, gpt/knowledge-api.md and the website .well-known/llms.txt: the flat "there is NO rememberMaterialInstance function" was imprecise (and contradicted the demos, which use a sample-only helper of that name in samples/common). Reworded to say there is none in the published SDK and to copy the materialLoader.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 VIBRATE permission in sceneview library manifest so HapticEngine's Vibrator.vibrate() calls no longer generate MissingPermission lint errors in the library and its consumers.
  • Security: patch CVE-2026-8723 (medium) — pin qs transitive dependency to >=6.15.2 in mcp/packages/rerun, mcp/packages/interior, mcp/packages/gaming, and mcp-gateway via npm overrides.

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 default Short (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 MB counter 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.filamat plane 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 line itself at 0.4 in gridLine() 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" to sceneview and arsceneview library modules so Filament's prebuilt .so files 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-level build.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, collapsing gridLine() 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 via BASE_UV_SCALE = 4.0 (was 8.0) so fwidth(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 .gimbal case to three exhaustive switch statements in CameraControlsDemo that were broken when CameraControlMode.gimbal was 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.gimbal is only available in the iOS 18.2+ / macOS 15.2+ SDK (Xcode 16.2+). Guard it at compile time — .gimbal mode falls back to the orbit gesture path on SDKs older than 16.2. Explicit RealityKit.CameraControls.* qualification added to all four native-mode cases to eliminate the type-inference ambiguity with SceneViewSwift.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's realityViewCameraControls(_:) 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.yml is the first workflow opted into the self-hosted macOS runner introduced in #2192. Its runs-on switched from macos-15 to ${{ vars.SELF_HOSTED_MACOS_ONLINE == 'true' && 'sceneview-mac' || 'macos-15' }} — when Thomas's Mac is online the type-check runs on bare metal (faster, no macos-15 minute spend), otherwise it falls back transparently to the GitHub-hosted runner. Picked as the pilot because of its low trigger frequency (path-gated on flutter/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.gimbal is 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 .so files 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 to main so 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.md for 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 new ar-plane-renderer-v2 demo in samples/android-demo with 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 with roughness 0.35; ceiling (HORIZONTAL_DOWNWARD_FACING) renders warm-white with roughness 0.65; wall (VERTICAL) renders neutral grey with roughness 0.80. Same single Material, one MaterialInstance per 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 via ARSceneView(planeRendererVersion = PlaneRendererBase.Version.V2). PR #4 of #2203.

Changed

  • setup-self-hosted-runner.sh v3 — 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 pilot bridge-ios-compile PR #2204 failed in 34 seconds on the Select Xcode step 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 manual rm -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 to macos-15. Re-running the v3 installer marks it true again automatically via the heartbeat.

Fixed

  • [Android AR] Fix DepthMeshNode never rendering its depth mesh — lastRebuildTimestampMs was initialised to Long.MIN_VALUE, causing the throttle guard (now - lastRebuildTimestampMs < refreshIntervalMs) to overflow to a large negative number on every frame and always return early. Changed to 0L so the first rebuild fires immediately as designed. (#2186)
  • [Android 3D] Fix Node transform floating-point drift when updating position, quaternion, or scale at high frame rates (60–120 Hz) — e.g. node.quaternion = newQ in an onFrame loop (#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 every transform write; individual getters and setters use the caches, eliminating the matrix-decomposition round-trip.
  • catmullRom(): Fix centripetal/chordal parameterisation — the alpha != 0 path now uses the Barry-Goldman pyramidal recurrence over chord-length knots instead of the uniform matrix formula, so alpha = 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's AssetLoader.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: 3 on every release upload (both r0adkll/upload-google-play and the Python promote / fallback paths) so the in-app UpdateBanner actually 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.appUpdateInfo returned UPDATE_NOT_AVAILABLE for 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 :commit with PERMISSION_DENIED — Artifact does not support 16KB page size. Root cause traced via 5 redispatches + 2 diagnostic PRs to libmediapipe_tasks_vision_jni.so from MediaPipe tasks-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. Bumped mediapipe-tasks-vision to 0.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: rebuilt libmediapipe_tasks_vision_jni.so now reports p_align = 0x4000. No API changes between 0.10.14 and 0.10.26 affect SceneView demo usage — compileReleaseKotlin clean. (#2214)

v4.15.3 — 2026-05-26

Changed

  • Self-hosted macOS runner infrastructure (opt-in) — .claude/scripts/setup-self-hosted-runner.sh installs actions/runner, writes a user LaunchAgent plist directly and launchctl bootstraps it (skipping actions/runner's svc.sh, which uses the deprecated launchctl load and fails on macOS 11+ with Input/output error; see actions/runner issue 1424), plus a second launchd heartbeat that updates the repo variables SELF_HOSTED_MACOS_ONLINE / SELF_HOSTED_MACOS_LAST_SEEN. Workflows opt in by changing runs-on: macos-15 to runs-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's KeepAlive=true makes the runner survive reboots, sleep/wake, and the runner's own auto-update cycle. Targets the 6 macos-15 jobs (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.js via a repo-root biome.json. Use cd mcp && npm run biome (advisory) or npm run biome:fix (auto-fix). Excludes generated dist/, mcp/src/generated/, __fixtures__/, vendored qrcode-*.js, and Kotlin/JS-emitted sceneview-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).

  • @claude mention bot — .github/workflows/claude.yml runs the official anthropics/claude-code-action@v1 whenever a contributor drops @claude in an issue body/title, issue comment, PR review, or PR review comment. Auth via CLAUDE_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-slug marker (so parallel sessions never confuse which checkout they're editing), VERSION_NAME from gradle.properties, free RAM in GB (useful for the emulator pool — flags when free RAM drops below the 3 GB EMU_MIN_FREE_RAM_MB floor), 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 to handoff.md for 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:97 that broke the v4.15.2 app-store.yml archive step. The private enum AssocKey { static var delegate = 0 } global (used only as an objc_setAssociatedObject key) is now nonisolated(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's realityViewCameraControls(_:) 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.allowedIds now contains all 42 demo IDs (matching Android's DemoRegistry.kt), so every sceneview://demo/<id> QR code is reachable on iOS — available demos open their real destination; coming-soon demos route to a DeepLinkPlaceholder instead of silently dropping the link. Added missing destination(for:) cases for AnimationDemo, ARInstantPlacementDemo, ARLightingDemo, ARRecorderDemo, MaterialsDemo, OrbitalARDemo, SceneGalleryDemo and MultiModelDemo (#1579).
  • iOS QA mode deep-link arg. Appending ?qa_mode=1 to any sceneview://demo/<id> URL (or passing -qa_mode 1 as a launch argument) writes UserDefaults["qa_mode"], which freezes auto-rotation in ModelViewerScreen and SketchfabModelViewerScreen for deterministic QA screenshots — mirrors Android's qa_mode intent 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. Mirrors lib/android-cli.sh's pattern; falls back gracefully to xcrun simctl when AXe is not installed. Implements slice 1 of the iOS device-QA parity plan. (#1673)
  • SceneMeshNode — new ARCore node wrapping StreetscapeGeometry meshes with unified MeshClassification semantics (#1760). Provides ARKit ARMeshAnchor parity on Android: every face in the mesh is labelled with a MeshClassification (FLOOR, WALL, CEILING, TABLE, SEAT, WINDOW, DOOR, TERRAIN, BUILDING, UNLABELED) and an onClassifiedFace(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 in ARSceneScope; demo added as ar-scene-mesh in the Samples tab.
  • iOS demo: append-only demo registry pattern. Adding a new iOS demo now requires creating a single *Scene.swift file with six header directives (@sceneId, @title, @subtitle, @icon, @category, @available); no other file needs editing. samples/ios-demo/scripts/collate-ios-demos.sh discovers all scene files, sorts them by @sceneId for a stable diff, and emits GeneratedScenes.swift automatically before each Xcode build via a "Collate iOS demos" Run Script phase. GeneratedScenes.swift is .gitignored — parallel PRs adding different demos can never conflict on it (#1872).
  • iOS Augmented Faces demo (ar-face): new ARAugmentedFacesDemo using ARFaceTrackingConfiguration + AnchorEntity(.face); ring of coloured spheres orbiting the face pose tracked by TrueDepth camera (iPhone X+); simulator placeholder for non-device builds. Promotes ar-face from deep-link placeholder to a full iOS demo.
  • iOS AR Depth Occlusion demo (ar-depth-occlusion): new ARDepthOcclusionDemo using SceneReconstructionNode.enableOcclusion() for LiDAR-powered real-world depth masking; toggle to enable/disable occlusion at runtime; graceful fallback banner for non-LiDAR devices; simulator placeholder. Promotes ar-depth-occlusion from deep-link placeholder to a full iOS demo.
  • iOS AR Image Tracking demo (ar-image): new ARImageTrackingDemo using AugmentedImageNode.createImageDatabase() with a bundled QR code reference image; 3D cube overlaid on detected image; simulator placeholder shown on non-device builds. Promotes ar-image from 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 Android ARPlaneNodeDemo. (#910)
  • iOS AR Point Cloud demo (ar-point-cloud): renders ARKit live tracking feature points via ARView.debugOptions.showFeaturePoints, shows a live point-count pill, and offers a toggle to enable/disable the overlay. Mirrors Android ARPointCloudDemo. (#910)
  • iOS — Collision & Hit Test demo: port the collision demo from placeholder to a full implementation — five GeometryNode shapes (cubes and spheres) are tap-highlighted via SceneView.onEntityTapped; an on-screen "Reset Colors" button clears all highlights; Maestro interaction.yaml promoted from placeholder.yaml smoke to a real demo.yaml flow (#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-overlay now routes to the real demo instead of the coming-soon placeholder. (#910)
  • iOS — HDR Environment demo: port the environment demo from placeholder to a full SwiftUI implementation — SceneViewDemo now shows a .demoSettingsSheet with a grid of environment presets (.studio, .outdoor, .sunset, .night, .warm, .autumn, .nightSky) switchable at runtime; Maestro lighting.yaml promoted from placeholder to demo-settings.yaml smoke (#910).
  • iOS — Gesture Editing demo: port the gesture-editing demo from placeholder to a full implementation — a ModelNode (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 OcclusionMaterialDemo shows RealityKit's built-in OcclusionMaterial in action — an invisible, depth-writing plane that cuts a sphere, with a toggle to reveal the occluder as a semi-transparent slab. Reachable via sceneview://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 ARKit personSegmentationWithDepth to hide virtual cubes behind real people walking in front; requires A12+ chip (#910).
  • iOS AR Body Tracker demo (ar-body-tracker): ARBodyTrackingConfiguration + RealityKit BodyTrackedEntity marks the detected skeleton root joint in real time; requires A12+ chip (#910).
  • iOS AR Scene Mesh demo (ar-scene-mesh): ARWorldTrackingConfiguration.sceneReconstruction = .meshWithClassification builds a live LiDAR mesh with a debug wireframe toggle; requires LiDAR device (#910).
  • iOS — Reflection Probes demo: port the reflection-probes demo from a placeholder to a full SwiftUI implementation using ReflectionProbeNode. 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 shape demo from a placeholder to a full SwiftUI implementation using ShapeNode. 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 using PhysicallyBasedMaterial; teaches runtime material swap without geometry rebuild (#910).

Changed

  • Bump Filament from 1.71.0 to 1.71.4 (patch — no .filamat recompile needed; includes Metal async resource loading, bounds-check fixes in filaflat, 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 (commit 6a2b4b4d1).

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.yml submit step): switch from the legacy appStoreVersionSubmissions API to App Store Connect's reviewSubmissions API v3 (2023+). The old endpoint returned 403 "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 frontCameraConfig may fall back to the BACK camera, leaving the selfie feed dead without any user-visible error.
  • CI (quality-gate): feedback-worker npm test is now run as part of the quality gate — a future regression in the worker is caught on every PR that touches feedback-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 route key; add isEmulator() reachability test. (#2032)
  • iOS (SceneViewSwift): SceneEntities.deinit no longer traps if the instance is released off the main thread. Replaced MainActor.assumeIsolated with an explicit Thread.isMainThread guard + DispatchQueue.main.sync fallback so an off-main release degrades gracefully instead of crashing. (#2068)
  • iOS demo (samples): ModelViewerDemo, PhysicsDemo, and SpatialAudioDemo now set .environment(.studio) on their SceneView — 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_id fast-path in workflow_dispatch lets you commit the preserved edit in ~2 min after declaring FGS — no 40-min rebuild needed.
  • ARSceneView: detectConfigDowngrades now captures the post-sessionConfiguration-callback depth mode, so a callback-driven depth-mode request that gets silently downgraded is correctly surfaced as ARConfigDowngrade.DepthMode. (#2122 / #2096 gap 1)
  • MaterialsDemo: fixed infinite "Loading…" scrim when the materials registry category is empty (null selected slug now exits to an Empty state instead of staying in Loading forever). (#2122)
  • Feedback (Android demo): detect emulator in FeedbackContext (isEmulator flag). 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 -PversionName from 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.json and apple-app-site-association returning HTTP 404 on sceneview.github.io — upload-artifact@v7 silently stripped dot-prefixed directories unless include-hidden-files: true is set, causing the deploy job's patch step to fail (#2155).
  • docs.yml: fix /.well-known/ files returning HTTP 404 on sceneview.github.io — peaceiris/actions-gh-pages's internal shelljs cp glob does not expand dot-prefixed subdirectories, so assetlinks.json and apple-app-site-association were 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-placement deep-link aliases — the canonical Android IDs (ar-image-stabilization, ar-pose) were already present in allowedIds; the aliases were unreachable duplicates that silently dropped sceneview://demo/ar-image-stabilization QR-code taps. (#2173)
  • iOS demo — renamed placeholder scenes ArEisScene → ArImageStabilizationScene and ArPosePlacementScene → ArPoseScene so their @sceneId directives match the canonical Android IDs (ar-image-stabilization, ar-pose) used by QR codes and deep links; closes the gap left by #2174 which fixed allowedIds but not the scene catalogue.
  • Fix device-qa.sh crash on macOS (timeout: command not found): lib/maestro.sh now falls back to gtimeout (homebrew coreutils) or runs unbounded when neither GNU timeout variant 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 before startForegroundService raises ForegroundServiceDidNotStartInTimeException. 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.com was returning HTTP 202 + an empty body + x-amzn-waf-action: challenge to any request carrying OkHttp's default User-Agent: okhttp/<version> (treated as bot traffic), so the JSON decoder threw Expected 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-identifying SceneViewDemo/<version> (Android; +https://sceneview.github.io) User-Agent and surfaces a typed WafChallenge error 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 Engine is now pre-warmed at the sheet root on the first transition out of Preview (gated by stage !is Preview), so the ~5 s synchronous JNI cost overlaps with the Ken-Burns + spinner UI of the Downloading stage 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 between Downloading completion and Rendering (an earlier partial fix). The Engine slot survives the Downloading → Rendering transition, so the model appears the instant rememberModelInstance finishes 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_SPACE constant (in the new feedback/FeedbackChrome.kt) applied as bottom contentPadding on 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 live ARSceneView is on screen (via a DisposableEffect toggling FeedbackChrome.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.ViewInAr on every tile. FeaturedArDemo now carries a per-demo ImageVector (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.swift and Videos/sample.mp4 to the Xcode project (project.pbxproj) so the video demo that was already implemented (but orphaned) now compiles and runs. Fixes GeometryNode.plane(width:height:) call to use the correct width:depth: parameter.
  • Fixed BillboardNode silently ignoring billboard rotation on macOS. The #available(iOS 18.0, visionOS 2.0, *) guard in BillboardNode.init(child:) excluded macOS, so BillboardComponent was never applied and entities faced a fixed direction instead of the camera. Since SceneViewSwift requires macOS 15+ (which ships BillboardComponent), the guard is removed. Added a Platform Support table to SceneViewSwift/README.md documenting that SceneView (3D) is fully supported on macOS but ARSceneView is iOS-only (#914).
  • iOS demo (SketchfabService): downloadBinary now surfaces real download progress instead of always emitting 1.0 at completion. Replaced URLSession.download(from:) (no intermediate callbacks) with a URLSessionDownloadDelegate that 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.shareARScreenshot previously used UIView.drawHierarchy, which skips the Metal layer and produces a transparent / black hole where the 3D AR content lives. Now uses ARView.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-web README CDN/API mismatch fixed. The README marketed a non-existent sceneview.js CDN file and a SceneView.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 real sceneview-web.js artifact path and the actual window.sceneview API surface (createViewer, modelViewer, and the SceneViewer instance methods), matching sceneview-web.d.ts.
  • sceneview-web now ships its TypeScript declarations. package.json gained a "types": "sceneview-web.d.ts" field and the hand-written .d.ts is now in files[], so TS consumers get typings instead of any.
  • sceneview-mcp sceneview://known-issues resource no longer crashes on malformed GitHub API items. The issue type guard validated only number/title, then formatIssues unconditionally read issue.user.login, issue.labels and issue.updated_at — a partial API item (e.g. during a GitHub incident) threw a TypeError and took down the whole resource. Items are now normalized with safe defaults for user, labels and updated_at.

Tests

  • iOS deep-link registry: sync DemoDeepLinkRegistry.allowedIds to 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.md and llms.txt to mark SceneReconstructionNode (renderable mesh) and enablePhysics(in:) (physics collider) as Available — closes the documentation gap from #1860. The library wrapper ships since the earlier SceneReconstructionNode.swift implementation.
  • docs(ios) — samples-ios.md refreshed with the full 59-demo iOS catalog table (3D Basics, Lighting, Content, Interaction, Advanced, AR) and updated minimal working examples including the new CameraControlMode native 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). ARSceneView now exposes an onConfigDowngraded callback that fires with a typed ARConfigDowngrade (DepthMode or CameraConfig) 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-demo Swift source so the SceneViewDemo macOS target compiles: added cross-platform Color.systemBackground / secondarySystemBackground / tertiarySystemBackground helpers and a navigationBarTitleInline() modifier in Theme.swift, #if os(iOS)-guarded the iOS 18 .zoom(sourceID:in:) navigation transition and the topBarTrailing toolbar 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 SharedPreferences keyed 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.
  • materials and scene-gallery Android 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 a null path that left the loading scrim spinning forever. Both demos are flagged DemoStatus.KnownIssue so 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.swift were fabricated and returned HTTP 404, silently breaking every streamed sample demo. They are replaced with 29 real, API-validated Sketchfab models (each verified 200 + isDownloadable: true + CC-BY 4.0). The materials and scene-gallery demos no longer hang and are restored from KnownIssue to Working (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 to SketchfabService to cut rate-limit (429) pressure on basic-plan keys, and the verify-sketchfab-key CI step now runs on workflow_dispatch release paths, not only tag pushes.
  • Fixed the android-demo release AAB build failing at minifyReleaseWithR8 with Missing class javax.lang.model.**. MediaPipe's tasks-vision POM dragged the full com.google.auto.value:auto-value annotation processor (and a shaded JavaPoet) onto the runtime/minify classpath; R8 full-mode promoted the compile-time-only javax.lang.model.** JDK classes to a hard error. AutoValue is now excluded from the tasks-vision dependency and matching -dontwarn keep 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 a rememberModelDemoEnvironment helper 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 + -demo launch 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 under samples/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 (reusing DemoDeepLinkRegistry), giving the capture pipeline a deterministic, dialog-free entry point alongside the existing sceneview://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 coroutine TimeoutCancellationException: cross-session message propagation ran on Dispatchers.Default while assertions polled a real withTimeout, so a contended runner could starve the thread pool past the deadline. CollaborativeSession now accepts an injectable I/O dispatcher (test-only, production unchanged) and the test drives propagation on a StandardTestDispatcher with runTest virtual 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 ARRecordInterpreter library API and the running ARRecordInterpretation — tracked-frame %, trajectory length, dominant TrackingFailureReason, 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.glb purely 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 202 response now carries a reason field ("quota" vs "github_error") so a caller can tell a deliberate issue-quota throttle from a GitHub-side failure. An empty or whitespace-only Content-Length header is now rejected with 411 instead of slipping through as a zero-length body (Number("") is 0). The Whisper-detected transcript language is surfaced as a Transcript language row in the GitHub issue context table instead of being discarded. The unused 'purged' value was dropped from the feedback.status CHECK constraint (media expiry is tracked by media_purged, never status).
  • 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 — FeedbackRecordingService now re-fits the VirtualDisplay to the rotated screen aspect inside the fixed encoder surface (deliberate, centred letterboxing) on a configuration change. A MediaProjection revoked 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); MeshNode can free its owned VertexBuffer/IndexBuffer and StreetscapeGeometryNode opts in (#2037); ARCameraStream.destroy() releases its IndexBuffer (#2039); Delaunator's legalize() stack grows on demand instead of silently dropping edges (#2041); and the AnchorNode.anchor setter detaches the replaced ARCore anchor (#2043).
  • iOS NodeGesture no longer leaks entities (#2038). Per-entity gesture handlers are now stored in a RealityKit component attached to the target entity instead of in process-global static dictionaries. The handler closures live exactly as long as the entity does — the common onDrag(cube.entity) { cube.position += … } capture pattern no longer leaks the entity and its resources for the whole process lifetime — and two SceneView instances can no longer share or wipe each other's gesture state. removeAllHandlers() is replaced by the scene-scoped removeAllHandlers(under:).
  • iOS CameraControls convenience init minRadius default corrected to 1.0 (#2040). It previously defaulted to the pre-v4.4.0 value 0.5, which clips the perspective camera into geometry on the true-camera orbit path — so CameraControls(mode:sensitivity:) silently re-introduced the bug. Both initializers now agree.
  • iOS ViewNode<Content> documentation is now honest (#2042). ViewNode currently renders a placeholder white plane and does not display the SwiftUI content it 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 12 SceneViewSwift files were removed (the code inside stays). A note in CONTRIBUTING.md keeps new code from re-introducing them.
  • Web XR sessions no longer leak the Filament engine + WebGL context (#2045). WebXRSession, ARSceneView and VRSceneView now destroy the SceneView they created when the session ends — both via stop() and via the onend handler (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 new Camera.setCustomProjection binding, and the VR path renders every XRView into its own viewport instead of only views[0] — VR is now genuinely stereo.
  • Web: createViewerImpl no longer leaks a window resize listener (#2048). The untracked, never-removed listener was redundant with SceneView.autoResize (which also updates the viewport + projection) — it has been removed.
  • Web: sceneview-web.d.ts matches the Kotlin source (#2057). setAutoRotateSpeed is documented as radians per frame (was wrongly "per second" — a ~60x speed error for consumers), the missing setAutoCenterContent method is now declared, and the stale version example is refreshed.
  • Web: committed sceneview-web/package.json no longer carries misleading publish fields (#2058). The main/files/publishConfig entries pointed at a build/dist/js/... path the build never produces; they are removed (CI's release.yml generates the real published manifest) with a comment recording that release.yml is the single source of truth.
  • mcp/dist/ is no longer committed to git (#2047). The compiled tsc output was tracked in version control yet regenerated by the prepare script on every npm install / npm publish, so it silently drifted from src/ — the committed dist/generated/llms-txt.js and dist/generated/version.js embedded 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 .gitignore test-artefact glob is widened from mcp/dist/*.test.js to the whole directory so nested compiled test files are never tracked.
  • Flutter: SceneView/ARSceneView no 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 onTap callback now fires (#2051). The iOS bridge wires SceneViewSwift's entity hit-test to the onTap method channel, matching Android. AR onTap/onPlaneDetected remain 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 FlutterMethodChannel handler in deinit, breaking the retain cycle that kept the hosting controller, ARSession, and scene alive after the Flutter widget was disposed.
  • React Native: onTap / onPlaneDetected events now actually fire (#2053). The two event props were exported but never dispatched. Android now registers them via getExportedCustomDirectEventTypeConstants and dispatches a TapEvent (tapped node name + world position) / PlaneDetectedEvent (one per newly-tracked ARCore plane) through the view's EventDispatcher. iOS wires onTap to SceneViewSwift's tap callback.
  • React Native iOS: geometryNodes / lightNodes parity 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: ARSceneView depthOcclusion / instantPlacement wired to the AR session (#2055). Android now forwards both flags to ARCore via Config.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.podspec resolved its git source to the bare version (4.14.0) but the repo's release tags are v-prefixed; the source tag is now v#{s.version}.
  • Flutter plugin's iOS bridge now compiles against the real SceneViewSwift API (#2065). SceneViewSwiftUIWrapper / ARSceneViewSwiftUIWrapper referenced APIs that do not exist on SceneViewSwift — ModelNode(path) (no String initialiser) and ForEach inside @NodeBuilder (unsupported) — so the iOS plugin never compiled. The wrappers now use the real imperative SceneView { (Entity) -> Void } content closure and the async ModelNode.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 new bridge-ios-compile.yml workflow 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 SceneViewSwift API (#2067). RNSceneViewContent / RNARSceneViewContent referenced APIs that do not exist (ModelNode(String), an ARSceneView { anchor in … } content closure, an overriden requiresMainQueueSetup on a plain NSObject), so the module's iOS support never built. The bridge is rewritten to use the genuine SceneViewSwift surface — async ModelNode.load(_:), SceneView's imperative content init, and ARSceneView's onSessionStarted / onTapOnPlane — and a new rn-ios-compile.yml CI workflow type-checks react-native/react-native-sceneview/ios/*.swift against the real package so this can't regress. The podspec no longer declares a CocoaPods s.dependency on SceneViewSwift (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 setMethodCallHandler now installs the handler with a [weak self] capture; a bare method reference strong-held self, so the previously added deinit could never run and the platform view, hosting controller, and RealityKit/AR scene still leaked on every create/dispose cycle.
  • React Native Android: ARSceneView depthOcclusion / instantPlacement now apply on a live AR session (#2070). PR #2066 forwarded both flags through the consumed arsceneview:4.7.0 sessionConfiguration callback, 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 ARCore Session via onSessionCreated and re-applies the Config from a LaunchedEffect keyed on the two flags, so a runtime depthOcclusion / instantPlacement toggle genuinely reconfigures the running session.
  • CI: two recurring drift classes made structurally impossible (#2071). The docs/docs/llms.txt mirror of root llms.txt is no longer committed — it is regenerated from root llms.txt at docs-build time (docs.yml, before mkdocs build) and .gitignored, so it can never drift and reden the llms.txt mirror in sync quality-gate check on an otherwise-clean PR (same gitignore-and-generate fix as mcp/src/generated/llms-txt.ts #1928 and GeneratedDemos.kt #1976); check-llms-drift.sh now enforces the structural invariant that the mirror stays untracked. Separately, sceneview-web's SCENEVIEW_VERSION constant and its SceneViewVersionTest.kt regression pin are now swept and auto-fixed by sync-versions.sh, so a release version bump no longer leaves the constant stale (shipping a wrong version) and the :sceneview-web:jsTest job red.
  • Flutter iOS AR bridge: clearScene now removes placed models and tap placements no longer leak AnchorEntitys (#2078). The Flutter plugin's iOS AR placement (ARPlacementController in SceneViewPlugin.swift) previously added a fresh AnchorEntity to the ARView scene on every plane tap and never removed any — 100 taps left 100 anchors retained — and clearScene only 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 content AnchorNode is captured once via onSessionStarted, every tap-placed model is added as its child, and a clearScene (sync(to: [])) calls removeAll() 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() and RNARSceneViewContent.placeModels() are driven by SwiftUI .task(id:), which cancels the in-flight task whenever the JS modelNodes prop changes. A cancelled task still resumes past its await 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-check Task.isCancelled immediately after every await and 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-demo Xcode project's MARKETING_VERSION (which drives CFBundleShortVersionString) was frozen at 4.9.0, so every iOS and macOS build since v4.9.0 reported marketing version 4.9.0 to the App Store regardless of the real SDK version — the release pipeline only bumped the build number (CURRENT_PROJECT_VERSION). Both build configurations of the SceneViewDemo app target are now at the source-of-truth version, and sync-versions.sh --fix rewrites MARKETING_VERSION in lockstep with gradle.properties VERSION_NAME so 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.glb carried 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's metallicFactor = 1 the 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-synced samples/android-demo/distribution/play-store/en-GB/graphics/ directory, renamed to tablet7-screenshot-*.png / tablet10-screenshot-*.png so play-store.yml's listing-sync actually uploads them. Removed the re-created dead play/ tree, including the 3 Chromebook captures — the Play edits.images API 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.md and website-static/index.html stated 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.md is also rewritten to describe the current append-only DemoRegistry instead 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 an ARRecordInterpretation — camera trajectory length & extent, tracked-frame ratio with a per-TrackingFailureReason breakdown, 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.sh release-gate (wired into release-checklist.sh section 15) grades the real-world crash & ANR rate from the Play Developer Reporting API — advisory by default, blocking under PLAY_VITALS_HARD=1 (#1691). A new daily play-reviews job in maintenance.yml ingests 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.isPersonOcclusionEnabled occludes virtual objects behind real people using ARCore Scene Semantics' PERSON-class segmentation mask (flagship parity with ARKit ARFrame.segmentationBuffer, AR Foundation AROcclusionManager). New camera_stream_person_occlusion.filamat camera material (a strict superset of the depth-occlusion material) and an ar-people-occlusion demo. Requires Config.SemanticMode.ENABLED; outdoor scenes only (#1761).
  • Body tracking on Android via MediaPipe Pose (#1763): a new io.github.sceneview.ar.body package in arsceneview ships renderer-agnostic BodyPose / BodyLandmark value types and a 17-joint Joint enum named to match ARKit's ARSkeleton.JointName for cross-platform parity. BodyPose.fromMediaPipeLandmarks(...) projects the 33 raw MediaPipe Pose Landmarker landmarks onto the joint set (synthesising ROOT/SPINE/NECK as anatomical midpoints), and SKELETON_BONES exposes the bone topology for overlays. A new ar-body-tracker demo in samples/android-demo runs 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's ARBodyTrackingConfiguration + BodyTrackedEntity this 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 published arsceneview artifact carries only the BodyPose / Joint value types.
  • Collaborative AR — multi-user sessions. New io.github.sceneview.ar.collaborative package brings shared-coordinate-frame multiplayer to ARCore. CollaborativeSession (and the lifecycle-bound rememberCollaborativeSession() helper) orchestrates a shared AR experience on top of the existing CloudAnchorNode: 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 pluggable CollaborativeTransport interface — SceneView deliberately does not pick a stack — shipped alongside an always-available, no-networking LoopbackCollaborativeTransport reference impl that makes the API unit-testable and demonstrable on a single device. CollaborativeWireFormat is pure Kotlin with zero new runtime dependencies, and the whole merge core (CollaborativeState, last-writer-wins) is covered by 52 JVM unit tests. The new ar-collaborative sample demo proves the full sync end-to-end without a second phone. ARCore has no collaborationData API (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 MediaProjection and a mediaProjection foreground 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 POST to 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 configurable BuildConfig field (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 cameraNode binding 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") and movable-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 retired sceneview://demo/movable-light deep link keeps working: DeepLinkRouter aliases it to lighting via a new DEMO_ID_ALIASES table.
  • Demo app: DemoScaffold now exposes an opt-in onReset parameter 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.md so a Cloud Anchor demo no longer routes ERROR_NOT_AUTHORIZED users to a Streetscape-named doc, and updated all 14 references across the demos, arsceneview, build.gradle and llms.txt (#1614). The on-screen Host/Resolve actions already shipped via SceneActionBar in #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-Length validation), 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 Gate workflow shelled out to .github/scripts/ci-gate-aggregate.sh with no actions/checkout step, 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-201 response from the GitHub Pages build API (e.g. an expired PAGES_REBUILD_TOKEN returning 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 / instantPlacement as "Not yet bridged" since those props are accepted but never applied to the ARCore Config, and every README now carries a coverage map. Stale v3.6.1 version strings in the Flutter demo were corrected.
  • verify-sketchfab-key.sh: dropped curl -f from the live API probe so the real HTTP status reaches the case — the 401|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: added branches: [main] to the push: trigger so it no longer runs on every branch push.
  • check-workflow-scripts.sh: now scans every workflow if: expression and fails on a context disallowed in if: (notably secrets) — 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 # Changelog and the first section is no longer dropped on release.
  • Fixed the release pipeline: the secrets context is not allowed in a GitHub Actions step if: expression, which made release.yml (and docs.yml) invalid workflow files and blocked the v4.13.0 publish. The token-presence check is now done inside the step's run: script.

Tests

  • Device-QA screen recording moved to the host-side emulator console (#1671). New android_cli_screenrecord_* helpers use adb emu screenrecord, which is immune to the Emulator 36.x gfxstream regression that recorded -gpu host Filament 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.sh gains --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-vr session 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 (Playwright page.screencast) legs. Host-side capture is immune to the Emulator 36.x gfxstream regression that recorded -gpu host Filament content as near-empty. The Android and iOS QA recordings are now surfaced into device-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 — plus samples/android-demo/DEMO_TESTING.md documenting 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, website privacy.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/iOS Node ↔ Web builder DSL) and correct-vs-incorrect code examples. This stops AI assistants from generating Android-style Node-tree code that does not compile against sceneview-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 RuntimeAugmentedImageDatabase helper (rememberRuntimeAugmentedImageDatabase()) lets you register a brand-new reference image at runtime — e.g. from a photo the user just took — without a pre-bundled arcoreimg database. 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 typed AddImageResult (Added / LowQuality / Error) so low-quality captures are recoverable. New Frame.captureCameraBitmap() and Image.toArgbBitmap() extensions grab the live AR camera frame as an upright ARGB_8888 bitmap 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.yml job that monitors Android App Links + iOS/macOS Universal Links verification health — cross-checks the hosted assetlinks.json / apple-app-site-association against 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).
  • PlacementScene composable (#1765) — one-line tap-to-place AR scene with Sceneform ArFragment parity: bundles ARSceneView + 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. New Placement Scene demo in samples/android-demo.
  • PointCloudNode + rememberPointCloud() (#1773): renders ARCore's live tracking feature points (Frame.acquirePointCloud()) as an in-scene Filament point cloud — AR Foundation ARPointCloudManager parity — with a configurable color and confidence filter. Ships a new Point Cloud AR demo.
  • PlaneNode composable + rememberDetectedPlanes lifecycle helper for arsceneview (#1774): react to ARCore detected-plane lifecycle (onAdded/onUpdated/onRemoved) declaratively from Compose — the SceneView equivalent of AR Foundation's ARPlaneManager.planesChanged — instead of hand-rolling a frame.getUpdatedTrackables(Plane::class.java) loop. New "Plane Lifecycle" demo in samples/android-demo.
  • MaterialLoader.createOcclusionInstance() — invisible, depth-writing material (RealityKit OcclusionMaterial / Sceneform makeOcclusionMaterial parity). Compose helper rememberOcclusionMaterialInstance ships in samples/common. New "Occlusion Material" demo in the Android demo app (Advanced category). For AR scenes that want occlusion against the live depth camera, keep using ARCameraStream.isDepthOcclusionEnabled. (#1776)
  • Scene Semantics label-overlay material (#1868, follow-up of #1730): a new semantics_overlay.filamat Filament material colour-codes ARCore's per-pixel 12-class outdoor segmentation, exposed via MaterialLoader.createSemanticsOverlayInstance(texture, opacity) plus MaterialInstance.setSemanticsTexture / setSemanticsOpacity. ARSceneSemanticsDemo now renders the live segmentation as a camera ↔ semantic blend overlay (with a colour legend) alongside the existing top-3 label HUD.
  • ReticleNode library-level placement reticle (#1882). New arsceneview node + ARSceneScope.ReticleNode { ... } Composable for the "tap to place" UX every AR placement demo previously had to reinvent. ReticleNode is a thin wrapper over HitResultNode — it delegates the screen-coordinate hit test (including #1891's plane-only defaults and the 30 cm minCameraDistance floor) to HitResultNode and adds only the onHitResultChanged callback 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 in onSessionUpdated. Auto-hide on no-hit comes for free from HitResultNode's trackable/visibility contract. Visual marker is left to the caller as a child node so the reticle stays material/style-agnostic. Documented in llms.txt (and the docs mirror) + sceneview-mcp bundle.
  • Jetpack XR hand tracking (Slice 2, #1902): new preview XrHandNode mirrors an androidx.xr.arcore.Hand as a scene-graph node with one child node per skeleton joint, a SceneScope.XrHandNode composable, the JVM-testable XrHandSkeleton joint/bone math, and an ar-hand-tracking demo 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 of AugmentedFaceNode, plus the runtime-free XrFaceMesh adapter and an ar-xr-face sample 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-only ForceTrackingFailureMenu (ARStreetscapeDemo, ARImageDemo, ARSceneSemanticsDemo) now show the FAB only in QA mode, and the verbose "How to test" help cards in ARDepthOcclusionDemo / ARImageStabilizationDemo were 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 single samples/android-demo/distribution/play-store/en-GB/ source of truth (text + graphics/), and extended the play-store.yml listing-sync to upload the feature graphic and screenshots via the Play edits.images API 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>.xml grandfather existing violations and the Detekt CI step is now blocking on NEW violations (#1740).
  • Release builds of the demo apps now fail loud when SKETCHFAB_API_KEY or ARCORE_API_KEY is empty (#1915): the Android assembleRelease/bundleRelease path and the iOS Release archive 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 with SV_ALLOW_MISSING_SECRETS=1.
  • Pruned the unused focusPoint/radius spotlight parameters from plane_renderer.mat (#1922): these declared a half-built "spotlight around the focus point" effect whose fragment-shader falloff and PlaneRenderer.kt setter were both already commented out, so they never affected rendering. The .mat source, the orphaned Kotlin constants/getFocusPoint(...) helper, and the regenerated plane_renderer.filamat blob are all updated together — no behaviour change.
  • CI: split JaCoCo coverage off the PR-blocking unit-test job (#1955) — the blocking Unit tests job now runs the plain testDebugUnitTest suite (fast, deterministic, 30-min timeout), while JaCoCo instrumentation + reports run in a separate non-blocking Coverage (advisory) job, so a slow runner can no longer push the unit-test gate over its timeout and turn CI Gate double-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 CameraControlsDemo and ModelViewerDemo Android 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 same DemoSettings.cameraDistance deep-link hook (#1571).
  • Build: samples/android-demo's GeneratedDemos.kt is no longer committed — it is .gitignored and regenerated before Kotlin compilation by the new generateDemoRegistry Gradle 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): ARCameraStream draws the depth-aware camera quad first (Filament priority 0) when occlusion is enabled so the real-world depth written via gl_FragDepth primes 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): ARDepthOcclusionDemo now shows a transition spinner while the depth toggle rebuilds the camera stream (+ a connectedAndroidTest that flips depth mode 10× and asserts stability); LightEstimator gains an enableColorCorrection toggle and exposes the raw lastColorCorrection triple; sessionConfiguration / sessionCameraConfig KDoc now warns about mid-session config swaps; llms.txt documents camera-config swapping and editable nodes (TransformableNode parity).
  • Fix sceneview.github.io no longer rebuilding on push: GitHub Pages' legacy auto-build does not fire for the SSH deploy-key pushes docs.yml/release.yml make, and the existing "Trigger GitHub Pages build" workaround was permanently skipped because its if: condition tested an env: var set on the same step (not yet in scope) and referenced a non-existent secret. The step now tests secrets.* directly, falls back to the existing PERSONAL_TOKEN, and fails loudly on a bad API response; a daily maintenance.yml job 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-demo is now demo-UI-native (#1941): InAppUpdateManager.checkForUpdate() no longer auto-starts the Google Play consent modal on resume — it only surfaces an integrated Material 3 UpdateBanner ("A new version is available"). A new InAppUpdateManager.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 second onResume in the AVAILABLE window 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 is READY_TO_INSTALL and the install-state listener stays registered until INSTALLED.
  • 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; a destroyed guard stops late Play Core callbacks from mutating state after onDestroy; 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-demo Spatial Audio panel's #audio-play / #audio-stop buttons were dead — clicking Play produced zero Web Audio API activity. The wiring lived only in the Kotlin/JS Main.kt::setupSpatialAudio(), but index.html ships the hand-written js/sceneview.js runtime, 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 the AudioContext (inside the user gesture, per the autoplay policy), fetch+decodeAudioDatas the bundled audio/bell.wav, builds the AudioBufferSourceNode -> PannerNode("HRTF") -> GainNode graph, 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-play click, so CI catches a future regression.
  • Post-v4.12.0 audit polish (#1957): OpenGL.createEglContext() now reports a descriptive EGL context creation failed error instead of a bare !! NPE; cameraConfigFilter { } gains scalar targetFps(…) / depthSensor(…) / stereoCamera(…) convenience functions so single-sensor filters no longer need setOf(…) (the Set API from #1844 is unchanged); malformed Geometry vertex 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.computeAabb with an identical-Z depth frame, and the Frame.hitTestDepth zero-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's cp 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 Gate aggregator no longer red-lights a PR when an advisory check (e.g. Coverage (advisory)) is CANCELLED / SKIPPED / FAILURE — the pass/fail decision now excludes any check whose name matches an ADVISORY_CHECKS substring, for all conclusions (a transient concurrency-cancel of advisory Coverage had blocked otherwise-mergeable PR #1889 for ~3h). The decision logic is factored into .github/scripts/ci-gate-aggregate.sh with a regression suite (test-ci-gate-aggregation.sh, wired into ci.yml repo-hygiene) covering the CANCELLED-advisory case. Also confirmed and pinned: docs.yml uses matching upload-artifact/download-artifact@v7 pairs (no @v8 mismatch), and ci.yml's quality-gate job uses the shared setup-gradle composite action so it gets the gradle-wrapper.jar SHA 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's src/jsMain/.../Main.kt built a web-model-viewer.js bundle that index.html never loaded — the shipped page has always run on its hand-written inline <script> + self-hosted js/sceneview.js. The dead Main.kt/WebXRParityDemos.kt, the Kotlin/JS Gradle wiring (build.gradle.kts, webpack.config.d/, the :samples:web-demo settings.gradle include) are gone; the static deliverable moved from src/jsMain/resources/ to samples/web-demo/site/. The web demo is now a plain static site with one source of truth (the inline JS) — docs.yml deploys it with a verbatim file copy and the Playwright suite serves it directly. Root cause of #1541 and #1944.

Tests

  • QA: web-perf-qa.sh now enforces a tuned Lighthouse perf budget (mobile preset — FCP/LCP/CLS + perf-score) instead of always emitting an advisory verdict, and device-qa.sh records the result as an advisory web-perf leg so a budget breach surfaces in device-qa-report.json's release gate (#1898, follow-up of #1879).
  • Registered the new occlusion.mat/occlusion.filamat (added by #1832) in the tools/GenerateFilamat.sh inventory under a new Profile E (-a vulkan -a opengl -p mobile), so the .filamat ABI 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 root llms.txt) so the deployed sceneview.github.io/llms.txt always 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 AR PointCloudNode, PlaneNode, and ReticleNode additions.

v4.12.0 — 2026-05-21

Added

  • Auto-fit camera framing (#1439): a new library-level helper in io.github.sceneview computes 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; verticalFovDegreesForFocalLength and Box.toAabb() convert Filament's focal-length / Box types; SceneAutoFitState is a one-shot guard for use in a SceneView frame 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-demo scaleToUnits tuning. Android-only for now; iOS already frames from visualBounds (#1026 / #1391).
  • arsceneview: Environment-aware AR fog — ARFogNode (in io.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. Mirrors FogNode's density / color / enabled parameters so the same numbers fog both real and virtual geometry visually consistently, plus AR-only start / end distance bounds. Inspired by ARCore Depth Lab's AR Fog sample. Opt-in, off by default — collapses to a no-op when enabled = false (zero shader cost via a branchless fogEnabled gate). Requires Config.DepthMode.AUTOMATIC (or RAW_DEPTH_ONLY) and ARCameraStream.isDepthOcclusionEnabled = true. The depth-aware camera material (camera_stream_depth.mat) was extended with the fog term and its .filamat blob recompiled with the matching matc 1.71.0 toolchain — see CONTRIBUTING.md. Demo: new ARFogDemo in samples/android-demo (deep link sceneview://demo/ar-fog), with sliders that drive both the real-world fog and a virtual FogNode in lockstep so the parity is visible side-by-side (#1717).
  • New ARMLObjectLabelDemo in 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 new Frame.cameraImage() extension on arsceneview exposing the YUV CPU image for ML / CV pipelines. (#1737, #1733)
  • arsceneview: surfaced 11 ARCore Config.*Mode enums as typed DSL params on ARSceneView — planeFindingMode, depthMode, instantPlacementMode, geospatialMode, streetscapeGeometryMode, cloudAnchorMode, augmentedFaceMode, imageStabilizationMode, semanticMode, updateMode, focusMode. Each defaults to ARCore's recommended value, is applied to the Config BEFORE the sessionConfiguration callback (so the callback still wins as an escape hatch), and is reactive — flipping a param via Compose state reconfigures the running session without recreating ARSceneView. Demos (ARCloudAnchorDemo, ARInstantPlacementDemo, ARPlacementDemo) migrated off the raw sessionConfiguration callback (#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 new XRFeature.IMAGE_TRACKING constant), XRAnchorNode(xrAnchor) (anchors). Mirrors the Android arsceneview composables. XRFrame gains the getDepthInformation(view) and getImageTrackingResults() extensions plus a trackedAnchors accessor (#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-node MediaPlayer backend (Spatializer in phase 2); iOS RealityKit spatial audio; Web Audio PannerNode HRTF. Phase 1 of #1900 — drive the listener with setSpatialAudioListenerPose(position, forward, up) from the render loop; automatic camera tracking is phase 2.
  • New rememberHapticFeedback() (Android) + SceneViewHaptic (iOS) + Web navigator.vibrate fallback. 7 presets (light/medium/heavy/success/warning/error/selection) + continuous() + pattern() + cancel(). continuous(intensity, durationMs) takes a millisecond Int on 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.sh is rewritten to resolve the pinned matc version from gradle/libs.versions.toml, download + cache the matched matc tarball under ~/.cache/sceneview/matc-<version>/, and compile every .mat source with its profile-specific flag list. A new --check mode 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.sh so PRs that ship a .mat edit without a matching .filamat recompile are now blocked automatically. The smoke recompile surfaced and fixed three website-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 typed onSessionFailure (#1759). Both still fire when set; the legacy callback stays available indefinitely for backwards compatibility.
  • rememberARPlaybackStatus ported to the produceState idiom — matches rememberCameraGeospatialPose / rememberEarthState instead of the bespoke LaunchedEffect + mutableStateOf pair.
  • ARSceneScope.DepthHitResultNode adds a custom hitTest: (Frame) -> DepthHitResult? lambda overload — mirrors HitResultNode's 2-overload surface. Apps wanting multi-pixel / moving-reticle depth selection no longer have to subclass the node.
  • cameraConfigFilter { … } DSL: depthSensor and stereoCamera are now Set<…>? instead of singletons — symmetric with targetFps and with the underlying ARCore set*(EnumSet) API. setOf(X) keeps the singleton case ergonomic. Empty sets fail fast (validation moved out of #1845).
  • Cheatsheets refreshed: docs/docs/cheatsheet.md absorbs the Wave-4 ARSceneView(onSessionFailure / playbackDatasetUri / flashMode) parameters; docs/docs/cheatsheet-ios.md lists every new Android-only API surface so AI agents stop emitting iOS code referencing ARSessionFailure, DepthHitResultNode, cameraConfigFilter, Frame.cameraImage(), rememberARPlaybackStatus, or ARRecorder.addTrack / recordTrack / State.IO_ERROR.
  • Changelog fragments gain a Performance category — covers pure perf wins (#1810-style) that don't fit Fixed or Changed. .claude/scripts/collate-changelog.sh recognises it.
  • mcp/src/generated/llms-txt.ts is now build-generated, not committed (#1928). The ~230 KB embedded llms.txt bundle is .gitignored and regenerated by the prebuild / prepare / test npm lifecycle scripts, removing the guaranteed merge conflict every parallel PR that touched llms.txt used to hit. The published sceneview-mcp tarball still ships the compiled dist/generated/llms-txt.js.

Fixed

  • AR demos render placed PBR models as flat-black silhouettes (#1611). Two fixes to the ARSceneView IBL path. (1) The baseline environment.indirectLight is now applied via LaunchedEffect(environment) instead of SideEffect — demos that surface per-frame ARCore state to UI state (latestFrame, isTracking) used to recompose every frame and silently reset scene.indirectLight back to the baseline, dropping the per-frame rebuilt IBL produced by ARCore's ENVIRONMENTAL_HDR estimate. (2) The per-frame rebuild is now gated by shouldRebuildIndirectLight(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 (no irradianceTexture), so the legacy fallback returned a no-IBL builder and Filament collapsed diffuse PBR to black. Pinned by 4 new pure-JVM cases in IndirectLightRebuildDecisionTest. 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.uploadGeometry no longer caches the direct ByteBuffer used for VertexBuffer.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 fresh ByteBuffer.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_KEY secret, 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.yml iOS + macOS archives) fail-fast on tag pushes when the secret is empty/blank or rejected by GET /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.Logger WARN pointing at the local.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: The LARGE_FILES check no longer aborts the whole quality gate under set -euo pipefail when staged files are under the 10 MB threshold (the common case). Restructured the per-file && chain into nested if blocks so the size comparison returning false stays local to the loop iteration instead of propagating through pipefail and bailing the script via set -e. Local pre-push runs now reach the final Quality Gate Summary block as intended (#1914).
  • Filament materials audit Part A (#1918): removed the orphaned view_renderable material — its .mat source and the ~114 KB view_renderable.filamat blob shipped in every APK despite no code path ever loading it (superseded by view_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 .mat does not declare) and no leaked MaterialInstances — every createInstance(...) is tracked by MaterialLoader, ARCameraStream, or PlaneRenderer and destroyed on teardown. Added website-static/materials/README.md documenting the deliberate web-vs-Android divergence, and reviewed the A-vs-B matc flag-profile split as intentional.

Tests

  • Web device-QA: assertRendered() in catalog.spec.ts and the non-blank check in render.spec.ts are 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-swiftshader Chromium 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 .mat source now carries a header comment block (purpose, used-by node/loader, per-parameter contract, matc flag profile), and the CONTRIBUTING.md "Filament runtime ↔ .filamat ABI invariant" section is updated with the tools/GenerateFilamat.sh workflow, the quality-gate.sh drift 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 — new arDepthOfField(view, camera, options) composable + ARDepthOfFieldOptions(focusDepth, blurStrength, enabled) data class wire Filament's native DoF post-pass to the same z-buffer that ARCameraStream's depth-occlusion material already writes (gl_FragDepth in camera_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 .filamat required. Tap-to-focus helper Frame.depthFocusDistance(xPx, yPx): Float? reuses the depth hit-test added in #1712. Opt-in (off by default; zero cost on disabled frames). Requires Config.DepthMode.AUTOMATIC/RAW_DEPTH_ONLY + ARCameraStream.isDepthOcclusionEnabled = true. New ARDepthOfFieldDemo in the sample app demonstrates the canonical wiring; llms.txt documents the API surface (#1716).
  • arsceneview: Scene Semantics API — Config.SemanticMode.ENABLED is now support-gated via ARSession.configure (silently downgrades to DISABLED on devices without the on-device ML model, matching the depthMode / flashMode auto-fallbacks), and three new Frame extensions expose the per-pixel labels: Frame.semanticImage(): Image? (R8 label ordinal raster), Frame.semanticConfidenceImage(): Image? (R8 confidence raster), and Frame.semanticLabelFraction(label: SemanticLabel): Float (cheap GPU-backed pixel-share query, returns 0f when semantics are off / not yet available). Comes with a new ARSceneSemanticsDemo showing a live top-3 label HUD over the camera feed. Outdoor only — the ML model has no indoor training data. The custom .filamat label-overlay material is tracked separately as a follow-up (matc toolchain ABI work) (#1730).
  • arsceneview: surfaced ARCore CPU camera image access via Frame.cameraImage(): Image? — a 1-line wrapper around acquireCameraImage() returning null on NotYetAvailableException and documenting the caller-owned use { } lifecycle. Unblocks ML Kit / OpenCV / custom CV pipelines. Pair with the new cameraConfigFilter { facing = …; targetFps = …; depthSensor = …; stereoCamera = … } DSL on ARSceneView.sessionCameraConfig to pick a session-wide CameraConfig (resolution, FPS, depth/stereo-sensor usage) without hand-rolling Session.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 the androidx.xr.arcore:arcore:1.0.0-alpha14 dependency alias, and records the module / runtime decision in arsceneview/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 an Other escape hatch. New ARSceneView(onSessionFailure: ((ARSessionFailure) -> Unit)? = null) callback dispatches alongside the legacy raw-Exception onSessionFailed so apps can do exhaustive when matching (the compiler catches missing cases the day ARCore adds a new failure category). Original Exception preserved on .cause for every subtype. CloudAnchorNode.onHosted already passed the specific CloudAnchorState (not a binary isError), AugmentedImageNode.trackingMethod + onTrackingMethodChanged already surfaced FULL_TRACKING vs LAST_KNOWN_POSE, and Config.addAugmentedImage's ImageInsufficientQualityException is now routed via the new ARSessionFailure.ImageInsufficientQuality subtype. Backwards compatible — existing onSessionFailed callers see no change (#1759).
  • arsceneview: new SceneUnderstanding data class + ARSceneView(sceneUnderstanding = ...) parameter that groups four scattered AR rendering flags (occlusion, lighting, physics, planeVisualization) into one discoverable knob — mirrors RealityKit's ARView.environment.sceneUnderstanding.options for cross-platform parity. The parameter is opt-in (defaults to null); when null, the individual flags retain their pre-#1767 defaults. Named constants SceneUnderstanding.Full, .Minimal, .None cover 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 surfaces NONE / OK / FINISHED / IO_ERROR (the FINISHED transition is the only public end-of-replay signal, useful for rewind / loop / next-dataset logic).
  • ARRecorder.State.IO_ERROR — distinct from generic ERROR. Set by recordFrame(session) when ARCore reports RecordingStatus.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's RecordingConfig.addTrack + Frame.recordTrackData flow for ML annotation / ground-truth / custom sensor packets written inside the same MP4.
  • ARSceneView(playbackDatasetUri: Uri? = null) — scoped-storage equivalent of the playbackDataset: File? param (Android 10+). Accepts content:// URIs straight from the SAF picker so apps don't have to copy into app-private storage. Mutually exclusive with playbackDataset — setting both throws IllegalArgumentException.
  • samples/web-demo (QA): IWER (Immersive Web Emulation Runtime, iwer@^2.2.1) WebXR shim is now injected into the Playwright page via page.addInitScript(...) under a Meta Quest 3 emulated device profile, and a new tests/webxr.spec.ts clicks #enter-ar / #enter-vr and 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.sh runs Lighthouse (mobile preset) against samples/web-demo and emits web-perf-summary.json with FCP / LCP / CLS + the Lighthouse performance score. Wired into device-qa.sh as 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 any TrackingFailureReason so the actionable-message overlay wired by #1735 can be validated indoors without staging a real failure (dark room, textureless surface, EXCESSIVE_MOTION, etc.). New ForcedTrackingFailure singleton + ForceTrackingFailureMenu() composable section under samples/android-demo/.../common/ — visible only while DemoSettings.qaMode is on (long-press the demo's peek-chip or launch with --ez qa_mode true), so end users never see it. Wired into ARImageDemo as a proof-of-concept; a follow-up issue covers the remaining 11 AR demos that share the same trackingFailureMessage overlay (#1881).
  • samples/android-demo: extended the developer-only force-tracking-failure debug toggle (#1881 / #1887) to the remaining 11 AR demos that consume TrackingFailureReason. ForceTrackingFailureMenu() is now reachable from each demo's Settings sheet (still gated by DemoSettings.qaMode, so end users never see it), and each demo's status-overlay path now reads ForcedTrackingFailure.override directly 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.0 and the legacy video: 'on' capture is replaced with a page.screencast-driven screencast test fixture that brackets every test, writes one .webm per test under test-results/screencasts/<slugified-title>.webm, and exposes a screencast.chapter(title, description?) API for tagging meaningful boundaries (tab switch, model load, failure). device-qa.sh mirrors 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 an SV_BATCH_REBASE=1 env-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.log to 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.kt file under io.github.sceneview.demo.fragments; a collator (samples/android-demo/scripts/collate-demos.sh) aggregates them into GeneratedDemos.kt, sorted by id so two parallel PRs never collide on the same anchor. The quality gate runs the collator in --check mode to block stale generated files.
  • ci: .claude/scripts/worktree-auto-prune.sh polish pass — respects git worktree lock by default (with --unlock-locked override, #1833), broadens active-session detection from node/claude to 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.log for post-incident forensics, batches the merged-PR lookup into a single gh pr list (was N × gh pr view), and wraps lsof in timeout 10s so a hung scan can't hang the prune (#1839). New .claude/scripts/test-worktree-auto-prune.sh exercises 7 scenarios — merged, unmerged, dirty, locked, locked+--unlock-locked, live subprocess, --keep — and runs advisorily inside quality-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.
  • DepthMeshCollisionTest column-order test hardened — added a 90° Y-rotation + non-axis-aligned translate case that compares the inline matmul (post-#1810) against kotlin-math's reference Mat4 * 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.acquireDirectBuffer capped at a new MAX_UPLOAD_BUFFER_BYTES = 1 MB ceiling — 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. New DepthMeshNodeUploadBufferCapTest pins the invariant.
  • DepthHitResultNode per-frame Pose.makeTranslation documented as load-bearing — investigation found ARCore's Pose is immutable by design and DepthHitResult carries 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".
  • rememberARPlaybackStatus already migrated to bare try / catch (e: RuntimeException) in #1857 — no Throwable wrapper allocation on IO_ERROR frames.
  • samples/android-demo: per-demo strings.xml fragments. Title, subtitle, and demo-specific UI strings now live in dedicated res/values/strings_demo_<id>.xml files alongside each demo's *Fragment.kt, so two parallel PRs adding two different demos no longer collide on the central strings.xml. Android's resource merger fans every res/values/*.xml in at build time, so R.string.demo_* references resolve identically (no Kotlin / composable changes). The shared strings.xml keeps only app-level strings (navigation, AR launcher, About, accessibility…). Follow-up of #1797's append-only fragment registry (#1870).
  • samples/android-demo: collate-demos.sh now also rewrites the "Sample app demos (Android)" section of llms.txt (and its mirror docs/docs/llms.txt) between dedicated marker comments, sourced from the same per-demo *Fragment.kt files that drive GeneratedDemos.kt. Adding a new demo no longer touches any llms.txt: drop the fragment, run the collator, regenerate the MCP bundle (node mcp/scripts/generate-llms-txt.js), commit. --check mode bit-compares all three outputs and the existing check-llms-drift.sh + quality-gate.sh wiring picks the new section up unchanged (#1871, follow-up of #1797 / PR #1869).

Fixed

  • .claude/scripts/impact-check.sh: trace line per check + --fail flag + 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 -l patterns are guarded against the pipefail zero-match exit, and an ERR trap names the dying check + line. Default exit is now 0 (report-only); --fail opts in to non-zero for the quality gate. SV_IMPACT_TRACE=1 forces set -x; auto-trace fires when stdout is not a TTY (CI / agent) unless SV_IMPACT_TRACE_AUTO=0 (#1782, #1786).
  • build.gradle: document that the webpack <5.107.0 Yarn resolution pin (added in #1791 for :sceneview-web:jsBrowserProductionWebpack) also covers :sceneview-web:jsTest and :sceneview-web:jsBrowserDistribution. The root cause is shared — kotlin-web-helpers/dist/tc-log-error-webpack.js still does require("webpack/lib/ModuleNotFoundError") after webpack 5.107.0 moved that file to lib/errors/, and karma surfaces the resolution failure with a misleading top-of-stack karma/bin/karma line. The pin already keeps fresh-clone :jsTest runs 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:jsTest and :jsBrowserDistribution): both BUILD SUCCESSFUL with webpack 5.106.2 resolved (#1785).
  • arsceneview: DepthMeshNode.computeAabb now clamps every half-extent below DEGENERATE_AABB_HALF_EXTENT_M to the degenerate cube, not just the all-empty-positions case (#1806). The earlier #1783 fix only handled positions.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's AABB can't be empty SIGABRT. Added DepthMeshNodeAabbTest with 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.update now requires non-zero camera intrinsics (width, height, focal length) so degenerate ARCore frames raise a clear error instead of poisoning latestSnapshot with Inf. Frame.hitTestDepth and unprojectDepthPixel likewise reject zero focal length — the latter throws IllegalArgumentException for direct callers; the former returns null. DepthCollider.setBodiesRegion rejects 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. nearestSurfaceYBelow skips out-of-bounds index triplets so a future drift between positions/indices cannot AIOOBE on the render thread. All paths covered by JVM unit tests.
  • ci: app-store.yml's submit step now GETs the version record's appStoreVersionSubmission relationship 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 stranded 367 draft → renamed to 4.11.1 → POST refused. Closes the last loose end of the #1795 / #1687 saga (#1831).
  • DepthMeshNode no longer leaks the old VertexBuffer / IndexBuffer when an upload step throws mid-frame (engine teardown is the realistic trigger). rebuildBuffersIfNeeded returns the freshly-built buffers without mutating the owned* fields; uploadGeometry commits the swap + destroys the old buffers ONLY after setGeometryAt returns. On any exception the new buffers are safeDestroy-rolled-back and the owned* fields remain reachable for destroy(). Closes a latent leak introduced by the #1805 UAF fix (#1840).
  • ARDepthColliderDemo: collapsed the per-ball apply.onFrame fan-out into a single Scene-level onSessionUpdated callback — publishCollisionRegion now runs ONCE per AR frame (was N times for N balls → ~300 transient FloatArray/sec at 5 balls × 60 fps). Replaced the mutableListOf + activeBallNodes += this pattern with a per-ball-count arrayOfNulls<SphereNode>(ballCount) slot store written by index. Recompositions that don't change ballCount (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 pure packCentres(...) helper with a JVM regression test pinning the "no stale entries bleed through" invariant. Closes #1842.
  • arsceneview: harden ARRecorder + cameraConfigFilter + playback wiring against five privacy / misuse / threading regressions surfaced by Tier-2 Wave 4 security review (#1845): ARSceneView(playbackDatasetUri = …) now allowlists content:// and file:// schemes only (rejects https://, 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() } raises IllegalArgumentException at 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 documented RuntimeException failure point (Session.getSupportedCameraConfigs) so builder errors propagate to dev-time tests; ARRecorder.recordFrame IO_ERROR transition now commits state + errorMessage inside a Snapshot.withMutableSnapshot { } so Compose readers can no longer observe the in-between state == RECORDING paired with a non-null errorMessage; ARRecorder.recordTrack(handle, …) short-circuits to false when handle was not registered via addTrack on the same recorder (was forwarding to ARCore — cross-recorder reuse leaked packets between unrelated recordings); ARRecorder.addTrack is bounded to MAX_PENDING_TRACKS = 64 and a new clearTracks() API drops the in-memory registry (prevents the addTrack(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.sh now blocks llms.txt mirror drift (#1847). The drift detectors for docs/docs/llms.txt and the MCP bundle mcp/src/generated/llms-txt.ts used to live only in sync-versions.sh, which is not called by the PR-blocking gate. A new dedicated check-llms-drift.sh is wired into quality-gate.sh so any divergence from root llms.txt (e.g. the DepthHitResultNode drift that landed via #1822) fails the gate instead of silently sitting on main.
  • 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 under agents/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.sh covering manifest.json related_applications[].id, structured-data.json softwareVersion + releaseNotes tag + Maven prose, plus every per-skill Maven coordinate / npm sceneview-web@ / @sceneview-sdk/react-native@ / SPM tag prose line, with matching --fix rewrites so future releases catch the drift.
  • samples/android-demo: ARRawDepthPointCloudDemo now 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: ARDepthColliderDemo now 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 underlying DepthMeshNode renderable 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: ARPlacementDemo now 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-scope HitResultNode, 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).
  • HitResultNode defaults to plane-only (#1891). The screen-coordinate HitResultNode(xPx, yPx, ...) overload now defaults point = false, depthPoint = false, instantPlacementPoint = false, plus a new defensive minCameraDistance: Float? = 0.3f floor 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. The SKETCHFAB_API_KEY GitHub secret was empty (or whitespace) for several recent releases, so BuildConfig.SKETCHFAB_API_KEY / Info.plist:SketchfabAPIKey resolved to "" and SketchfabConfig.apiKey returned null. 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 every SketchfabAssetResolver-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 through mcp-gateway so this regression class can't recur (#1909).
  • Strip lying "implemented" badges from the Flutter demo (#909). samples/flutter-demo/lib/pages/features_page.dart was claiming green for several methods whose iOS bridge path is a no-op (ModelNode pos/rot, onTap, onPlaneDetected, Environment). Those cards are now labelled "Android only" with the iOS gap pointed at the #909 umbrella. Added a Flutter MethodChannel smoke-test suite and a React Native ARRecorder Jest smoke-test scaffold so future drift surfaces as a red test instead of a green badge.
  • sceneview-web SCENEVIEW_VERSION constant lagged 2 releases (4.9.0 while shipping 4.11.1). Bumped to 4.11.1 and promoted the sync-versions.sh check 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.sh no longer silently deletes worktrees with unmerged work when git fetch fails. Previously a failed fetch only printed a warning and continued with whatever local origin/main was cached, so a worktree on a branch with commits past main could be misclassified as ahead=0 and removed. The fetch now exits with an error; pass --allow-stale to opt back into local refs for offline runs. In --allow-stale mode, candidates additionally require a merged-PR signal — ahead=0 alone is no longer trusted. New active-session guard (on by default, --no-check-active-sessions to disable): a worktree is skipped if any live node/claude process has its cwd inside it. The scan re-runs immediately before the destructive loop to close the prompt-window race. The wrapper cleanup-branches-worktrees.sh propagates --allow-stale when its own fetch fails so offline runs through the wrapper still work.

Docs

  • arsceneview: Tighten ARDepthOfField KDoc with the upstream Filament verification (colorPassOutput.depth is the buffer gl_FragDepth writes 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 the clip.z = 0.9999f vertex hack, MSAA resolve filtering on the depth attachment, and cocParams calibration 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.md mapping the four May 2026 Android-only AR surfaces (DepthMeshNode / DepthCollider / Frame.hitTestDepth / CloudAnchorNode.host Future-cancel) to their RealityKit / ARKit counterparts; root llms.txt cross-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 the ARSessionFailure KDoc + llms.txt examples to use a fully exhaustive when (all 25 subtypes + Other) and removed the else -> 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 without else ->. AI agents copy-pasting the snippet now keep the exhaustive-when guarantee (#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's TrackingStateHelper. Each TrackingFailureReason (BAD_STATE, INSUFFICIENT_LIGHT, EXCESSIVE_MOTION, INSUFFICIENT_FEATURES, CAMERA_UNAVAILABLE) maps to a localised string resource (tracking_failure_* in strings.xml), giving the user actionable guidance instead of nothing. Inlined when branches 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 / onMeshRebuilt now carry @MainThread annotations with KDoc notes spelling out "render-thread only — reading from a background coroutine is unsupported". CloudAnchorNode.hostTask is @Volatile and cancelHost() wraps its read-cancel-clear sequence in synchronized(this) so callers can safely cancel an in-flight host from viewModelScope.launch { … } without racing the ARCore async callback (which fires on the GL/render thread). DepthCollider.setBodiesRegion gains a KDoc render-thread pin matching the surrounding floorYAt / ingestSnapshot contract. A new CloudAnchorNodeThreadingTest reflects the @Volatile and exercises the synchronized contract under contention.

Fixed

  • arsceneview: per-frame IndirectLight no longer leaks native memory across long AR sessions with intermittent light estimation. The IndirectLight built in onARFrame is now tracked via a dedicated AtomicReference and destroyed explicitly on every supersession or on DisposableEffect teardown, independent of scene.indirectLight mutations by third parties. The rebuild decision (estimation vs. environment baseline per channel) is extracted to a pure pickIndirectLightSources helper covered by JVM unit tests (#1756).
  • arsceneview: clarify the depth ByteBuffer lifecycle invariant in ARCameraStream — the buffer borrowed from ARCore's depth Image is 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 the buffer.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-native package.json now declares publishConfig.access=public, anchoring the scoped-package public-access intent inline. release.yml's npm publish --access public CLI 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 registry PUT (#1788).
  • build: v4.11.1 re-validates the sceneview-web Kotlin/JS production webpack chain that broke on v4.11.0 — webpack 5.107.0 moved lib/ModuleNotFoundError.js to lib/errors/ModuleNotFoundError.js while kotlin-web-helpers still resolved the legacy path, crashing every :sceneview-web:jsBrowserProductionWebpack invocation. Fixed on main by the webpack <5.107.0 resolution pin in #1791; this release ensures the production publish + Deploy website + docs pipelines run end-to-end on the v4.11.1 tag (#1789).
  • ci: app-store.yml's submit step now reads VERSION_NAME from the root gradle.properties as its workflow_dispatch fallback for ASC_VERSION_STRING, instead of build_version (which returns CFBundleVersion — the build number, not the marketing version). v4.11.0's manual deploy created a nonsensical App Store version record named 367 and 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 old VertexBuffer/IndexBuffer were safeDestroy'ed before RenderableManager.setGeometryAt rebound 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. Added DepthMeshNodeBufferRebuildTest JUnit suite that pins the ordering across two successive growths via a mock-engine recorder. Surfaced by the May 2026 Tier-2 SECURITY audit.
  • arsceneview: DepthCollider class-level KDoc example now passes the collider through floorProvider = collider instead of the non-existent depthCollider = collider parameter (#1807). Code pasted from the KDoc previously did not compile. The ARSceneScope.rememberDepthCollider KDoc and the PhysicsNode KDoc already used the correct form, so the bug was isolated to DepthCollider.kt.
  • sceneview: deprecated mass-overload PhysicsNode's @Deprecated(ReplaceWith(...)) now preserves the newly-added floorProvider parameter (#1807). The IDE quick-fix on the deprecation previously silently stripped AR floor wiring. The deprecated overload itself also gained a floorProvider parameter so the replacement is a 1:1 source-compatible swap.
  • mcp: regenerated src/generated/llms-txt.ts so 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-returning CloudAnchorNode.host & resolve / TerrainAnchorNode.resolve / RooftopAnchorNode.resolve). Added a sync-versions.sh CI drift guard that rebuilds the bundle in-memory and fails when it disagrees with root llms.txt, so a future sprint can no longer land API additions in llms.txt while leaving MCP clients on a stale snapshot. Documented the regen step in the new mcp/CONTRIBUTING.md (#1808).
  • docs: fixed broken TerrainAnchorNode.resolve and RooftopAnchorNode.resolve examples in llms.txt — both used earth = earth, but the real signature takes session: Session (the function reads session.earth internally). Code pasted from the docs now compiles. Healed mirror drift between root llms.txt and docs/docs/llms.txt (the CloudAnchorRegistry + ttlDays block from #1734 was missing in the mirror). Added a DisposableEffect.onDispose { future?.cancel() } snippet to the Terrain and Rooftop sections so AI agents emit the same cancel-on-dispose pattern they already produce for CloudAnchorNode (#1768). Added a "Threading" note to Frame.hitTestDepth (~L846) and DepthMeshNode (~L1039) — both must run on the AR frame / GL-main thread; KDoc said so already but llms.txt didn't. Added "See also" cross-references between DepthMeshNode, rememberDepthCollider and Frame.hitTestDepth so 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-pass for (n in childNodes) when (n) { is PoseNode -> ...; is DepthMeshNode -> ... } replaces two filterIsInstance<...>().forEach { } walks (~240 list allocations/sec at 60 fps on the render thread).
  • DepthMeshNode.uploadGeometry: vertex / index upload now reuses two cached direct ByteBuffers 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 output FloatArray, removing ~9k transient Mat4 * Float3 allocs/sec.
  • PhysicsBody.step: velocity + position integrated as plain Float triples, committed in exactly 2 Position allocs per body per frame (was 3-4 → ~1200/sec at 5 balls × 60 fps).
  • ARDepthColliderDemo: now drives DepthCollider.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 onDispose ordering on ARScene's per-frame IndirectLight rebuild — clear scene.indirectLight = null BEFORE engine.safeDestroyIndirectLight(...) so a late onARFrame queued on the GL thread cannot dereference a freed native handle (#1814).

Docs

  • arsceneview: document ARCore 1.54's Geospatial Depth in llms.txt and on StreetscapeGeometryNode. Enabling Config.DepthMode.AUTOMATIC together with Config.GeospatialMode.ENABLED and Config.StreetscapeGeometryMode.ENABLED automatically 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, ARCameraStream occlusion) benefits transparently — no API change required (#1731).
  • docs: new migration block in docs/docs/migration.md for the CloudAnchorNode.host() return-type change (Unit → HostCloudAnchorFuture, #1768). Covers the source-compatibility break + the DisposableEffect.onDispose { future.cancel() } recommendation with billing rationale (#1814).
  • llms.txt: DepthHitResultNode section and Frame.hitTestDepth @return KDoc clarification documenting the single-vs-list asymmetry vs Frame.hitTest (depth at one pixel is unique) (#1814).

v4.11.0 — 2026-05-20

Added

  • Android demo: added a cameraDistance zoom deep-link parameter — a --ef camera_distance <f> intent extra and a sceneview://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.yaml now captures a near + far framing for model-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 a DepthHitResult (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.Default is the contemplative SlowCinematic profile. Pairs with SceneView(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 into FileLoader.loadFileBuffer, with Context.fileCacheDir / Context.clearFileCache() to inspect or reclaim it, and FileCache.enabled to opt out.

Changed

  • validate-demo-assets.sh now cross-checks every asset physically bundled under the demo asset roots against assets/catalog.json and 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 lint job (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.yml artifact action versions are aligned, the quality-gate job restores Gradle wrapper validation, and a stale Node-version comment in telemetry-ci.yml is 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(...) and FogNode.heightFalloff are 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's View.fogOptions.heightFalloff. Use FogNode.exponential(density:color:) instead. The iOS Fog demo no longer advertises a "Height" mode. (#1380)
  • Device QA runs no longer show cancelled when only the advisory android/ar emulator leg is flaky (#1643). The emulator-leg script: blocks bounded adb wait-for-device and device-qa.sh with internal timeouts. A flaky CI emulator now produces a clean step failure (absorbed by continue-on-error) instead of letting the job run to timeout-minutes — a timed-out job ends cancelled, 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). PhysicsNode no longer clobbers or destroys the caller's existing Node.onFrame callback — 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, and SceneView.destroy() releases leaked LightManager components (#1697, #1698, #1700). Android TV demo: D-pad controls now work on launch — the root Box is focusable() and requests focus on first composition so key events reach the onKeyEvent handler.
  • PhysicsDemo: each falling body now gets its own ModelInstance spawned from a shared Model, 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 AAB step 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. SceneView no longer triggers "Modifying state during view update" Xcode runtime warnings — appliedMainSlot, appliedFillSlot, and appliedSkyboxResource are now held in a private reference-type cache class rather than individual @State properties, so mutations inside RealityView.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 shared rememberArPlaybackDataset() helper resolves the --es ar_playback_file <path> deep-link extra (set by the autonomous AR replay device-QA harness) into the ARSceneView(playbackDataset = …) parameter. Previously only ar-record-playback consumed the extra, so the harness could only grade the other AR demos alive; they can now graduate to replayed with frame-indexed assertions. When the extra is absent — i.e. every normal launch — the helper returns null and 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 hardened sync-versions.sh to 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 the CLAUDE.md ↔ sync-versions.sh contradiction over mcp/package.json — the Version Location Map now documents sceneview-mcp as an independent npm version track that must NOT be synced to the SDK VERSION_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-demo playground 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-vendored samples/web-demo/.../js/sceneview.js viewer helper — they are a web-DEMO addition and do not change the published SceneViewJS Kotlin/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.sceneview computes 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; verticalFovDegreesForFocalLength and Box.toAabb() convert Filament's focal-length / Box types; SceneAutoFitState is a one-shot guard for use in a SceneView frame 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-demo scaleToUnits tuning. Android-only for now; iOS already frames from visualBounds (#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 MaterialInstance via setMaterialInstanceAt(...) 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 .ktx texture-set CDN) is a documented follow-up.
  • device-qa: added the maestro iOS leg — .maestro/ios/ flows drive every iOS demo reachable via the sceneview://demo/<id> deep link in samples/ios-demo like 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.sh is 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 single device-qa-report.json plus a human-readable summary; it exits non-zero if any selected platform fails, is disk-aware (reuses disk-gated-spawn-check.sh and cleans build output between legs), and degrades a missing emulator/simulator/browser to skipped (treated as a failure under --ci). The release checkpoint (release-checklist.sh + the /release skill) now blocks tagging on a green device-qa-report.json, and a path-gated device-qa.yml CI workflow (also reused by nightly-ci.yml) runs the web and Android legs (#1566).
  • Device-QA emulator can now boot visible (windowed) via the opt-in --window flag or EMU_VISIBLE=1 on setup-ar-emulator.sh; the default stays headless and CI is unchanged (#1660).

Changed

  • Android demo polish (#1443): demo-grid cards now carry a hairline outlineVariant border 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.sh and ar-replay-qa.sh resolving REPO_ROOT one level shy — they live in .claude/scripts/ so the repo root is two levels up, not one. When invoked by device-qa.sh (whose CWD is not the repo root) the scripts cd'd into .claude/ instead, so Maestro flow discovery found nothing ([qa] no such flow: .maestro/android/3d-basics.yaml). All three now derive REPO_ROOT from ${BASH_SOURCE[0]}/../.. so every path (.maestro/..., ./gradlew, the demo module) resolves regardless of the caller's CWD (#1585).
  • sceneview node API honesty (#1598, #1599): verified MeshNode does not leak its RenderableManager component — RenderableNode.destroy() already releases the renderable built on entity (#1598 confirmed stale, no code change needed). Deprecated the PhysicsNode / PhysicsBody mass parameter — the Euler integration applies only gravity, which is mass-independent, so mass was a silent no-op; it is now @Deprecated with 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 on node-version: 20 to match device-qa.yml and docs.yml; deleted the orphan top-level docs/screenshots/ directory (a byte-identical, unreferenced duplicate of docs/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.sh reuses any already-running emulator, gates a fresh boot on free host RAM, scales the -memory flag 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's loop parameter 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 -port whenever live host RAM safely allows (cap floor((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 pins ANDROID_SERIAL to 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. The Automatically delete head branches setting is now enabled, so every PR branch is dropped the instant its PR merges. The cleanup-branches-worktrees.sh backstop was reworked to fetch PR status with two bulk gh pr list calls instead of one gh pr view per branch — the per-branch form fired hundreds of sequential API calls and timed the daily branch-cleanup job out before it could delete anything, which had let the remote grow to ~190 branches.

Fixed

  • visionOS target of the SceneViewSwift Swift package now compiles (#1366): the deployment target is raised to visionOS 2.0 (RealityKit's DirectionalLight/PointLight/SpotLight entities and per-entity shadow API are @available(visionOS 2.0, *)), SceneView uses the cross-platform RealityViewContent initializer on visionOS instead of the @available(visionOS, unavailable) RealityViewCameraContent, light components drop the visionOS-unavailable isRealWorldProxy: initializer parameter, and a new Build Swift Package (visionOS) CI step in ios.yml builds the xrOS SDK on every iOS PR so this can't regress silently.
  • iOS: the Multi-Model Park demo 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, but Multi-Model Park nests its models under an AnchorEntity — 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 the file:// cache URI was handed to AssetManager.open, threw FileNotFoundException, and the four streamed planets stayed null while the four bundled-asset planets kept working. The streamed branch now loads the local file via ModelLoader.loadModelInstance, which understands file:// URIs. Same root cause as the Multi Model demo fix (#1422).
  • React Native: bumped the iOS bridge podspec SceneViewSwift dependency from the year-old ~> 3.4 pin to ~> 4.9, matching the published SPM tag the bridge code already targets. (#1512)
  • iOS test build: AugmentedImageNodeTests.swift failed to compile under the iOS 26.2 SDK (#1515). The AugmentedImageNode.ReferenceImage(name:image:physicalWidth:) initializer became throws in #883, but the test still called it without try — the macOS swift test target stayed green only because it never built the iOS-gated test file. The throwing call sites now use try (and a sibling CameraControlsTests.swift now imports RealityKit for BoundingBox), and the iOS CI workflow's xcodebuild steps gain set -o pipefail so a failing test-build is no longer masked by xcpretty's exit 0.
  • CI: raise Unit tests + coverage and CI Gate timeouts (#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. The Unit tests + coverage job timeout is now 45 min, and the CI Gate aggregator'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.sh redirected the whole wrapper's output to a file shown only after it returned, and qa-android-demos.sh built the demo APK with Gradle -q (no output at all). The Android leg now streams live via tee, builds with --console=plain, and bounds the cold APK build and each Maestro run with timeout so a genuine hang fails fast with a clear diagnostic instead of eating the CI job budget. The job's timeout-minutes is 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.yaml passed the demo deep link as a deepLink: sub-property of launchApp, but Maestro 1.39 has no such property — the flow failed to parse with Unknown Property: deepLink before a single demo ran, so device-qa.sh --platform=android --fast reported passed=0 failed=1. The fix delivers the demo id and qa_mode flag as launchApp arguments: instead, which Maestro maps to intent extras (--es demo <id>, --ez qa_mode true). MainActivity already reads exactly those extras through DeepLinkRouter.validate — the same closed-registry allow-list the sceneview://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 renders test looped over all four primitives in one test body and still overran even the tripled test.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 with Test timeout of 60000ms exceeded on 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 call test.slow() (triples their timeout) and the Models test waits for the demo's real load-completion signal (#loading-chip clearing) via a new waitForModelChipIdle helper instead of a blind waitForTimeout(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 and neutral_ibl.ktx are now bundled under samples/web-demo/src/jsMain/resources/ and a local version.json removes the last 404, eliminating all external asset failures. The sampleCanvas test helper now decodes a Playwright screenshot instead of gl.readPixels, which returned all-zero pixels on Filament's preserveDrawingBuffer:false context even when the canvas was visibly rendering (#1573, #1586, #1362).
  • iOS demo: resolved Swift 6 concurrency warnings — the OrbitalARDemo and DoublePendulumDemo per-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, and SceneViewDemoApp adopts the modern two-parameter onChange(of:) signature (#1574).
  • Web demo: self-host the Filament/SceneView engine (#1586). samples/web-demo's index.html loaded filament.js and sceneview.js from cdn.jsdelivr.net — a jsDelivr hiccup 404'd both engine scripts and turned the Playwright device-QA suite red. Both files (plus filament.wasm) are now bundled under src/jsMain/resources/js/ and referenced by relative path, so they ship with jsBrowserDistribution. 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 — SceneView now exposes an autoFitContent parameter that drives it, moving the camera so the content fills the viewport regardless of the model's intrinsic glTF size. Both SceneAutoFitState and SceneAutoCenterState now use a diagonal-stability gate (Android port of web's AutoCenterGate, #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.loadModel against a use-after-free — a reloaded or destroyed model's pending loadResources callback no longer touches the freed FilamentAsset (#1597).
  • sceneview-web SceneView.loadModel (#1597): the auto-center pass no longer frames the scene on a model whose loadResources() is still in flight (premature/wrong framing on an unreadable bounding box), and reloading the same model URL now destroys the prior FilamentAsset instead of orphaning it on the GPU — mirroring the EnvironmentResourceTracker leak-free-swap pattern from the IBL/skybox fix (#1496).
  • assets/catalog.json synced with bundled demo assets (#1603). Two assets that ship in samples/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": the threejs_soldier.glb animated character (used by OrbitalARDemo, AnimationDemo, MultiModelDemo, the AR view, android-tv-demo, and ios-demo) and the chinese_garden_2k.hdr Poly Haven environment (used by EnvironmentDemo). Both now have full registry entries with source / author / license / sourceUrl provenance and usedIn arrays, matching the existing entry schema.
  • Device-QA AR leg no longer fails with a shell syntax error on its first CI run (#1608). The ar job's ARCore sideload was an inline multi-line if … fi block in the ReactiveCircus/android-emulator-runner script:. That action runs each line of script: as a separate sh -c, so the standalone if … then line aborted with Syntax 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.sh helper (ABI-aware ARCore APK resolution from the public google-ar SDK release, honest non-fatal exit when ARCore is genuinely unavailable), and the workflow now invokes it as a single self-contained line — matching the working android job.
  • EngineDestroyQueue no longer resurrects a queue after engine teardown (#1630). EngineDestroyQueue.of(engine) is backed by a WeakHashMap; a Node.destroy() arriving after Engine.safeDestroy() (a disposal order that does happen) used to getOrPut a fresh, live queue against the now-dead engine — the enqueued Texture/Stream was 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 stale of() returns an already-drained queue whose enqueueTexture/enqueueStream destroy the resource immediately instead of queueing onto the dead engine. Dropping the live entry also fixes the WeakHashMap-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.js was 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 path environments/neutral_ibl.ktx, matching the self-hosted models/ convention and working on both layouts. Additionally, .claude/scripts/validate-demo-assets.sh no longer skips the entire vendored web-demo resources/js/ tree — it now narrowly filters only the JSDoc placeholder literal model.glb, so a real broken asset literal in a future vendored js file is caught instead of silently passing.
  • Web AutoCenterGate now 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's FramingGate ceiling (#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 android and ar device-QA jobs now boot the emulator with -memory 4096, and qa-android-demos.sh retries 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.yml had never run from schedule: 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 the schedule: block re-registers the cron under the current committing account. The cron is also moved off the congested top-of-hour (0 7 → 11 7 UTC) 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.
  • ARRecorder now converts the Surface.ROTATION_* constant passed as recordingRotation into degrees (0/90/180/270) before handing it to ARCore's RecordingConfig.setRecordingRotation, which expects degrees — not the ordinal (0/1/2/3). Previously a 90° capture was recorded as 1°, leaving AR datasets stored sideways. New public ARRecorder.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.sh now splits the verdict by leg weight — only a non-passing required leg (e.g. web) blocks the gate (exit 1, releaseGate.verdict=blocked), while a failed or honest skipped advisory leg (android/ar, e.g. the #1645 ar-record-playback skip on the CI emulator) surfaces as a warn and exits 0. release-checklist.sh section 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 Filament Texture, and ViewNode.destroy() no longer risks a native SIGABRT (Invalid texture still bound to MaterialInstance) from freeing its texture/stream too eagerly — both now enqueue their GPU resources on a per-Engine EngineDestroyQueue that destroys them a few rendered frames later, on the main thread, after Filament has reclaimed the bound MaterialInstance. High-churn UIs (feeds, infinite scrollers, particle emitters) that create many short-lived ImageNodes per Engine lifetime 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 AAB step 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.yml previously 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 new ar job 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 runs device-qa.sh --platform=ar --ci, uploading the ar-qa-summary.json / device-qa-report.json artifact like the other legs.
  • AR replay device-QA harness (ARReplayHarnessTest + ar-replay-qa.sh) no longer reports a misleading pass when the recorded ARCore session was never actually replayed. ARCore dataset playback needs camera-stream support the x86 software-GPU CI emulator does not provide, so ar-record-playback advancing replayedFrames: 0 is now graded skipped (with the reason surfaced) rather than green alive. ar-qa-summary.json gains skipped / failed counts and a per-demo reason; ar-replay-qa.sh exits 3 and the device-QA AR leg records skipped — skips never count as passes (#1645).
  • Device-QA CI: prebuild the android-demo APK in a separate cached build-android-apk job and install the artifact in the emulator legs (no cold build on the 2-core emulator runner); the release gate now grades continue-on-error legs — 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 on github.run_id, so a subsequent push to main can no longer cancel an in-progress release-gate Device QA run. Push-triggered runs still share a push group and auto-cancel stale runs.
  • iOS device-QA now screen-records each run (#1673). ios-device-qa.sh previously captured only one screenshot per demo; it now records the whole Maestro run via xcrun 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 — recordVideo needs hardware Metal, which CI VMs may lack, so a recording failure never fails the QA run — and is stopped with SIGINT so the .mov finalises cleanly. The file lands under tools/qa-screenshots/ios/ (gitignored).
  • Web QA: pass --enable-unsafe-swiftshader to 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 stays helpers.ts:sampleCanvas (a compositor screenshot with a luminance-variance check); the video is for human review. Recordings land under samples/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 run device-qa.sh, what each platform leg covers, where reports land, and the per-release-checkpoint mandate), a CONTRIBUTING.md subsection 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.json for 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 a maestro.sh auto-install helper; qa-android-demos.sh is now a thin maestro wrapper (#1562).
  • device-qa: added an autonomous ar replay harness — ARReplayHarnessTest drives every augmented-reality demo through a recorded arcore session headless on the emulator (no physical device), asserts no crash, and emits a machine-readable ar-qa-summary.json; the ar-replay-qa.sh script 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). The Install Maestro step (and the maestro.sh auto-install helper) fetched the installer from get.maestro.dev, which does not resolve — the canonical host is get.maestro.mobile.dev. Because the install ran as curl … | 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 under set -o pipefail with an explicit test -x on 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

  • rememberPausableHeroYaw gained an opt-in idleResumeMillis parameter: 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 · worldPoint projection 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-demo app adds three tabs — Materials (lit PBR vs unlit geometry materials), Animation (auto-playing glTF clips via ModelNode.animation) and Environment (HDR image-based lighting plus the autoCenterContent toggle) — and its bottom tab bar is now horizontally scrollable so the catalog can keep growing. The flutter-demo app gains a dedicated Demos tab with four runnable per-feature scenes — Materials (GeometryNode.unlit), Model Animation (loadModel with 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. createARView was using ToneMapper.Linear, but the camera-stream shader's inverseTonemapSRGB() 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. With Linear the inverse curve was left uncancelled, flattening the live camera feed. The AR view now uses ToneMapper.Filmic and 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 ARPlacementDemo and ARInstantPlacementDemo, each AnchorNode now keeps rendering its model while the ARCore anchor is PAUSED (it holds its last known pose) instead of disappearing the moment the camera looks away from the plane — content only hides on a permanent STOPPED anchor. 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 new demos/internal/ArPlacement helper with JVM regression tests.
  • AR Face Mesh demo: the face mesh now actually tracks. Session.Feature.FRONT_CAMERA only makes the front camera eligible — the session stayed on the default BACK camera config, so AugmentedFaceMode.MESH3D produced zero trackables and no mesh ever appeared. The demo now passes sessionCameraConfig = ::frontCameraConfig so ARCore opens the selfie camera. Added a public frontCameraConfig(session) helper in arsceneview for 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_night skybox 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 a null skybox — 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 ViewNode renders 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 reconfigures Config.ImageStabilizationMode live via Session.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.materialInstance setter now drains the frame pipeline before freeing the superseded MaterialInstance — previously it was freed while the external video texture was still GPU-bound, the same Invalid texture still bound to MaterialInstance SIGABRT #1497 fixed for destroy(). MaterialLoader.destroyMaterialInstance now 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's autoCenterContent pass 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 web AutoCenterGate now 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 calls fitToModels() 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 and fitToModels() (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 CDN sceneview.js) and again by a duplicate setupTabs() in the Gradle-compiled Kotlin Main.kt, which is not referenced by the page. The dead Kotlin tab path has been removed so each .tab-btn click runs a single switchTab handler.
  • Docs: reconciled samples/README.md with the actual DemoRegistry — 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 bundled environments/*_2k.hdr files, and corrected the Flutter features_page.dart snippet to reference the real environments/studio_small.hdr asset.
  • CI Gate no longer flips red on fork PRs that are merely awaiting maintainer approval. GitHub reports such checks with conclusion action_required; the aggregator now treats action_required as 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-run ci.yml check (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 (where ci.yml is path-filtered out entirely), so light PRs are never blocked (#1543).
  • Docs version staleness fixed (#1544). CLAUDE.md's "Latest release" block claimed v4.4.0 and instructed AI sessions to treat it as the latest version — 4 minors stale (repo is 4.8.0); it is now version-agnostic and points at gradle.properties:VERSION_NAME as the single source of truth. README.md's SwiftPM install snippets (from: 4.4.0 / (SPM, from 4.4.0)) are bumped to 4.8.0, and a broken intra-repo anchor in CLAUDE.md is corrected. sync-versions.sh now also recognises the unquoted from: X.Y.Z SwiftPM prose form used in README.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 an onResetSettings callback to show a "Reset" text button that restores their defaults, and into a peekHeader string 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. FogDemo wires 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 in play-store.yml is now continue-on-error: true and swallows a 403 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 SceneView now 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 like Multi-Model Park rendered 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.sh no longer bumps the Flutter/RN plugins' consumed SceneView dependency (#1494). The io.github.sceneview:(ar)sceneview:X.Y.Z coordinate 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 the Build flutter-demo APK CI check during v4.7.0. The script now reports these consumed-dependency coordinates WARN-only (never MISMATCH) and excludes them from every --fix sweep, while the plugins' own package versions still bump correctly.
  • Web SceneView no longer leaks IBL + skybox GPU resources (#1496). sceneview-web's SceneView.loadEnvironment created a Filament IndirectLight and Skybox but never tracked the handles — destroy() left both resources allocated on the GPU, and a 2nd loadEnvironment / loadDefaultEnvironment call overwrote the scene's environment while orphaning the previous handle. The handles are now tracked by an EnvironmentResourceTracker, the previous IBL/skybox is destroyed before a replacement is bound, and destroy() detaches and destroys both.
  • CI hygiene cluster 2 (#1500). Narrowed app-store.yml's tag trigger from v* to the strict v[0-9]+.[0-9]+.[0-9]+ semver glob so pre-release or stray tags can no longer fire an App Store deploy; ci-gate.yml no longer treats a stale check conclusion as a failure (a stale run 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.yml paths now also matches build.gradle*/settings.gradle*/gradle.properties so renderer-affecting build-script changes are not skipped; and release.yml's dead cross-run dokka-api-docs artifact upload was removed (download-artifact only 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.kts depended on the year-old io.github.sceneview:sceneview:3.6.0 / arsceneview:3.6.0 — pre the v3.6 Scene-to-SceneView composable rename and missing every 4.x feature — while the published @sceneview-sdk/react-native package is versioned 4.7.0. The Maven coordinates are bumped to the last-published 4.7.0 (a consumed dependency, so it tracks the released artifact per #1494). The stale compose-bom:2024.06.00 is aligned to the repo's 2026.05.00, and the obsolete composeOptions { 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.md pinned Filament v1.70.1 throughout (clone/download archives and the filament-android:1.70.1 AAR), but the repo runtime is 1.71.0 and the committed .filamat blobs are v71 — all six references now read 1.71.0. CLAUDE.md's pre-push unit-test command mixed :sceneview:test with :arsceneview:testDebugUnitTest; both modules now use testDebugUnitTest, consistent with CI. The fully-completed docs/v3.6.0-roadmap.md (all 14 issues done, predating the 4.x line) is moved to docs/archive/v3.6.0-roadmap.md.
  • web-demo: Main.kt's stale tab switcher fixed (#1503). The Kotlin/JS switchTab() toggled panel-viewer/panel-geometry, but index.html ships panel-models, panel-geometry, panel-physics, panel-settings — a drift that left 3 of 4 panels unreachable from that entry point, with currentTab also defaulting to the non-existent "viewer". switchTab() now toggles all four shipped panels and currentTab defaults to "models". The shipped index.html inline-JS demo (the actual runtime path) already wires all four tabs plus the Double Pendulum physics and Settings panels correctly. The stale samples/web-demo/README.md and the Playwright suite were also refreshed to match the shipped 4-tab UI, and a new render.spec.ts test clicks every .tab-btn and asserts the matching panel-* becomes active.
  • android-demo: honest demo-status badges, correct AR icon, and a fixed Blender recipe path (#1504). The DemoStatus enum and StatusChip UI were fully built, but all 41 ALL_DEMOS entries used the default Working, 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 marked KnownIssue, so they surface a "Preview" chip instead of lying as all-green. ar-instant-placement no longer uses the HourglassEmpty ('coming soon') glyph despite routing to a real working demo — it now uses the placement-themed Bolt icon. Finally, samples/recipes/blender-to-sceneview.md no longer points at the non-existent concrete path samples/android-demo/src/main/assets/models/car.glb; it now shows an illustrative relative path with a note clarifying that car.glb is 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 external Texture/Stream immediately after destroying the MaterialInstance that referenced them — the exact ordering that triggers Filament's Invalid texture still bound to MaterialInstance abort, since MaterialInstance reclamation is coupled to the render loop rather than to the destroy() 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 sibling ImageNode. Adds VideoNodeTest pinning 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 SceneView scene 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 in ParticleBackground.kt.

Changed

  • Daily maintenance digest in CI (#1303). maintenance.yml now runs a report-only mirror of the /maintain skill — a new .claude/scripts/maintenance-report.sh produces 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.yml and quality-gate.yml are merged into a single ci.yml with ONE changes path-detection job gating every downstream job (build, lint, unit-test, web-desktop, flutter-demo, compile-kmp, repo-hygiene, quality-gate). Eliminates the duplicate dorny/paths-filter run and two redundant checkout + JDK + Gradle-cache-restore chains per PR. The CI Gate aggregator is unchanged — it polls the Checks API and treats skipped as passing. Closes #1370.
  • Dropped the dead desktop-demo compile step from ci.yml (#1396). The web-desktop job ran :samples:desktop-demo:compileKotlinDesktop with continue-on-error: true permanently, so it could never fail the build — it only burned runner minutes. Since samples/desktop-demo is a deliberate Compose Canvas wireframe placeholder (it does not use SceneView or Filament), the step was removed along with the now-redundant samples/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_compression geometry plus EXT_texture_webp textures (21 MB → 9.5 MB), both decoded natively by Filament's bundled gltfio. android-demo build.gradle also drops duplicate transitive licence/metadata files via packaging.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 from DESIGN.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 DemoSettingsLayer bottom sheet treated its initial SheetValue.Hidden state as a dismissal, slamming the panel shut before it could animate open — making the Settings controls dead in every demo. Hidden is 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; autoCenterContent centred the union of all three, shifting the helmet far off the hero camera's fixed orbit pivot. The demo now passes autoCenterContent = false so 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 the file:// cache URI was handed to AssetManager.open, threw FileNotFoundException, and every model instance stayed null. The demo now loads the local file via ModelLoader.loadModelInstance, which understands file:// URIs, so the scene reaches a rendered state.
  • Streamed demos no longer hang forever on their loading spinner (#1423). SketchfabAssetResolver staged 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 both prefetchAll and its per-slug produceState at 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 whose glTF magic header is missing so an already-poisoned cache self-heals.
  • Explore tab no longer crashes and loads thumbnails reliably (#1424). AsyncNetworkImage decoded Sketchfab thumbnails at full resolution into ARGB_8888 bitmaps; ~30 oversized images at once exhausted the heap and the tab crashed with OutOfMemoryError — which the runCatching fetch path never caught because an Error is not an Exception. Decoding is now downsampled via a two-pass BitmapFactory inSampleSize, the fetch path catches Throwable and 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 LargeTopAppBar no longer double-counts the status-bar inset already applied by the root Scaffold. 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 (DefaultCameraNode Z 2.0 → 2.75, Y 0.3 → 0.4) so origin-placed models are no longer framed too tight. Orbit/pan sensitivity is reduced (orbitSpeed 0.005 → 0.003) so finger drag tracks the model more calmly, and pinch-zoom is made more responsive (DEFAULT_PINCH_ZOOM_SPEED 1/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 flightStartPosition to 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-ran SceneScope.ModelNode's declared-transform SideEffect and reverted every gesture-driven rotation before it was visible. The poll is now isolated in its own LiveTransformOverlay composable 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 autoCenterContent at its true default. 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. Disabled autoCenterContent for 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 that SphereNode re-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 SphereNode composable no longer re-pushes position/rotation/scale to the node on every recomposition, which was clobbering the per-frame position written by PhysicsBody (the same fix already applied to the bare Node composable). 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 Duck model no longer 404s (#1487). Added the self-hosted Duck.glb asset so the playground's Duck model option and the Spring Physics example load correctly instead of issuing a 404 for the missing file.

Added

  • Flutter bridge: addGeometry / addLight are now rendered natively on Android (#909). These two SceneViewController methods previously returned result.success(null) without drawing anything — the Flutter demo's feature badges did not reflect that. The Android SceneViewPlugin now appends to reactive geometryNodes / lightNodes Compose state lists, so cube/box, sphere, cylinder and plane primitives and directional/point/spot lights render in both SceneView and ARSceneView, 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/common shared helpers (#972). The LifecycleAwareLaunchedEffect (#936) and rememberMaterialInstance / rememberUnlitMaterialInstance (#937) helpers shipped with no tests. New JVM/Robolectric suites pin their contracts: LifecycleAwareLaunchedEffectTest drives a real TestLifecycleOwner to assert the body cancels on onStop and re-runs from the top on onStart, and RememberMaterialInstanceTest fails if a future edit puts metallic/roughness/reflectance back into the remember(...) key (the 60 Hz MaterialInstance churn caught by the #937 review). :samples:common:testDebugUnitTest is 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 async readPixels callback 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 .aab files) instead of aapt2, which can only read APKs and was mis-reporting bundles as corrupt. The validation step is also marked continue-on-error so 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 and docs.yml's site deploy both push to the external sceneview.github.io repo; on a release tag they ran concurrently and the second push failed non-fast-forward. A shared cross-workflow concurrency group 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.sh now resolves aapt2 from $ANDROID_SDK_ROOT/build-tools/<newest>/ instead of relying on PATH (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.sh deletes merged local and remote claude/* branches (single git push --delete, no bot-burst) and prunes stale .claude/worktrees/* directories, with current-branch / unmerged / open-PR safety guards and a --dry-run default. A daily branch-cleanup job in maintenance.yml prunes merged remote branches automatically.
  • AR demo screenshot regression pipeline (#1050). New ARPlaybackScreenshotTest replays the bundled ARCore recording through ARRecordPlaybackDemo and 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 per onSessionUpdated) rather than wall-clock sleeps, so they land on the same frame on every machine regardless of emulator load. Wired into render-tests.yml on a pinned emulator profile and documented in samples/android-demo/AR_TESTING.md.
  • Flutter & React Native demos: Double Pendulum physics demo (#1332) — a new "Physics" tab in samples/flutter-demo and samples/react-native-demo runs 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 shared sceneview-core DoublePendulum simulation rendered via a Flutter CustomPainter / React Native views. Closes #1332.

Changed

  • play-store.yml now validates the release AAB manifest before upload (#1301). A new gate runs after the bundleRelease build and fails the job fast if the artifact's package, versionName, or versionCode don'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 + an android_cli_describe helper (wrapping aapt2, since the android CLI's describe subcommand introspects projects, not built artifacts). Closes #1301.
  • cross-platform-check.sh can cross-check the demo APK manifest (#1302). A new opt-in --with-apk flag builds (or reuses) the android-demo debug APK and inspects its manifest via the aapt2-backed android_cli_describe helper to verify the exposed entry points match expectations — the io.github.sceneview.demo package id, a launchable MainActivity, and the sceneview:// deep-link scheme — then cross-checks the Android DemoRegistry demo count against the iOS SamplesTab inventory 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.yml now publishes the complete built site (marketing + MkDocs + web-demo + Dokka API) to the canonical apex repo sceneview/sceneview.github.io, and the redundant deploy-website.yml (which pushed only website-static/ to a competing URL) is removed. Fixed the iOS SPM cache keys in ios.yml and app-store.yml to hash the checked-in samples/ios-demo/Package.resolved instead 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 the CI Gate job timeout to 35 min so the poll loop's own diagnostic surfaces before a hard runner kill. Linked the permanently continue-on-error desktop-demo compile 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 jsTest coverage for the sceneview-web core logic classes (#1394). New unit tests pin the OrbitCameraController orbit/zoom/pan math (spherical-to-Cartesian eye conversion, phi/distance clamping, auto-rotate, damping), the GeometryGLBBuilder GLB container output (header, chunk alignment, accessors, KHR_materials_unlit extension, node transforms), and the auto-center one-shot gate. The didCenterContent flag was extracted into a testable AutoCenterGate so the #1357 regression — a 2nd loadModel must re-run content centering — is now directly covered. Closes #1394.
  • API docs: KDoc for the sceneview module geometry, texture and material helpers (#965). Added accurate KDoc to previously undocumented public declarations in the sceneview Android module: the six geometry builders (Cube, Cone, Cylinder, Sphere, Capsule, Torus), the texture helpers (ImageTexture, VideoTexture, TextureSampler2D/TextureSamplerExternal, Texture.use/setBitmap), RenderableManager extensions, NodeAnimator, and the ubershader MaterialInstance parameter setters. Documentation only — no behavior change. arsceneview is tracked separately.

Fixed

  • Samples cleanup (#1361). Rewrote samples/MULTIPLATFORM.md so the architecture diagram and recipe list match the real tree (*-demo/ folders, the 11 actual recipes/*.md files). 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 dead else -> PlaceholderDemo router fallback in MainActivity.kt into a debug-only drift guard that crashes loudly if a new ALL_DEMOS entry is added without a matching route, while still degrading gracefully in release builds.
  • iOS demo cleanup + Android parity (#1373). Renamed the Scenes tab to Samples, aligned the Samples category taxonomy to Android, removed the Auto Rotate / AR Record & Playback duplicate entries and the dead Explore buttons, fixed the Settings pill 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-screen SceneView (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 .sheet is reserved for the lightweight ComingSoonScreen, which has no 3D surface.
  • sceneview-web jsTest suite can now run in CI (#1401). The Karma / ChromeHeadless test bundle threw Uncaught ReferenceError: Filament is not defined at load time — the @JsModule("filament") external is mapped to a global that no script injected into the headless page, which failed the entire jsTest run before any test executed. A karma.config.d/filament-stub.js config now serves a no-op Filament global 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.centeringOffset returned a signed -0.0 for an already-centred axis (now normalised to 0.0), and SceneViewVersionTest's pinned literal was a version behind.
  • sceneview.github.io/docs/ and /api/ no longer serve the landing page (#925). The docs.yml workflow 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-facing sceneview/sceneview.github.io repo. Previously docs.yml deployed to a different Pages host while deploy-website.yml published only website-static/ (which carried a docs/ meta-refresh redirect stub) to sceneview.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 redundant deploy-website.yml workflow and the obsolete redirect stub have been removed; docs.yml is now the single authoritative site deploy.
  • MaterialLoader / EnvironmentLoader no longer leak their CoroutineScope across composition disposal (#933). Each loader's destroy() cancels its CoroutineScope, and rememberMaterialLoader / rememberEnvironmentLoader wire destroy() to DisposableEffect.onDispose, so an in-flight loadMaterialAsync / loadHDREnvironment job can no longer outlive the owning composition and touch a destroyed Engine. EnvironmentLoader.clear() no longer cancels the scope — it now releases environments only, so calling clear() 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 new LifecyclePausingLaunchedEffect / LifecyclePauseGate pair in samples/common provides the reusable state-preserving primitive for any while(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.sh fails when a PR drops coverage more than the configurable threshold_pp (0.5pp default). Wired into the unit-test CI 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 #973 follow-up.

Docs

  • /issue-batch skill 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-forget gh 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 editing CHANGELOG.md's ## Unreleased anchor, so parallel PRs no longer conflict on the changelog. .claude/scripts/collate-changelog.sh X.Y.Z collates the fragments into a new ## vX.Y.Z section at release time. Closes #1337.

Added — iOS

  • visionOS immersive-space skybox (#1235). A SceneView pulled into a fully immersive ImmersiveSpace now renders its showSkybox HDR environment as a background. The new .immersiveSpace() modifier opts in; the HDR is mapped onto an inverted sphere parented under a WorldComponent root, since RealityViewContent.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 SceneView default 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-core collision API (#965). Documented previously-undocumented public declarations in the collision module (Box, Sphere, Plane, Ray, RayHit, Vector3, Quaternion, Capsule, MeshCollider, ChangeId, TransformProvider) plus the Easing curve set and the cross-platform logWarning logger.

Added — Docs

  • New recipe: iOS visual-polish pipeline (#1218). docs/recipes/ios-visual-polish.md documents how to combine the v4.4.0 HDR-skybox background render, PBR default material, and Apple AR Quick Look hand-off — decoded from @radcli14's twolinks. The iOS demo's DynamicSkyDemo deep-night bucket now uses the dramatic SceneEnvironment.nightSky HDR.

Added — Samples

  • Web demo: Double Pendulum physics demo (#1221) — a new "Physics" tab in samples/web-demo runs the chaotic two-link pendulum with link-length / gravity sliders + reset; the integrator mirrors the shared sceneview-core DoublePendulum simulation that drives the Android and iOS demos. Reachable via the #double-pendulum deep link.

Fixed — iOS true look-around camera (#1236)

  • iOS .firstPerson now rotates the perspective camera in place instead of orbiting the scene root, so switching orbit ↔ firstPerson no longer teleports the camera; new recentersTargetOnOrbit(_:) 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 = true default (#1086), the no-double-close hoisted cubemap upload callback (#1091), and the 7 @Volatile LightEstimator toggles (#1095).

Added — CI

  • Nightly full-CI safety-net workflow (#1324). nightly-ci.yml runs the full heavy validation surface (compile + builds + unit tests + render tests + quality gate) against main HEAD once a night, reusing the existing workflows via workflow_call, so a path-gated-out regression still surfaces within 24h. Not a PR gate.

Fixed

  • iOS FogNode.heightFalloff / heightBased are 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 / ShapeNode unlit: true now returns a flat UnlitMaterial (#1359). The unlit: parameter previously produced a lit SimpleMaterial that still reacted to scene lighting, contradicting the KDoc contract — it now yields an UnlitMaterial, matching ImageNode and GeometryMaterial.unlit.

  • SceneView main/fill light mutations are now reactive (#1306). rememberMainLightNode / rememberFillLightNode re-run their apply block on every recomposition (via SideEffect), so Compose-state-driven light properties (intensity, direction, color) propagate to the Filament scene without re-keying the remember — matching the iOS RealityView.update: reactive light contract.

Changed — Samples

  • Migrated the remaining samples/android-demo demos to the rememberMaterialInstance / rememberUnlitMaterialInstance helpers (#971). CollisionDemo, LightingDemo, VideoDemo, ARStreetscapeDemo, GeometryDemo, DebugOverlayDemo, PhysicsDemo and the shared Axes3DNode no longer allocate MaterialInstance handles via raw materialLoader.create* without disposal — the helpers own the lifecycle. Behaviour-preserving.

Fixed — Web

  • sceneview-web stale SCENEVIEW_VERSION + auto-center not resetting on a 2nd model load (#1357). The @JsExport-reachable SCENEVIEW_VERSION was two majors stale (3.6.0); it now reports 4.4.0 and is pinned by a jsTest. SceneView.loadModel now resets didCenterContent so a model loaded after the first one was auto-centered gets re-centered, mirroring Android's SceneAutoCenterState.reset().

Fixed — CI security

  • discord-notify.yml no longer interpolates user-controlled github.event.* fields into inline shell scripts (#1313). Issue title/author and release name/tag now pass through env: 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). DemoScaffold now shows a surface-tinted loading scrim over the 3D viewport until the SceneView presents its first Filament frame, wired via the new rememberFirstFrameState() helper.

Fixed — ViewNode rendering

  • ViewNode no longer renders as a permanent black rectangle after a background → foreground cycle (#984). ViewNode.WindowManager now 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

  • SecondaryCameraDemo camera-angle controls are now TalkBack-friendly (#1256). The section label is exposed as a heading and the selected FilterChip carries an explicit "Selected camera angle" state description.

Changed — CI

  • ci.yml's "Build & lint" job split into parallel build / lint / unit-test jobs, and quality-gate.yml switched to a shallow checkout (#1311). The three Android jobs share the Gradle cache and run concurrently (~3 min wall-clock saved); the quality gate drops fetch-depth: 0 since its scripts only diff against the working tree / HEAD.

  • CI/publish workflows' inline pip install deps moved into per-workflow .github/workflows/requirements/*.txt files so Dependabot's pip ecosystem tracks and bumps them (#1286). Same packages, same pinned versions installed — Dependabot just cannot see inline pip install x==y lines in workflow YAML, so the pins would have gone stale silently.

  • render-tests.yml reverted 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 on stop() (#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 on stop() (and on a failed start()).

Fixed — Docs

  • Reconciled stale version refs that survived the v4.4.0 release and hardened sync-versions.sh to catch them (#1356). README badges/CDN/SPM snippets, ai-context.md, android-xr-emulator.md, website-static/js/package.json, the Kotlin toolchain version in llms.txt, and the root↔docs llms.txt ARRecorder.saveToPhotoLibrary paragraph now all read 4.4.0 / 2.3.21; sync-versions.sh gained 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-frame Estimation, color-correction, cubemap face-offset, RGB-triplet, and 27-element irradiance buffers are now hoisted, reused fields; an ENVIRONMENTAL_HDR capability probe (Session.isSupported, cached per mode) early-returns instead of feeding silently-degraded HDR estimates to Filament; the legacy Sceneform 1.8 pixel-intensity gain is now a named constant. Public behaviour is unchanged. Closes #1105.

Fixed — AR recording resolution (#1065)

  • ARRecorder no 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's sessionCameraConfig now defaults to the new highestResolutionCameraConfig selector (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 optional recordingResolution: Size? parameter to request a specific resolution explicitly. Closes #1065.

Added — Agent skills

  • Published sceneview-ios and sceneview-web agent skills, and documented the Android sceneview skill's android-cli registry submission (#1080, #1081, #1082). New agents/sceneview-ios/ (SwiftUI + RealityKit) and agents/sceneview-web/ (Filament.js + WebXR) skills with install scripts; check-sceneview-skill.sh now validates all three; submission packet and steps tracked in agents/REGISTRY.md.

Added — Web auto-center content (#1052)

  • sceneview-web now auto-centres loaded content on the orbit-camera target — library-level port of iOS autoCenterContent (#1026). Enabled by default; opt out with autoCenterContent(false) in the DSL builder or setAutoCenterContent(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 test build, CameraControls rename, deep-link, Plane, first-paint audit. Local cd SceneViewSwift && swift test runs the full 603-test suite again (#1054): the 36 SceneViewSwiftTests classes are now @MainActor-annotated so their RealityKit @MainActor node factories no longer raise ~500 #ActorIsolatedCall compile errors under the Xcode 26 toolchain. OrbitCameraDemo.swift renamed to CameraControlsDemo.swift (#1055) so the file matches its struct CameraControlsDemo (project.pbxproj synced). The sceneview://demo/multi-model deep-link no longer hangs on an eternal black "Loading park scene…" scrim (#1056) — MultiModelDemo loads its four park models concurrently and reveals each progressively, so one slow heavy USDZ no longer blocks the rest. GeometryDemo's Plane is 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's PHAsset.localIdentifier (String?) (#1057). Callers get a handle to the saved recording — resolve it later via PHAsset.fetchAssets(withLocalIdentifiers:options:) or deep-link to it — closing the cross-platform parity gap with Android's ARRecorder.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, and ARRecorder (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)

  • DefaultCameraNode now 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's look(at: .zero, from: [0, 0.3, 2]) default, so the same scene frames identically on Android and iOS. The position is exposed as DefaultCameraNode.DEFAULT_Y / DEFAULT_Z and pinned in SceneFactoriesTest. This is a visual behavior change — demos that supply their own cameraNode are unaffected. The remaining umbrella items (IBL intensity 30k→10k, PostProcessingDemo SSAO toggle, light/material leaks, render-quality clobber, indirectLightApply threading, PhysicsDemo light retune) already shipped in #1079, #1088, #1089, #1092 and #1147.

Changed — CI

  • release.yml now deploys the generated Dokka API docs to sceneview.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 Store is now tag-only — fixes Apple upload-limit failures on every main merge (#1318).
  • Lighter main CI: Deploy Demo to Play Store is now tag-only (#1321), Deploy website + docs is path-gated to docs/website/markdown changes, and Render Tests / Build sample APKs path filters were tightened to skip doc-only, CI-only and mcp/-only merges (#1311).

Changed — MCP

  • sceneview-mcp adds search_android_docs / fetch_android_doc tools wrapping Google's android docs CLI, and makes the package-files regression test deterministic by routing the generate-llms-txt banner to stderr (#1083, #1113).

Added — Double Pendulum demo (shared KMP physics)

  • New DoublePendulum simulation in sceneview-core (Addresses #1221) — a pure-Kotlin, platform-independent two-link (double) pendulum in io.github.sceneview.physics. DoublePendulumState holds two DoublePendulumLinks (length, mass, angle, angular velocity), a fixed pivot, gravity and damping; DoublePendulum.step(state, dt) advances it with a symplectic (semi-implicit) Euler integrator, sub-stepped at 1/240 s so the chaotic motion stays numerically stable at any frame rate. Exposes joint / tip joint positions and a totalEnergy accessor; covered by 12 commonTest cases including energy-conservation (bounded energy band with damping = 0) and rest-state stability. Adapted from @radcli14's MIT-licensed twolinks.
  • 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 the sceneview-core simulation into a SceneView { } 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 local DoublePendulum.swift, kept numerically identical to the Kotlin source, since iOS cannot consume the KMP module directly until the sceneview-core XCFramework lands (#1033). Reachable via sceneview://demo/double-pendulum on 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 PhysicallyBasedMaterial instead of SimpleMaterial (#1223) — GeometryNode.cube/sphere/cylinder/cone/plane/torus/capsule(color:), ShapeNode(points:color:), LineNode(from:to:color:), and lit ImageNodes previously created RealityKit SimpleMaterial, which is effectively unlit-flat: it does not react to image-based lighting (the HDR environment SceneView wires 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 new unlit: Bool = false parameter, e.g. GeometryNode.cube(color: .red, unlit: true). Also fixes a latent bug where GeometryMaterial.pbr(...) was internally backed by SimpleMaterial (so metallic/roughness never reached the PBR pipeline) — it now correctly builds a PhysicallyBasedMaterial. Closes #1223.

Added — night_sky environment preset (#1219)

  • New night_sky HDR environment bundled across iOS and Android demos — a dramatic Milky Way starfield over a dark landscape (Poly Haven dikhololo_night by Greg Zaal, CC0 1.0 public domain). iOS exposes it as SceneEnvironment.nightSky (added to allPresets, so it auto-surfaces in the demo's environment picker); Android adds a "Night Sky" chip to EnvironmentDemo. 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

  • AppStoreUpdater snooze 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. The SceneViewDemoTests unit-test target is now wired into SceneViewDemo.xcodeproj so the AppStoreUpdater tests run in CI (#1227).

Fixed — iOS rendering

  • SceneEnvironment.showSkybox = true now actually paints the HDR as the scene background (PR #1215, ported from @radcli14's sceneview-swift#1) — SceneView previously loaded the HDR and applied it as IBL via ImageBasedLightComponent, but never assigned it to RealityViewContent.environment, so the scene rendered against the default neutral void regardless of which environment preset was selected. The new path caches the loaded EnvironmentResource in a @State and applies it via content.environment = .skybox(resource) in the RealityView.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 + scaling entities.root while 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 via CameraControls.cameraPosition() + look(at: target, ...), so the skybox correctly wraps. The scene root stays at identity for both modes; camera.orbitRadius is now the literal camera-to-target distance. firstPerson retains 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 firstPerson pinch into orbit / pan — switching to firstPerson, pinching FOV down to e.g. 30°, then back to orbit kept the 30° pinched FOV on the perspective camera (visible as a stuck zoom-in). applyCamera() now writes the baseline 60° FOV in orbit / pan regardless of camera.fov, and only mirrors camera.fov in firstPerson. On firstPerson exit, camera.fov itself is reset to 60 so the next entry starts fresh.

Changed — CameraControls defaults (BREAKING for direct constructors)

  • CameraControls.orbitRadius public default changed from 5.0 to 2.0 — 5.0 was unreachable through any public modifier (cameraControls(_:) only accepts a CameraControlMode), and the internal @State already overrode to 2.0 so existing demos retain their on-screen framing. Callers constructing CameraControls() directly will see the same 2.0 default 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.minRadius public default changed from 0.5 to 1.0 — under the new true-camera path, 0.5 puts the perspective camera inside any model with extent >1m (which most demo content has). The old 0.5 was safe under the fake-orbit scale = 5.0 / radius scene-scale hack but clips into geometry now. Override for smaller content.

Changed — SPM URL retirement

  • sceneview-swift SPM 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.org sameAs graph, and the bundled MCP llms-txt.ts now points at https://github.com/sceneview/sceneview(.git). The sceneview/sceneview-swift mirror has been archived read-only; its frozen v4.0.0 tag 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 root Package.swift (added in PR #920) declares the SceneViewSwift product.

Changed — CI / scripts hardening

  • CI/scripts hardening batch (#1226, #1230, #1237, #1114) — new check-sceneview-swift-urls.sh PR gate forbids reintroducing the archived sceneview-swift mirror URL; sync-versions.sh now uses a portable _sed_inplace helper (BSD/GNU); publish-time pip install calls in play-store.yml / app-store.yml are 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 — InAppUpdateManager listener stacking (#1244): the early-return guard added in #1216 only covered DOWNLOADING / READY_TO_INSTALL. A fast double-resume landing two checkForUpdate() calls while the first was still in the CHECKING / AVAILABLE window would issue a parallel appUpdateInfo request and a duplicate startUpdateFlow, double-prompting the user. A private inFlight flag 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 in InAppUpdateManagerTest.
  • 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 once loading-overlay.hidden is 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 a HEADER_HEIGHT constant so it sits cleanly below the header (react-native-safe-area-context is not a dependency of this demo, so a measured constant is the minimal fix).
  • Flutter — deprecated withOpacity() (#1247): Color.withOpacity(0.8) in app.dart replaced with the Flutter 3.27+ withValues(alpha: 0.8) API. flutter analyze lib/app.dart now reports no issues.
  • iOS / macOS — AppStoreUpdater.openAppStore() no-op on macOS (#1248): the open call 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 via NSWorkspace.shared.open with the macappstore:// scheme.
  • iOS — update throttle hard-locked on clock rollback (#1249): if the system clock rolled backward, now − lastCheckAt went negative — always below the throttle — so updates never re-checked. shouldCheck() now clamps lastCheckAt to min(last, now) on read, repairing a future-stamped timestamp. Covered by a new test in AppStoreUpdaterTests.

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 its AnimatedVisibility wrapper. AnimatedVisibility introduces its own layout node for the enter/exit transition and the align modifier on that wrapper is not reliably honoured by the animated child. The alignment is now carried by a static Box that is a direct child of the outer Box, with AnimatedVisibility inside 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 SceneView content block had no LightNode and the spheres were spawned with no materialInstance, 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 via remember, 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 the SceneView.modelViewer(...) success path; an engine-init rejection left a deferred version stuck in pendingUpdateVersion forever. The .catch() path now flushes too — the snackbar is pure DOM and flushPendingUpdateSnackbar() nulls pendingUpdateVersion, so it can never double-show. Closes #1279.
  • DemoInteractionTest — FR-locale gap in control helpers (#1282) — secondaryCamera_pipAngles now resolves the PiP-angle chip labels from R.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.sh no longer risks destroying a parallel session's uncommitted work (#1278) — the script now skips any worktree with a non-empty git status --porcelain, uses plain git worktree remove (fail-safe) instead of --force, accepts repeatable --keep paths, and reclaims squash-merged worktrees via a gh-backed merged-PR check that degrades gracefully offline. Closes #1278.

Docs

  • New recipe: Blender → SceneView asset pipeline (#1222) — samples/recipes/blender-to-sceneview.md and docs/docs/recipes/blender-pipeline.md walk contributors through authoring a custom 3D model in Blender and shipping it in a SceneView app: .glb is 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's blender-to-realitykit tutorial (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 iOS autoCenterContent feature (#1026 / PR #1038). DSL content nodes 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-node ModelNode(centerOrigin = …). Lights / camera are SceneView parameters (never DSL children) so they stay put. Opt out with autoCenterContent = false for intentional off-centre composition.

Follow-ups (filed against the master polish-pipeline reference #1218)

  • #1219 — Bundle ambientCG NightSkyHDRI008 (CC0) as night_sky env 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 SimpleMaterial to PhysicallyBasedMaterial

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 + 4 animation-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-screen cyberpunk_hovercar hero with a "Surprise me" extended button that searches the Sketchfab catalogue server-side, downloads the pick via SketchfabService.downloadModel, and replaces the hero in place. Button hidden when SketchfabConfig.apiKey is nil (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 streamed park-category slugs. Per-model visibility chips + spin toggle wired through AnchorEntity + SceneView.autoRotate(speed:).
  • ARPlacementDemo.swift — tap-to-place AR demo with a 5-bundle cycle and the 6 streamed ar_placement-category chips. Reuses SceneViewSwift's ARSceneView(onTapOnPlane:) raycast hook.
  • ARInstantPlacementDemo.swift — instant-placement variant with a toggle. ARKit doesn't expose Config.InstantPlacementMode.LOCAL_Y_UP directly; the iOS port approximates via .estimatedPlane raycasts 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 streamed physics-category crash-test meshes (vase / stool / barrel / amphora). Drop count capped at 20 active bodies because RealityKit's PhysicsBodyComponent slows past that.

Changed — iOS plumbing

  • AutoRotateDemo.swift struct renamed from AnimationDemo → AutoRotateDemo to free up the canonical name. The "Auto Rotate" Samples-tab entry continues to point at this struct; the new "Animation" entry routes to AnimationDemo.swift.
  • SamplesTab.swift — added Model Viewer / Multi-Model Park entries under Geometry, and promoted "AR Plane Placement" + "AR Instant Placement" from Coming soon to fully wired demos.
  • DemoDeepLinkRegistry.swift — model-viewer and multi-model ids no longer route to the SceneGalleryDemo placeholder; both land on the dedicated demos. ar-placement newly routed to ARPlacementDemo.

Fixed — iOS Stage 2 demo polish (#1280)

  • ARPlacementDemo / ARInstantPlacementDemo gain 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 .estimatedPlane raycast (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 in MultiModelDemo was simplified.

Fixed — pre-existing AppStoreUpdater build break

  • AppStoreUpdater.swift:66 default parameter currentVersion: @escaping () -> String? = AppStoreUpdater.bundleVersion was losing the @MainActor global-actor isolation under Swift 6 strict concurrency, breaking the iOS demo build on main. Added @MainActor on both the parameter type and the stored field so the implicit @MainActor from 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.md adds a new "Troubleshooting — ERROR_NOT_AUTHORIZED persists 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> with pc-api-4638313286439917620-648 for 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.kt host/resolve error messages broadened. The in-app banner for ERROR_NOT_AUTHORIZED no 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.sh reminder 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.kt was renamed "Camera Presets" in commit dfc241d5 and 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 the PictureInPicture icon + "Picture-in-picture camera view" subtitle. Two SceneViews now share the same engine/loaders and render the helmet simultaneously: the main view keeps the default orbital camera (user-interactive), and a small SurfaceType.TextureSurface PiP overlay top-start binds a dedicated rememberCameraNode driven by the Top / Side / Front / Corner chips via LaunchedEffect(cameraPreset). Title restored to "Secondary Camera (PiP)" so DemoInteractionTest.secondaryCamera_pipAngles finds it again. Two correctness invariants doc'd inline: each SceneView gets its OWN rememberModelInstance (sharing one across views would double-destroy modelInstance.root on dispose — SIGABRT — and reparent child light/camera nodes off whichever ModelNode built last) and the PiP receives cameraManipulator = null (without it the SceneView frame loop writes cameraNode.transform = manipulator.getTransform() every frame, clobbering the LaunchedEffect preset writes). iOS gets the matching "Coming soon" placeholder under .advanced (SamplesTab.swift, pip.fill SF Symbol, v4.4) — SceneView Swift currently uses an internal @State private var camera = CameraControls(mode:) with no per-instance cameraNode binding, 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)

  • InAppUpdateManagerTest now covers the intermediate DOWNLOADING state + non-zero downloadProgress (#1229); UpdateBanner auto-focuses its "Restart" CTA on D-pad hosts (#1228) — the TV demo passes an optional restartFocusRequester so the Restart button grabs focus when an update reaches READY_TO_INSTALL; phone hosts leave it null and 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.swift now wires .refreshable { await loadSketchfabFeeds(force: true) } on the ExploreTab ScrollView, mirroring the Android PullToRefreshBox shipped in v4.3.4 (PR #1203). New loadSketchfabFeeds(force: Bool = false) overload bypasses the "already loaded" guard when invoked from the swipe-down gesture, and conditionally gates the loader on SketchfabConfig.apiKey so builds without the key don't spinner-flash on every refresh. Items 2 (matchedGeometryEffect hero 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:77 and marketing/stackoverflow/qa-drafts.md:215. Both files live in non-canonical directories that sync-versions.sh doesn't sweep, so the drift slipped past the v4.3.4 release cut (#1153). Surfaced by .claude/scripts/impact-check.sh after PR #1203 landed.

Changed — CI workflow deduplication (~20 min saved per PR)

  • Workflows trimmed — Audit of .github/workflows/ showed assembleDebug compiling 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 — dropped compile-android, lint, compile-web-demo, build-flutter-demo (all already covered by ci.yml's build, web-desktop, flutter-demo jobs). Kept only the unique fast guards: check-deprecated-api, check-sceneview-skill, compile-kmp (KMP all-targets, beyond ci.yml's JS-only build), check-workflow-scripts, validate-demo-assets. Also mirrored the paths-ignore block from ci.yml so docs-only PRs no longer spin up the ~5 min Gradle KMP compile.
  • build-apks.yml — dropped the pull_request trigger. APKs were already built twice on every PR by ci.yml + pr-check.yml; this workflow's unique value (artifact upload, GitHub Release attachment) only matters on push / tag.
  • quality-gate.yml + .claude/scripts/quality-gate.sh — added QUALITY_GATE_SKIP_ANDROID=1 env var, set in the CI workflow so the gate no longer re-runs assembleDebug + the same Android unit tests that ci.yml's build job already executes (with JaCoCo coverage). Local invocations of quality-gate.sh still 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 the pull_request trigger. 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, with workflow_dispatch available for ad-hoc feature-branch vetting.

  • Supply-chain guard centralised — Moved gradle/actions/wrapper-validation@v6 from the (now removed) pr-check.yml:compile-android step into .github/actions/setup-gradle/action.yml so every workflow that calls ./gradlew (CI, PR Check, quality-gate, build-apks, render-tests, release, docs) inherits the validation. Catches any tampered gradle/wrapper/gradle-wrapper.jar regardless 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.kt now 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 from BottomCenter to TopCenter (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.kt retunes both cube and sphere PBR materials from roughness=0.85, reflectance=0.1 to roughness=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 ARCore ENVIRONMENTAL_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::RenderContent now defers mounting SceneView until rememberModelInstance resolves (instance != null). Before, the SceneView was always composed and an opaque-surface loading placeholder was layered on top with a centered CircularProgressIndicator. 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.xml adds the 7 keys flagged by the post-#1099/#1160 audit: credits_sheet_title / credits_sheet_subtitle / credits_sheet_footer / credits_row_open_cd for the streamed-model attribution sheet, and demo_chip_bundled / demo_chip_streamed / demo_chip_streaming for the DemoScaffold asset-source chip. The demo_ar_streetscape_* keys called out in the original issue body were already translated; this PR closes the broader audit gap discovered by comm -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.kt now 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_SPACING so 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) - 5 baked 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�linice shows U+FFFD (#1181 — PR #1202) — SketchfabService.authenticatedGet now decodes the response body as UTF-8 explicitly via response.body.source().readString(Charsets.UTF_8) instead of body.string(). OkHttp's string() honours the Content-Type charset and falls back to ISO-8859-1 when the header lacks a charset= 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 test decodes non-ascii model names without substitution exercises Polish / Czech / Greek / CJK fixtures.

  • AR Examples menu — green pills replaced with M3 Expressive grid (#1185 — PR #1202) — ArViewTab.kt's ArDemoCard now mirrors the DemoCard pattern from DemoListScreen.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.swift companion to #1151 fillLightNode port (#1155 — PR #1202) — New AR demo at samples/ios-demo/SceneViewDemo/Views/Demos/ARLightingDemo.swift showcases the .mainLight(_:) + .fillLight(_:) modifiers shipped in v4.2.0 (PR #1151). Three filter chips toggle between .systemDefault on both slots, dim-key .custom(LightNode.directional(intensity: 5_000)), and key-only (.fillLight(.disabled)) — registered under the AR section in SamplesTab.swift.

Added — Compose UX patterns in samples/android-demo

  • Pull-to-refresh on Explore Sketchfab feeds (ExploreTabScreen.kt) — PullToRefreshBox reloads 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 (refreshTick LaunchedEffect 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) — BackHandler routes the system gesture to the same exit path as the top-end Close button (detach anchors, return to the AR launcher screen). Manifest opts into android:enableOnBackInvokedCallback="true" so Android 13+ routes back via the new OnBackInvokedDispatcher (prerequisite for any future PredictiveBackHandler upgrade).
  • Shared-element hero morph between viewer stages (SketchfabModelViewerScreen.kt) — Crossfade replaced with SharedTransitionLayout + 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 uses SurfaceType.TextureSurface so the layer alpha is honoured during the morph (the default SurfaceView is 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)

  • .refreshable on Explore Sketchfab feeds (ExploreTab.swift, PR #1225) — pull-to-refresh on the iOS ScrollView mirrors the Android PullToRefreshBox in #1203. loadSketchfabFeeds(force: Bool) bypasses the "already loaded" guard when called from .refreshable so 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 explicit Stage.Preview (description / tag chips / "Open in SceneView" CTA / non-downloadable warning) matching Android's Stage.Preview PreviewContent — 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.kt no longer passes cameraExposure = -1.5f. The author had intended a "-1.5 EV bias", but Filament's single-arg CameraComponent.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 (see ArSession.kt) and the new ARDefaultCameraNode defaults (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 the cameraExposure parameter KDoc in ARScene.kt so future contributors don't repeat the misinterpretation. Pinned by ARCompletenessDefaultsTest.ARFaceDemo no longer passes a negative cameraExposure value so any grep-and-paste regression gets caught.

  • AR Instant Placement — anchors silently floating after STOPPED (#1184 — PR #1198) — samples/android-demo/.../ARInstantPlacementDemo.kt now reconciles each placed anchor's TrackingState every frame. When ARCore drops a placed InstantPlacementPoint's underlying Anchor to STOPPED (the user typically panned the camera away from where the point was approximated), we now detach the dead anchor, hide its ModelNode (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 iterates placedModels rather than trackingMethods so anchors that flip to STOPPED before their first trackingMethod ever fires still surface as Lost.

  • AR Image Stabilization (EIS) — demo auto-places helmet on first tracking frame (#1183 — PR #1191) — ARImageStabilizationDemo now auto-creates a 1 m-in-front anchor on the first stable TRACKING frame and drops the helmet there, with a one-shot autoPlaced guard 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 is frame.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) gain flingBehavior = rememberSnapFlingBehavior(state) so scroll releases always land on a card boundary, never mid-card, plus contentPadding = 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 own ScrollView/LazyHStack snapping 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_AUTHORIZED now surfaces actionable guidance (#1177) — When host() or resolve() comes back with ERROR_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.md adds 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.yml now fails fast if ARCORE_API_KEY secret is missing, if samples/android-demo/build.gradle no longer injects the arcoreApiKey manifest placeholder, or if AndroidManifest.xml drops the ${arcoreApiKey} reference. Catches the silent-regression class that ships an AAB with an unwired Cloud key.

Verified fixed (closing tracker issues)

  • #1097 spherePlaneResponse wrong contact point on negative side — fixed in CollisionResponse.kt (contactPoint = center - planeNormal * signedDist projects along the original unflipped normal). JVM regression test spherePlaneResponseContactPointLandsOnPlaneOnEitherSide pins the behaviour on both sides of the plane.

  • #1178 AR Terrain & Rooftop Anchors fail in release builds (R8 strip) — fixed in arsceneview/consumer-rules.pro via PR #1190. Consumer-side R8 now keeps com.google.android.gms.location.**, common.api.**, and tasks.** so ARCore can reflectively link Fused Location Provider when Config.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, AtomicReference baselines), #1063 (neutral IBL fallback in createAREnvironment), #1064 (environmentalHdrSpecularFilter = true default in LightEstimator.kt), #1067 (AR exposure aligned to v4.1.0 3D defaults). Config.LightEstimationMode.ENVIRONMENTAL_HDR is the default in ARScene.kt so 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 in react-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 — DemoScaffold v2 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 the ListResourcesRequestSchema + ReadResourceRequestSchema handlers.
  • mcp/src/generated/llms-txt.ts — regenerated from root llms.txt (the build pipeline embeds it via mcp/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 by mcp/scripts/generate-version.js running during npm run prepare.

Acceptance:

  • cd mcp && npm test GREEN (2562 tests, 102 files — 16 new from examples.test.ts).
  • bash .claude/scripts/sync-versions.sh GREEN (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.assetPath renamed to assetLocation so both assets/-relative paths and file:// URIs flow through the same rememberModelInstance call.
  • samples/android-demo/.../demos/ARInstantPlacementDemo.kt — same chip row, hoisted to the outer ARInstantPlacementDemo composable so it survives the key(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 — grow ar_placement from 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.

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's tags[0] is the KHR_materials_* extension name).
  • samples/android-demo/.../DemoRegistry.kt — new materials entry in the Advanced category with the Icons.Filled.Palette icon.
  • samples/android-demo/.../MainActivity.kt — routes materials to MaterialsDemo.
  • 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. Same materials category, same chip row + extension tag + author byline, SketchfabAssetResolver.shared.resolve(slug) + ModelNode.load(contentsOf:). The existing SamplesTab entry already wires up MaterialsDemo() — 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 to downloadable && 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 shared SketchfabService cache. 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 surpriseInFlight flag flips back to false so 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).

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 four gallery slugs via SketchfabAssetResolver, warms the category on first frame with prefetchAll("gallery"), orbit camera, inline CC-BY author byline.
  • samples/android-demo/.../DemoRegistry.kt — new scene-gallery entry in the 3D Basics category with the Icons.Filled.Collections icon.
  • samples/android-demo/.../MainActivity.kt — routes scene-gallery to SceneGalleryDemo.
  • 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's SketchfabSlug.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: same gallery category, same chip row + author byline, SketchfabAssetResolver.shared.resolve(slug) + ModelNode.load(contentsOf:), prefetchAll(category:) warm, error path surfaces the resolver's localizedDescription rather 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 / byCategory lookups + 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. Wraps SketchfabService with 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 GLB glTF magic and USDZ ZIP PK\x03\x04 magic in the bounds check). actor for the URLSession serialisation invariant that matches SketchfabService.
  • SketchfabAssetResolver+Tests.swift — XCTest mirror of the Android suite (no live xcodebuild test target wires it up yet; the file lives next to the existing SketchfabService+Tests.swift scaffold 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, requireValid succeeds, byUid/byCategory agree with all, all 6 Stage 2 categories represented, constructor rejects non-CC-BY / blank author / non-positive scale.
  • SketchfabAssetResolverTest.kt — 11 tests: resolve falls back without an API key, Unknown for slugs outside the registry, boundsAreSane rejects 0-byte/junk/missing files and accepts a real GLB header, pruneCache is a no-op sub-budget, FallbackUnavailable when the bundled asset is missing, prefetchAll returns 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 SketchfabSlug carries a fallbackBundledPath that 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 / URL only; consumers feed it into rememberModelInstance(modelLoader, file) / RealityKit Entity.load(...).
  • CC-BY only. Every entry's licenseUrl is https://creativecommons.org/licenses/by/4.0/. Other Creative Commons variants (NC, ND, SA) and the bespoke "Sketchfab Standard" license are rejected by SketchfabSlug.init.
  • Cache survives across demos. Resolver uses the same cacheDir/sketchfab/ directory as SketchfabService, 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.swift read the key via ProcessInfo.processInfo.environment["SKETCHFAB_API_KEY"] — that path only works under Xcode's "Run" scheme. CI env vars set on the runner don't survive xcodebuild archive into the shipped binary, so SketchfabConfig.apiKey == nil for every TestFlight + App Store build → SketchfabError.missingApiKey → ExploreTab runCatching swallow → empty / fallback results with no error banner.
  • .github/workflows/app-store.yml and ios.yml never referenced SKETCHFAB_API_KEY — confirmed by grep. The Android pipelines (play-store.yml:170, build-apks.yml:47) inject the secret correctly and Android's BuildConfig.SKETCHFAB_API_KEY bakes 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 — apiKey now resolves from Bundle.main.object(forInfoDictionaryKey: "SketchfabAPIKey") first, with a guard that rejects the unsubstituted $(SKETCHFAB_API_KEY) xcconfig token literal. Legacy ProcessInfo lookup stays as a fallback so the Xcode "Run" scheme env-var workflow keeps working for contributors.
  • samples/ios-demo/SceneViewDemo/Info.plist — added SketchfabAPIKey = $(SKETCHFAB_API_KEY) placeholder. xcodebuild substitutes it from the user-defined build setting at archive time.
  • .github/workflows/app-store.yml — both iOS and macOS xcodebuild archive steps now pass SKETCHFAB_API_KEY="$SKETCHFAB_API_KEY" (sourced from the SKETCHFAB_API_KEY repo secret).
  • .github/workflows/ios.yml — same injection on the CI demo-build step so the Info.plist substitution 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 JVM RenderQualityLaunchedEffectTest. Pins the #1078 keyed-LaunchedEffect(view, renderQuality) contract using a real View: apply the preset, mutate view.bloomOptions.strength = 0.4f, simulate 5 unchanged recompositions, assert the user tweak survived. Pre-#1078 (unkeyed SideEffect), the 0.4f would have been clobbered back to the preset value on every recomposition. 3 test methods. The two pure-JVM RenderQualityLaunchedEffectTest + instrumented RenderQualityComposeTest cover 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 the DisposableEffect(cameraNode) rewire shipped in PR #1147 (Scene.kt:293, closes #1143). Three tests: 5 sequential SceneNodeManager lifecycles sharing one Filament Scene leak zero cameras, parent → child HUD-node propagation cascades on dispose, and the cameraNode swap 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 the destroyMaterialsOnDispose: Boolean = false flag added to RenderableNode + GeometryNode constructors in PR #1132 (closes #1123). Four tests: the flag actually destroys the constructor-passed MaterialInstance (its nativeObject handle drops to 0), default false preserves the instance for external owners (rememberMaterialInstance, DisposableEffect), multi-primitive lists with null entries are handled without NPE, and the destroy path is idempotent across double-destroy via the runCatching-wrapped safeDestroyMaterialInstance.
  • 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 DemoScaffoldTestTags object exposes stable testTags (demo-settings-fab, demo-settings-peek, demo-settings-sheet, demo-qa-pill) consumed by DemoInteractionTest and any future visual smoke tooling.
  • samples/android-demo/.../DemoInteractionTest.kt lazy-opens the sheet inside tap() / 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 (was OrbitCameraDemo).

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's dokkaSourceSets FactoryNamedDomainObjectContainer cannot be deserialized from the Gradle configuration cache, so the step crashed on release.yml run 25870464897. The --retry-with-backoff wrapper 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-release job no longer veto-gated on publish-api-docs — Maven Central + 3 npm packages + SPM tag are user-visible artifacts; Dokka HTML is secondary (users can still consume libraries on mvnrepository / npm without 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 in IBLPrefilter.specularFilter instead 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 — --install branch now calls android_cli_install_and_launch (atomic install+launch via android run) with an adb install -r fallback when the CLI is missing.
  • .claude/scripts/capture-play-store-screenshots.sh — initial APK install uses android_cli_install_and_launch on single-device hosts; falls back to adb install -r on multi-device hosts (the android run subcommand has no --device flag in v0.7). The per-iteration am force-stop + am start --es demo <id> block stays on adb (legit holdout — android run v0.7 has no intent-extras forwarding).
  • tools/try-demo.sh — check_device now accepts either android or adb on PATH (and surfaces both install hints when neither is present). Already wired to android_cli_install_and_launch since the helper landed.
  • .claude/scripts/visual-check.sh — annotated the bottom-nav tap coordinates to flag them as legit adb holdouts (no input-event API in android CLI v0.7).
  • sceneview/src/androidTest/.../VisualVerificationTest.kt — KDoc now states explicitly that adb pull is the only operation here without an android CLI equivalent as of v0.7.
  • docs/docs/try.md — terminal-install snippet now shows android run first (atomic install+launch) and keeps adb install -r as 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 the attempts=0; while …; done loop above it.
  • Validator extension: .claude/scripts/check-workflow-scripts.sh (shipped by #1145) now runs a per-line slicing simulation on every with.script: block — dash -n passes 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 to main undetected. Sanity-tested by reintroducing the original break locally — validator exits 1 with a pointed error message.
  • Backwards compatibility: run: blocks (which GitHub Actions defaults to bash -e {0}, executed as one script) are untouched; backslash continuations remain valid there. Only with.script: blocks (per-line sh -c semantics) 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.…):

  • DemoEntry data class refactor — title: String, subtitle: String → @StringRes titleRes: Int, @StringRes subtitleRes: Int. The category field stays a stable non-translated key (used as map key + accent-colour lookup) with a parallel categoryDisplayNameRes(category) helper that returns the localized header.
  • 37-demo registry rewritten to thread R.string.demo_*_title / R.string.demo_*_subtitle IDs through to the Samples grid and the Explore "Try a sample" carousel.
  • 39 per-demo DemoScaffold(title = "…") callsites migrated to stringResource(R.string.demo_*_title) — every demo's TopAppBar title 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.xml expanded from 164 → 270+ keys, covering every public-facing UI string in the priority surfaces. FR values-fr/strings.xml mirrors 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 from SketchfabCategories.kt and 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.kt updated to pass R.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 on ARSceneView — same LightSlot enum as the 3D SceneView. Default .systemDefault provisions a 10 000-lux directional main + a 3 000-lux fill, matching Android's ARSceneView(mainLightNode = …, fillLightNode = …) defaults.
  • Reactive swap path — when the caller mutates the modifier value, the previous light's AnchorEntity is removed from arView.scene and a new one is added in its place. Mirrors Scene.kt:540's prevFillLightRef diff pattern. No full RealityView teardown.
  • ENVIRONMENTAL_HDR parity documented — config.environmentTexturing = .automatic (already set, now annotated) is the ARKit equivalent of ARCore's Config.LightEstimationMode.ENVIRONMENTAL_HDR. Both drive PBR cubemap reflections for runtime-built environment probes; neither exposes a per-frame directional light estimate on fillLight.
  • Tests: 9 pinning tests in ARSceneViewTests.swift (default slots, modifier copy-semantics, .disabled round-trip, .custom(LightNode) entity-identity retention, last-modifier-wins, chaining with .cameraExposure + .onSessionStarted).
  • Docs sync: docs/docs/cheatsheet-ios.md AR 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 @MainActor ObservableObject — state: .idle / .recording / .error(message), lastOutputURL, isRecording (@Published-derived), isAvailable.
  • async throws API — 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-matching errorDescription).
  • ARRecorder.remembered() factory — mirrors Android's rememberARRecorder() for code-generation symmetry.
  • ARRecorder.saveToPhotoLibrary(_:) static helper (#1043 item 2) — wraps PHPhotoLibrary.performChanges so the recorded .mov can be copied into the user's Photos library. Mirrors Android's ARRecorder.exportToDownloads(). Requires NSPhotoLibraryAddUsageDescription in the host app's Info.plist. Demo gets a "Save to Photos" button alongside ShareLink.
  • What's recorded: screen pixels only (NOT ARSession state). The .mov plays back in Photos / QuickTime; it cannot be fed back into ARSession for deterministic replay. Use RerunBridge for replay-driven testing.
  • iOS demo: samples/ios-demo/.../ARRecorderDemo.swift mirrors Android's ARRecordPlaybackDemo with a record-only banner + live AR session + tap-to-place markers + "Save to Photos" + ShareLink for the captured .mov. Registered in the AR section of SamplesTab.
  • 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 orbit target along 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's fieldOfViewInDegrees — mirrors Android FovZoomCameraManipulator (range 10°..120°, default 60°).
  • Mode picker in iOS demo: CameraControlsDemo gets a 3-way Picker segment 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 (default true). Pass false for 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 000 lux (#1075, PR #1079 + PR #1088 for the AR cross-fix). Now 1:1 with DEFAULT_MAIN_LIGHT_COLOR_INTENSITY, ambient and key light contribute proportionally. Apps that hand-tuned mainLight.intensity against the implicit 30k IBL will see ambient drop ~3× and shadows deepen. Restore the v4.2.0 look via indirectLight.intensity = 30_000f on 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 3D DefaultCameraNode for cross-mode parity and aligns with the v4.1.0 light defaults (main 10k, fill 3k). Apps that override cameraExposure = -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 via lightEstimator.environmentalHdrSpecularFilter = false.
  • AR Config.LightEstimationMode default : ARCore's stock AMBIENT_INTENSITY → ENVIRONMENTAL_HDR (#1063 acceptance #2, CORR-A). Set inside ARSceneView's session.configure { … } block BEFORE the user's sessionConfiguration callback so callers can still opt back into another mode. Front-camera sessions still force DISABLED (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 #1064 specular 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 degrades LightEstimate.State to NOT_VALID and the #1063 neutral IBL baseline stays in place — no crash, no visual regression. Restore v4.2.0 mode via sessionConfiguration = { _, c -> c.lightEstimationMode = Config.LightEstimationMode.AMBIENT_INTENSITY }.
  • AR ARSceneView two-light defaults (#1063 acceptance #3, CORR-A). ARSceneView now exposes a new fillLightNode: LightNode? = rememberFillLightNode(engine) parameter, mirroring the 3D SceneView v4.1.0 setup (main 10k + fill 3k lux from opposite-side directional). The fill light is unaffected by ARCore light estimation — only mainLightNode is 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 via ARSceneView(fillLightNode = null). Deprecated ARScene alias forwards the param.
  • SceneView(isOpaque = false) is now actually transparent (#1077 via PR #1092). v4.2.0 ignored the flag — uiHelper.isOpaque and view.blendMode were never wired. Apps that set isOpaque = false and 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 mainLight in ~15 frames (#1062 via PR #1069). Per-frame mainLight.intensity *= estimate.pixelIntensity compounded toward 0 (or ∞). Replaced by a baseline-cache pattern (compareAndSet on first valid estimate, then baseline * estimate each frame). Keyed on mainLightNode identity so the #1017 reactive swap resets cleanly. Regression pin in ARMainLightBaselineMultiplyTest.
  • 🚨 createAREnvironment shipped without IndirectLight (#1063 via PR #1069). New iblBuffer: Buffer? parameter; rememberAREnvironment defaults to the bundled neutral 256×128 dim-grey IBL arsceneview/src/main/assets/neutral_environment.ibl. Metals in AR no longer render jet-black before the first ARCore estimate.
  • 🚨 ARSceneView AR scene baseline now mirrors the 3D Scene v4.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 on ARSceneView. Mirrors the 3D SceneView v4.1.0 two-light defaults — main 10k + fill 3k lux from opposite-side directional. The fill light is unaffected by ARCore light estimation (only mainLightNode is multiplied by the estimate); pass null to keep a single-light AR scene. Deprecated ARScene alias forwards the new param. The prevFillLightRef SideEffect mirrors prevMainLightRef so reactive swaps are clean.
  • Default Config.LightEstimationMode = ENVIRONMENTAL_HDR (replacing ARCore's stock AMBIENT_INTENSITY). Without HDR, the IBL baseline shipped by rememberAREnvironment (#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's sessionConfiguration callback so callers can still opt back into another mode. Front-camera sessions still force DISABLED inside ARSession.configure(...) regardless. Documented in the ARSceneView KDoc for both sessionConfiguration and the param section. Pinned by ARCompletenessDefaultsTest (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 upstream CubemapSH.cpp convention, 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.
  • LightEstimator double-closed ARCore Image objects in cubemap callback (#1090 via PR #1091). The image.use { } block already closed the Image; the trailing arImages.forEach { it.close() } then threw IllegalStateException (swallowed). Side-fix : @Volatile on 6 environmentalHdr* toggles + isEnabled (#1094 via PR #1095).
  • LightEstimator robustness — 3 follow-ups to #1091 / #1095 (CORR-B audit, acceptance #2 of umbrella #1094). Three latent issues that survived the first two LightEstimator cleanups:
  • destroy() race vs. late render frame — added @Volatile private var isDestroyed gate at the top of update() so a frame arriving after DisposableEffect.onDispose short-circuits instead of touching freed engine.destroyTexture natives. destroy() is now idempotent and latches the flag before freeing textures.
  • Cubemap-texture leak on environmentalHdrReflections toggle — toggling true → false previously skipped the if (reflectionsOn) { ... } branch entirely, leaving cubeMapTexture + cubeMapTextureSpecular + the direct staging ByteBuffer alive in native heap forever. New nullify-on-disable path at the top of update() routes through the existing destroy-on-reassign setters; symmetric handling of environmentalHdrSpecularFilter toggling off (frees only the specular texture, preserves the base).
  • Staging-buffer race vs. async Filament upload — restored the PixelBufferDescriptor callback as a @Volatile uploadInFlight flag flip (set true before setImage, reset by the Filament render thread). AR thread now skips the cubemap update while in flight, preventing a cubeMapBuffer.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 in LightEstimatorRobustnessTest.
  • AR cleanup batch — 4 follow-ups to CORR-B and post-merge audit of #1069 / #1091:
  • createAREnvironment no longer advertises an inert isOpaque (#1121). The hard-coded isOpaque = true was bypassed by skybox = 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.
  • uploadInFlight callback hoisted from per-frame allocation (#1102). Texture.PixelBufferDescriptor previously received a fresh Runnable { 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 a private val uploadCompletedCallback so a single allocation per LightEstimator instance covers its full lifetime. Can't move to the companion object because the callback mutates per-instance state.
  • LightEstimator lifecycle ownership documented (CORR-B FU-3). Class KDoc gets a new "Lifecycle ownership" section spelling out that engine and iblPrefilter are borrowed (caller-owned, typically ARSceneView-scoped) and the correct LIFO teardown order (estimator first, then engine).
  • Instrumented stress test for concurrent update() ↔ destroy() (#1094 acceptance #3). LightEstimatorConcurrentDestroyTest.kt lands in both src/test/ (algorithmic mirror, fast CI tier — 4 tests) and src/androidTest/ (real Filament Engine smoke — 3 tests, JNI-grounded). arsceneview gains a testInstrumentationRunner config so ./gradlew :arsceneview:connectedDebugAndroidTest works. Asserts: no exceptions, monotonic isDestroyed transition, post-destroy textures freed, engine survives ≥10 allocate→destroy cycles.

Fixed — 3D rendering pipeline

  • 🚨 PostProcessingDemo silently disabled SSAO on first paint (#1076 via PR #1079). Demo state initialised at false but the library default is true. Initial paint inverted the library default, hiding ambient occlusion until the user toggled it.
  • RenderQuality preset clobbered user view tweaks on every recomposition (#1078 via PR #1089). view.applyRenderQuality(...) was in an unkeyed SideEffect. Moved to LaunchedEffect(view, renderQuality) — preset reapplies only on actual quality change, user-set view.colorGrading / view.bloomOptions survive across recompositions. Switching presets still overrides preset-owned fields (intended semantic).
  • EnvironmentLoader.createHDREnvironment convenience overloads silently dropped indirectLightApply (#1124). The 4 convenience overloads (asset / rawRes / file) plus loadHDREnvironment(url:) and loadKTX1Environment(url:) delegated to the buffer: overload but forgot to forward the indirectLightApply hook — users who wanted to override the v4.1.0-balanced 10k IBL default (#1075) had to copy the buffer-loading boilerplate. Now all overloads expose indirectLightApply: IndirectLight.Builder.() -> Unit = {}. EnvironmentDemo gains 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.
  • PhysicsDemo stacked 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).
  • cameraNode leaked into shared Scene on SceneView unmount (#1143). Same SideEffect + AtomicReference pattern that #1122 / PR #1131 just fixed for the main + fill lights. Switched to DisposableEffect(cameraNode) { addNode; onDispose { removeNode } } so the camera (and any HUD-space child nodes parented under it) is removed from nodeManager on 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-10f was below FLT_EPSILON (~1.19e-7) for normalised ray directions, so the parallel branch never triggered — Inf / Inf slab comparisons produced lottery hits on flat OBBs. New explicit abs(d) < 1e-6f parallel detection at the 3 Box slab call sites + matching twin fix in MeshCollider.AABB.rayIntersection (PR #1100). Note : MathHelper.MAX_DELTA stays at 1e-10f because bumping it would silently break Vector3.normalized() for short vectors (documented in KDoc).
  • 🚨 spherePlaneResponse returned wrong contact point on negative side (#1097 via PR #1098). Used the flipped (collision) normal for the contact-point projection — bounce side was double-shifted off the plane. Now uses planeNormal directly for the projection identity contact = 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.

  • SpringAnimator underdamped 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.slerp transform uses exponential decay (#1126 item 2, PR #1141). Transform.slerp previously called raw Quaternion.slerp(a, b, t) with t = deltaTime * speed, which is NOT frame-rate independent (smaller t at higher fps → slower convergence). Replaced by exponential-decay formulation t = 1 - exp(-speed * deltaTime) so convergence rate is identical at 30 / 60 / 120 fps.
  • Matrix.decomposeRotation no longer uses this as scratch (#1126 item 3, PR #1140). The method mutated this as 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 — decomposeRotation is 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 bare SideEffect so a removed LightNode recomposition left the light entity attached to the Filament scene forever. Now uses DisposableEffect(mainLightNode, fillLightNode) with explicit removeEntity on dispose — symmetric add/remove, no Filament-side leak across LightSlot swaps. Pinned by the existing Scene lifecycle tests + a new add/remove-balance assertion.
  • destroyMaterialsOnDispose flag on RenderableNode + GeometryNode (#1123 via PR #1132). MaterialInstance allocated inside a node's apply block was leaked because the node assumed the material was owned by the caller. New destroyMaterialsOnDispose: Boolean = false parameter (default preserves caller-owned semantics); set true when the node creates its own MaterialInstance. rememberMaterialInstance helpers default to true, so callers using the v4.0.x recommended pattern see no leak.

Fixed — AR cubemap upload (#1142)

  • 🚨 Texture.Builder now sets Usage.GEN_MIPMAPPABLE for the ARCore HDR cubemap (PR #1142). v4.3.0 RC blocker. Filament 1.71 hardened the texture-usage check and engine.createTexture now throws when a cubemap is built without GEN_MIPMAPPABLE and later submitted to mipmap generation. LightEstimator called texture.generateMipmaps() immediately after setImage, so AR sessions with environmentalHdrReflections = true crashed on the first cubemap upload (~1 second after START_TRACKING). Fix adds the flag at the two Texture.Builder call sites + a regression pin in LightEstimatorCubemapBuilderTest.

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). Lets ARRecordPlaybackDemo show a non-empty list on first launch and unblocks emulator-testable AR demos. 4 JVM tests pin the ftyp + avc1 + mett codec 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 --fix mode now actually rewrites SwiftPM from: clauses (#990). The pre-existing fix block silently no-op'd under set -euo pipefail because the last loop iteration's [ ] && echo short-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 SwiftPM from: snippets across docs/website/marketing, plus root Package.swift install snippet).
  • DemoInteractionTest AppBar 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 now Billboard Panel to disambiguate from the AppBar.
  • OrbitalARDemo Float precision drift (#978) — modulo 2π on orbit + spin angles (Android + iOS) so cumulative angle survives long-running sessions.
  • ExploreTabScreen partial-success path (#980) — supervisorScope + catchingFeed helper so a transient Sketchfab feed failure no longer wipes the other two; CancellationException re-thrown to keep structured concurrency intact.
  • DeepLinkRouterTest.kt JVM compile — pre-existing breakage since 2556c467 (4-arg DemoEntry ctor lost when icon field was added). Caught during PR #1040 5-agent review; all 13 deep-link tests now compile and run.
  • validate-spm regex hardened (#1007) with a targets: anchor so a commented-out // .library(name: "SceneViewSwift", ...) line cannot satisfy the check.
  • QA script qa_android_demos.py updated to the renamed registry labels.

Documented — Triage sweep

  • Filament runtime ↔ .filamat ABI invariant (#1023) in CONTRIBUTING.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.0 and sceneview_flutter@4.2.0 aligned 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) pin Box.rayIntersection correct behaviour for thin-slab boxes. Acceptance criterion oublié de #1096.
  • MeshColliderTest.kt — 5 new methods pin the twin parallel-ray epsilon fix in MeshCollider.AABB.rayIntersection across x and z axes. Acceptance criterion oublié de #1100.
  • SceneFactoriesTest.kt (new file) — pins DEFAULT_IBL_INTENSITY = 10_000f, the 1:1 ratio with DEFAULT_MAIN_LIGHT_COLOR_INTENSITY, and the 3D DefaultCameraNode.DEFAULT_APERTURE/SHUTTER_SPEED/ISO triple (#1067, #1075).
  • ARDefaultCameraNodeTest.kt (new file) — pins ARDefaultCameraNode exposure via the new companion constants, cross-checks parity with 3D DefaultCameraNode, and asserts ≥1 stop brighter than sunny-16 (#1067). 3D DefaultCameraNode was refactored in the same PR to expose matching DEFAULT_APERTURE/SHUTTER_SPEED/ISO companion constants; AR aliases them at compile time to eliminate drift risk.
  • RenderQualityLaunchedEffectTest.kt (new file) — pins the LaunchedEffect(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 verifies Scene.kt:278 actually keys on both view and renderQuality (#1078).

CI — Batch B0 (#1116, #1117, #1118)

  • publish-api-docs now gates create-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: true and || echo swallow removed.
  • quality-gate.yml skips docs-only PRs (#1117) — paths-ignore mirrors the filter already in place on ci.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, accepts java-version: "17" for Flutter jobs) and .github/actions/setup-mcp (Node + npm-lockfile cache + npm ci in mcp/). 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-render is continue-on-error: true and 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 000 lux (×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:
    SceneView { /* ... */ }
      .mainLight(.custom(LightNode.directional(intensity: 1_000)))
      .fillLight(.disabled)
    

Added — LightSlot / LightNode.fill / mainLight / fillLight modifiers (#1016)

  • LightSlot enum — .systemDefault / .disabled / .custom(LightNode) (3-state, exhaustive switch). Cleaner than Optional<LightNode?> sentinel.
  • SceneView.mainLight(_:) + SceneView.fillLight(_:) modifiers.
  • LightNode.fill(color:intensity:castsShadow:) factory, signature-consistent with LightNode.directional(...). No baked orientation (caller calls .lookAt(_:)).
  • @MainActor public struct LightNode — replaces the unsound Sendable conformance (LightNode wraps a non-Sendable Entity).
  • Known limitation (#1017): light slot is read once during scene setup. Reactive replacement via .fillLight(.custom(newLight)) mid-frame is not yet wired — Android's prevFillLightRef swap pattern (Scene.kt:287-305) needs equivalent diffing in iOS RealityView.update:.

Added — RenderQuality preset (#1018)

  • RenderQuality enum — .cinematic / .default / .performance, mirrors Android RenderQuality.kt.
  • SceneView.renderQuality(_:) modifier. Walks all DirectionalLight children + adjusts ImageBasedLightComponent.intensityExponent per 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 Android AugmentedImageNode.
  • AnchorNode.face() — anchor to detected face (front-camera). Mirrors Android AugmentedFaceNode (pose only — no morphing-mesh; for that, drop down to raw ARFaceAnchor + 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.

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 .filamat to the pre-efd296f1 snapshot (git checkout efd296f1~1 -- sceneview/src/main/assets/materials/).
  • Recompiled the two newer opaque_unlit_colored.filamat + transparent_unlit_colored.filamat with matc 1.70.2 from the upstream v1.70.2 release tarball so they match the runtime.
  • Verified on a Pixel_7a -gpu host emulator: 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 ARSceneView behind an explicit "Start AR Camera" CTA, with an ArCoreApk.checkAvailability() status pill and a 2×3 grid of the six headline AR demos visible immediately.
  • runCatching around checkAvailability so it can't silently die on OEMs without Play Services. CTA is hard-disabled on UNSUPPORTED_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.
  • sessionStarted is now rememberSaveable so 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.

  • DemoEntry now carries icon: ImageVector and status: 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 dark surfaceContainer instead of burning at >9:1 contrast.
  • LargeTopAppBar scroll behaviour wraps rememberTopAppBarState() 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.
  • SampleCard rebuilt with the same accent-tinted icon-tile layout as the Samples grid so both tabs feel like one product.
  • FeedSection self-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.md memory rule rewritten to drop Google Stitch as the mandated UI source — reference-driven (Sketchfab mobile / Polycam / Reality Composer)
  • DESIGN.md tokens is the new SceneView demo workflow.
  • Local Sketchfab API key support in local.properties for 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 .filamat files via matc 1.71.0 to material-binary version 71.
  • Commit 4a31b579 (May 11, #961) reverted ONLY gradle/libs.versions.toml's filament to 1.70.2 thinking the .filamat files were still v70 — they had been at v71 for a month. Filament 1.70.2 runtime cannot parse v71 packages → SIGABRT in libfilament-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_000 lux (×10 drop). Existing apps will render noticeably darker unless they override mainLightNode.intensity explicitly 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 on SceneView, defaulted to rememberFillLightNode(engine). Pass null to 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 via view.ambientOcclusionOptions.enabled = false if 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 → 5 so 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) and Open Settings (deep-links into the app's permission page) buttons.
  • DynamicSkyDemo: rendered as "fully black at noon" because DynamicSkyNode positions 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 live SceneView once the model loads — the "come to life" transition that reads as proof of native rendering; (3) premium studio_2k.hdr IBL by default (much more flattering on PBR than neutral_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 uses Crossfade from androidx.compose.animation keyed 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 + MultiModelDemo already had LoadingScrim; this completes the four-demo set by adding the same translucent spinner overlay to CameraControlsDemo and AnimationDemo (both load non-trivial GLBs — khronos_damaged_helmet.glb / threejs_soldier.glb — with a multi-second empty-black first-frame window). GeometryDemo deliberately 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-line Cinematic / Default / Performance switch on SceneView. 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 what ambientOcclusionOptions is) can pick one preset and ship. Individual view.* 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. New fillLightNode: LightNode? parameter on SceneView defaults to this; pass null to keep the single-main-light look.

Added — Sketchfab streaming scaffold

  • iOS (918faacd) — actor SketchfabService under samples/ios-demo/.../Services/. URLSession + Codable models, on-disk LRU cache (500 MB cap), env-var-based API key (SKETCHFAB_API_KEY).
  • Android (72cff080) — mirror in samples/android-demo/.../sketchfab/. OkHttp + kotlinx-serialization, same 500 MB LRU cache, BuildConfig.SKETCHFAB_API_KEY populated from env or local.properties (gitignored).
  • CI (7858051f) — build-apks.yml forwards secrets.SKETCHFAB_API_KEY next to the existing ARCORE_API_KEY pattern. Forks / PRs from forks with an unset secret build cleanly — the gallery falls back to bundled featured models and disables Sketchfab search at runtime via SketchfabError.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.1 markers are in place in SketchfabConfig.{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) in SceneFactories.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 = true and view.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 via view.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 three playAnimation(...) overloads accepted a speed: Float parameter but never wired it through. Fixed by capturing the returned AnimationPlaybackController and setting .speed = speed.
  • CameraNode.depthOfField(focusDistance:aperture:) (141eda05) — annotated @available(*, deprecated, message: "..."). RealityKit's PerspectiveCameraComponent does 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 to ARSceneView(cameraExposure:) for AR or to scene lighting intensity for 3D.
  • LightNode.shadowColor(_:) (141eda05) — DirectionalLightComponent.Shadow has no color property; the parameter is ignored. Deprecation message points users at castsShadow(_:) / shadowMaximumDistance(_:).

Added — iOS demo: "Coming soon" badges for non-ported demos

  • DemoStatus enum + ComingSoonScreen (567d6476) — Android has 37 sample demos, iOS has 16. The other 21 used to be invisible on iOS. Now they appear in the Scenes tab 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 (sceneview v4.0.11) bundling the sceneview-mcp server, 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):
    /plugin marketplace add sceneview/claude-marketplace
    /plugin install sceneview@sceneview
    
  • 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-mcp stays independently versioned on npm.
  • Discovery surfaces wired (01114229): plugin-install instructions added to README.md, llms.txt, mcp/README.md, docs/docs/ai-development.md, docs/docs/index.md. GitHub topics on the marketplace repo cover claude-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: Boolean field on the web geometry { … } DSL. When set, the GLB material gets the standard glTF 2.0 KHR_materials_unlit extension — Filament.js supports it natively and skips PBR / IBL evaluation entirely. Closes the cross-platform unlit gap (Android already had createUnlitColorInstance in v4.0.8, Apple had CustomMaterial.unlit, RN/Flutter bridges shipped unlit: bool in v4.0.9 too).
  • Web demo showcase — per-shape "Unlit" checkbox in samples/web-demo so 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-safe ReadableType.Boolean parsing 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-ops addGeometry).

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.glb 30 MB, khronos_iridescent_dish.glb, khronos_sheen_chair.glb, khronos_glam_velvet_sofa.glb, toon_cat.glb, khronos_duck.glb) from the shared android-demo/assets/ symlink target to a TV-demo-private folder. TV demo picks up shared assets via sourceSets.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. The samples/android-demo-assets/ com.android.asset-pack module 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) — added max-parallel: 1 to the publish job's matrix so the internal and production tracks 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_VERSION blind spot (04e75ad5) — samples/ios-demo/SceneViewDemo.xcodeproj/project.pbxproj was missed for 8+ releases. sync-versions.sh now covers it (29 checks, was 28).

Tested

  • NoTangentsGlbContractTest (04e75ad5) — substring "TANGENT" assertion replaced with regex anchored to the attributes block, 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: Filament Color, Compose Color, and Int. 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, and ARFaceDemo — the front-camera face-mesh overlay no longer needs an explicit fill light to compensate for the front-camera disabling ENVIRONMENTAL_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 from yHeight = 0.15 m (low-angle monument) to 0.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 by studio_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.kt cameraManipulator swap reactivity — cameraManipulator is now wrapped in rememberUpdatedState so 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 see getTransform() 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 future gltfio bumps 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? on ARSceneView replays 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.kt with LIVE / RECORD / PLAYBACK modes. Recording uses setAutoStopOnPause(true) so backgrounding the app produces a clean MP4; optional recordingRotation keeps replay upright across orientations.
  • ARDepthOcclusionDemo — toggles Config.DepthMode.AUTOMATIC so real-world objects correctly hide virtual ones. Falls back to a clear "device not supported" banner when isDepthModeSupported returns false. Library plumbing in ARCameraStream was 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 to FULL_TRACKING.
  • ARTerrainAnchorDemo — geospatial anchor that snaps a model to Google's terrain altitude at any lat/lng. Drop-here button gated on Earth.EarthState.ENABLED to avoid silently swallowed IllegalStateExceptions.
  • ARRooftopAnchorDemo — geospatial anchor that snaps to building rooftops. Same Earth-state gate as Terrain.
  • ARImageStabilizationDemo — toggles Config.ImageStabilizationMode.EIS. Smooths the camera background image without affecting virtual content. Gates on Session.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, and RecordingConfig builder calls. Surprising current behaviours pinned: stop() does not internally guard the IDLE state, and attach(newSession) mid-RECORDING is a pure pointer swap (the original session never receives stopRecording() — 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 optional playbackDataset: File? = null param. Snapshotted at first composition; switch playback files via key(playbackDataset) { ARSceneView(...) }. PlaybackFailedException is routed to onSessionFailed.

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 the get_setup_guide MCP tool): added the API_KEY meta-data + ACCESS_FINE_LOCATION permission + Cloud setup block.
  • mcp/src/explain-api.ts (returned by explain_api): added the missing key/permission gotcha to the "common mistakes" list.
  • mcp/src/debug-issue.ts (returned by debug_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.md shipped earlier in v4.0.6 stays the canonical step-by-step guide.

Improved — sample app demos

  • ARStreetscapeDemo and ARCloudAnchorDemo now read com.google.android.ar.API_KEY from the manifest at runtime (via PackageManager.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 cryptic ERROR_NOT_AUTHORIZED after a tap. No-op for production builds (Play Store / App Store ship the key); helpful for forks.

Internal

  • npm sceneview-mcp 4.0.8 → 4.0.9 — picks up the regenerated mcp/src/generated/llms-txt.ts so npx sceneview-mcp users see the new ARCore Cloud key section in sceneview://api.
  • 8 Dependabot ip-address moderate alerts cleared via npm audit fix across 8 lockfiles (commit a155966b).
  • iOS bundle 362 → 363, MARKETING_VERSION 4.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_VERSION 4.0.5 → 4.0.6, CURRENT_PROJECT_VERSION 361 → 362 (TestFlight cumulative bundle counter).
  • Documentation: samples/android-demo/STREETSCAPE_SETUP.md shipped 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 a remember { 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 a Node() to attach a position via the wrapper, breaking apply = { nodeRef = this } because this was the wrapper Node, not the inner SphereNode. Collapsed back to SphereNode(position = …, apply = { nodeRef = this }) since SphereNode supports both.
  • samples/ios-demo/SceneViewDemo.xcodeproj: CURRENT_PROJECT_VERSION 359 → 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: reactive position / rotation / scale / isVisible props on the composable; lifecycle race on post-destroy fixed.
  • Node / ModelNode default Scale(1f) regression — was (1, 0, 0) singular transform that cascaded NaN through every downstream matrix op (Physics, animations, children).
  • MaterialInstance reassignment now propagates to all geometry nodes (Sphere/Cube/Plane).
  • onFrame callback 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 pinchZoomDelta and nextFov helpers 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:test cover sub-pixel linearity, sign preservation, speed scaling, damping softening, FOV clamps, and default constants.

API surface — non-breaking by design

  • LightNode(color = …) parameter placed AFTER position (not in slot 3) to preserve positional source-compat for existing 4.0.x callers passing direction positionally. Documented in SceneScope.kt:354.
  • Engine.kt safeDestroy* helpers retain runCatching wrapping (the rebase-rescue PR initially stripped it; restored to avoid ABI break for v4.0.x consumers — see commit message fd1d820e).
  • ImageNode.destroy() deliberate Texture retention now documented in a public KDoc with the recommended bitmap = newBitmap recycling 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 SurfaceOrientation in AugmentedFaceNode.computeTangents (~30 Hz JNI alloc on hot path).

  • 874: frame-deferred destroy queue for ImageNode / ViewNode GPU textures.

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 .rrd recording 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-S events) ship on Android and iOS with full parity. iOS uses the native share sheet; Android uses MediaStore. Wire-format goldens updated (4b8993dd, fa1f8bc1).
  • One-command review guide .claude/scripts/check-rerun.sh for the Save & Share MVP (58c74d3f).
  • 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.json now 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.entitlements now declares applinks:sceneview.github.io (Associated Domains capability). Pairs with the existing apple-app-site-association published 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). The v4.0.3 tag 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

  • AboutTab was hard-coding "v4.0.0-rc.1"; now reads BuildConfig.VERSION_NAME so the published build always shows the truthful version (f516387f).

Internal

  • 11 commits in this release, all on main. Tag v4.0.3 is 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 the MaterialInstance "view" still in use by Renderable SIGABRT seen on screen navigation (#849, closes #837, #847).
  • PlaneRenderer.destroy() routes through MaterialLoader.destroyMaterial() to prevent double-free on AR scene teardown (#850).
  • ViewNode.destroy() and rememberViewNodeManager hardened against the post-destroy race that left a leaked WindowManager view if resume() and destroy() interleaved within a single frame (#820, #853).

Fixed — BillboardNode mirrored texture

  • BillboardNode (and TextNode via inheritance) no longer renders the back face of the plane quad. Switched from lookAt(camPos) to lookTowards(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 in BillboardNodeMathTest pins the math convention (#858).

Fixed — ViewNode reactive props

  • ViewNode composable restores the full reactive prop set (position, rotation, scale, isVisible) and switches from SideEffect to DisposableEffect keyed on scalar components — Compose state changes now propagate without redundant per-recomposition writes (#856, #857). Closes the regression of the original 7d82701c implementation reintroduced by #842.

Security

  • hono bumped to 4.12.17 across mcp-gateway, telemetry-worker and the bundled MCP packages — resolves the hono/jsx SSR XSS via JSX attribute names (9 alerts) (#862).
  • postcss bumped 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

  • roborazzi 1.43.0 → 1.60.0 (#830).
  • dev.romainguy:kotlin-math reference in llms.txt synced 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() and capsule() added to SceneViewSwift geometry API, matching the Android/KMP surface
  • ConeNode, TorusNode, CapsuleNode documented in docs/nodes.md

Fixed — Filament 1.71.0 Materials

  • Recompiled 6 .filamat materials 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-mcp and interior-design-3d-mcp files[] glob fix — tarball no longer ships incomplete
  • FREE_TOOLS count corrected (14 → 23)

Improved — Android Samples

  • Layout and scaleToUnits tuned 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's apply is a named parameter (apply = { intensity(…) }), not a trailing lambda — matches the Compose convention for layout-affecting side effects.
  • rememberModelInstance(modelLoader, "models/file.glb") returns null while 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 + rememberRerunBridge composable helper. Non-blocking Dispatchers.IO scope, Channel.CONFLATED drop-on-backpressure, rate-limited 10 Hz by default, runtime setEnabled() kill switch. Zero new Gradle dependencies.
  • iOS: new SceneViewSwift.RerunBridge (@ObservableObject with @Published eventCount), Network.framework NWConnection on a dedicated utility queue. New ARSceneView.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 via rr.init(spawn=True).
  • Playground: new "AR Debug (Rerun)" example in the ar-spatial category with per-platform code tabs.
  • Sample apps: new RerunDebugDemo tile in samples/android-demo (Samples tab) and samples/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 /mcp proxy with X-Api-Key auth, 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 native android/ and ios/ modules scaffolded.
  • Web: sceneview-web Kotlin/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): NodeAnimator now writes animated values back to the target Node'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 Engine instance per test class. The class-level @Ignore workarounds have been removed.
  • AR camera exposure (#792): Added cameraExposure parameter to ARSceneView composable.
  • customer_creation bug: stripe-client.ts now guards form.customer_creation = "always" with if (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 sceneview and arsceneview (#814).
  • 168 gateway/hub tests.

Dependencies

  • AGP bumped 8.11.1 → 8.13.2, maven-publish 0.35.0 → 0.36.0.
  • activesupport bumped >= 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 + rememberRerunBridge composable helper. Non-blocking Dispatchers.IO scope, Channel.CONFLATED drop-on-backpressure, rate-limited 10 Hz by default, runtime setEnabled() kill switch. Zero new Gradle dependencies.
  • iOS: new SceneViewSwift.RerunBridge (@ObservableObject with @Published eventCount), Network.framework NWConnection on a dedicated utility queue. New ARSceneView.onFrame { frame, arView in … } modifier wired to the existing ARSessionDelegate.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 via rr.init(spawn=True).
  • Playground: new "AR Debug (Rerun)" example in the ar-spatial category — embeds the official Rerun Web Viewer from app.rerun.io next to the SceneView canvas with per-platform code tabs for Android / iOS / Web / Flutter / React Native / Desktop / Claude.
  • Sample apps: new RerunDebugDemo tile in both samples/android-demo (Samples tab) and samples/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 boilerplate
  • generate_ar_logger — Kotlin or Swift AR streaming helper, parameterized by data types and rate
  • generate_python_sidecar — TCP → rerun-sdk Python bridge
  • embed_web_viewer — HTML + module-script snippets for @rerun-io/web-viewer
  • explain_concept — focused docs for rrd, 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 cameraExposure parameter to ARSceneView composable, 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 Engine instance 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 @Ignore guards added as a temporary workaround have been removed.
  • MCP tiers test: Removed stale Polar URL from tiers.test.ts that was causing a test failure after the Polar → Stripe migration.

Tests

  • 16 new JVM tests in arsceneview (12 golden-JSON for RerunWireFormat, 4 socket integration for RerunBridge with a mock ServerSocket)
  • 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 sceneview and arsceneview modules (#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 Node god class into NodeGestureDelegate, NodeAnimationDelegate, NodeState
  • Extract ARPermissionHandler interface (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 (@MainActor on 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_artifact MCP 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_artifact tool (590 tests)

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_preview accepts 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

  • MeshNode now accepts optional boundingBox parameter

Security

  • 6 Dependabot vulnerabilities fixed, 15 audit issues resolved
  • 28 stale repository references updated

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_preview tool 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-web module: 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}-demo naming 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_setup tool for Swift/iOS project bootstrapping
  • Swift code validation in validate_code tool
  • 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 deprecated engine parameter from PlaneNode, CubeNode, CylinderNode constructors (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 row
  • camera-manipulator: scale up model 0.25 → 1.0; add gesture hint bar (Drag·Orbit / Pinch·Zoom / Pan·Move)

Developer tooling

  • /maintain Claude 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.md added 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.gradle to match
  • Refresh gltf-camera sample: 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
  • ViewNode rename — ViewNode2 unified into ViewNode

Fixes

  • ToneMapper.Linear in ARScene prevents overlit camera background
  • ImageNode SIGABRT: destroy MaterialInstance before texture on dispose
  • cameraNode registered with SceneNodeManager so HUD-parented nodes render correctly
  • Entities removed from scene before destroy to prevent SIGABRT
  • UiHelper API corrected for Filament 1.56.0

AI tooling

  • MCP server: validate_code, list_samples, get_migration_guide tools + 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 (.kt files).
  • 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 — SideEffect was resetting isEditable to the parameter default (false) on every recomposition, silently disabling gestures when isEditable = true was set only inside apply { }. Pass isEditable = true as a named parameter to maintain it correctly.
  • ARCore install dialog — Removed canBeInstalled() pre-check that threw UnavailableDeviceNotCompatibleException before requestInstall() was called, preventing the ARCore install prompt from ever appearing on fresh devices.
  • Camera background black — ARCameraStream used RenderableManager.Builder(4) with only 1 geometry primitive defined (invalid in Filament). Fixed to Builder(1).
  • Camera stream recreated on every recomposition — rememberARCameraStream used a default lambda parameter as a remember key; lambdas produce a new instance on every call, making the key unstable. Fixed by keying on materialLoader only.
  • Render loop stale camera stream — The render-loop coroutine captured cameraStream at launch; recomposition could recreate the stream while the loop kept updating the old (destroyed) one. Fixed with an AtomicReference updated via SideEffect.

New features

  • SceneScope / ARSceneScope — fully declarative, reactive 3D/AR content DSL
  • NodeScope — nested child nodes using Compose's natural trailing lambda pattern
  • SceneNodeManager — internal bridge that syncs Compose snapshot state with the Filament scene graph, enabling reactive updates without manual addChildNode/removeChildNode calls
  • SurfaceType — explicit surface-type selection (Surface vs TextureSurface)
  • ViewNode — Compose UI content rendered as a 3D plane surface in the scene
  • Engine.drainFramePipeline() — consolidated fence-drain extension for surface resize/destroy
  • rememberViewNodeManager() — lifecycle-safe window manager for ViewNode composables
  • Autopilot Demo — new sample demonstrating autonomous animation and scene composition
  • Camera Manipulator — new dedicated sample for orbit/pan/zoom camera control
  • Node.scaleGestureSensitivity — new Float property (default 0.5) that damps pinch-to-scale gestures. Applied as 1f + (rawFactor − 1f) × sensitivity in onScale, making scaling feel progressive without reducing the reachable scale range. Set it per-node in the apply block alongside editableScaleRange.
  • 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