visionOS 版を .tortoise のビューワーにする #53 - #54
Merged
Merged
Conversation
SUPPORTED_PLATFORMS gains xros/xrsimulator on both targets, with XROS_DEPLOYMENT_TARGET 26.0, TARGETED_DEVICE_FAMILY "2,7" and the visionOS spellings of UISupportsDocumentBrowser. Nothing in the layout is conditional: the scene is regular width, so the same three-pane NavigationSplitView fills the window, and both packages had already declared .visionOS(.v26). What the platform actually costs is the #ifs, in both directions. A guard written `#if os(iOS)` compiles clean on visionOS and simply stops applying, which had silently dropped the launch scene (DocumentGroupLaunchScene is unavailable on macOS only), the numeric keyboard and the 44pt touch targets; those are `#if !os(macOS)` now. And some API is genuinely gone: ToolbarSpacer, the Liquid Glass grouping separator, which is why the canvas toolbar becomes a CanvasToolbar: ToolbarContent — somewhere for the #if to live that isn't the call site. CI builds visionOS for exactly this reason; nothing else catches a guard that quietly does nothing. pointerHover() stays iOS-only, and says why. hoverEffect on a palette entry segfaults inside SwiftUI's own update of PaletteEntryButton.body before a window is ever shown, with .automatic as well as .highlight, so it is the modifier rather than the effect. Buttons get the system's hover treatment there anyway. Verified by opening a real document in the visionOS 27 simulator: palette, workspace, canvas, playback row and the document title all render as they do on iPadOS, and drag & drop and the four value-slot popovers behave. The app icon is the one thing still missing. visionOS wants a circular layered icon and Icon Composer only writes squares (plus watchOS circles), so AppIcon.icon produces nothing for it and the system placeholder is what shows on the Home View. #11
`ideal` carries more weight than it looks. macOS and iPadOS 26 both let the split view's divider be dragged, so there it is only where the column opens; visionOS has no draggable divider, so there it is the width, permanently, and the detail column absorbs every extra point the window has. Measured on a 1280pt visionOS window: 360 for the workspace against ~690 for the canvas, which is 270 past the canvas's own ideal. At 360 a nested Japanese program wraps, and not only where the design had accepted it. 「くりかえす 10 かい」 broke か/い at the *top* level, no nesting involved, and 「はこにかける」 split in the middle three levels down. 440 fits every row of that program on one line at three levels — each level of nesting costs 18pt — and still leaves the canvas ~600pt. max goes to 560 because ideal == max would leave the two platforms that can drag able to drag only narrower. Checked against the same three-deep document on visionOS 27 and on a 1280×800 Mac window. Judge any change to it on a nested *Japanese* program: English fits where 「はこにかける」 does not, and the top-level wrap is invisible in a flat one. #11
Icon Composer writes squares (plus watchOS circles) and nothing else, so AppIcon.icon left visionOS with the system placeholder. The circular layered icon comes from a second source instead: an AppIcon.solidimagestack of three .solidimagestacklayers — Front, Middle, Back at 1024×1024 on the vision idiom — carrying the same artwork as the Icon Composer icon, so the tortoise reads the same in the Home View as it does in the Dock and on a Home Screen. Both are named AppIcon and neither shadows the other: ASSETCATALOG_COMPILER_APPICON_NAME is one value for every platform and actool routes by idiom. Verified in the built products, not in Xcode — the visionOS Assets.car holds a SolidImageStack and no IconImageStack, the iOS one the reverse, and the Mac still ships AppIcon.icns. Checked on the visionOS 27 Home View too, which is the only place the layers and the circular mask are actually applied. #11
visionOS で iPad アプリ相当を動かす #11
Two are `#if os(visionOS)`, so iPadOS and macOS keep the arrangements #23, #31 and #41 settled. The third — the code pane — was wrong everywhere and merely unreadable there. **A way back to the browser.** iPadOS puts a chevron beside the document title and macOS has File ▸ Open with a window per document. visionOS has neither: its window carries the drawing it was opened with, nothing offers another, and the only route to a second one was closing the window and launching the app again. A folder button in the sidebar's bar calls `dismiss`, which is what a DocumentGroup document closes itself with. Where that `dismiss` is read from decides whether it does anything. Read inside the toolbar item's own view — the obvious place, since that is where the button is — it resolves against the toolbar's context and the button is inert: it highlights on press and nothing happens. It has to come from the environment of the *content* the toolbar is attached to, hence a ViewModifier rather than a view inside the `toolbar` block. Nothing warns you: the code compiles and the button draws. **The name stays in one place.** The document's name reaches the pane late — opening from the browser leaves it blank for a moment and then fills in — and a second, self-drawn label from `\.documentConfiguration`'s fileURL fixes that, being right from the first frame. It was built, looked at, and taken back out: on a window this wide, the sidebar's title and a canvas-pane copy read as one name printed twice rather than as a title and a reminder. The sidebar's is the one that can rename, so it is the one that stays. **The code pane is paper now.** It sat on `.background.secondary`, a *semantic* surface: near-white or near-black on iPad and Mac, but light translucent glass on visionOS. The syntax colors then had nowhere to stand — system `.purple` and `.blue` are tuned for an opaque backdrop, and `.plain` was `Color.primary`, which is *white* there, so the plain text and the ground behind it were both light. It is white and opaque now, the same in both appearances, rounded the same 8 as the canvas: the two swap places inside one ZStack, so the toggle should change the content and nothing else. The token colors are fixed values measured against white — 8.6:1, 8.4:1, 5.1:1 and 16.9:1 — for the reason the block fills are fixed (#41). Two traps came with that. The copy button had to move outside the paper: on it, it needed the ink as a tint to be legible, and on visionOS the tint went to the button's *capsule* rather than its label, leaving a black lozenge with invisible text. And a program narrower than the pane sat in the middle of it, which white made obvious — in a scroll view that scrolls both ways the content is offered no width to fill, so the `.leading` frame does nothing and `defaultScrollAnchor(.topLeading)` is what places it. #11
Running on real hardware stopped with "TortoiseBlocksThumbnail has entitlements that require signing with a development certificate". The extension's Debug identity was a bare `CODE_SIGN_IDENTITY = "-"` with an `[sdk=iphoneos*]` exception naming a real certificate. That reads correctly right up until a platform arrives the exception does not name: a visionOS device build fell into the ad-hoc default, and both targets are sandboxed, so ad-hoc is never enough on a device. Simulators hid it, because they ad-hoc sign whatever they are handed — every build this repository makes on CI is a simulator or a Mac. Scoped to `[sdk=macosx*]` instead, which is what the app target already does and what the everyday Mac loop actually needs. The iphoneos exception goes with it: with ad-hoc named for the one platform that wants it, automatic signing resolves the rest. Verified with -showBuildSettings that both targets now read identically — macosx "-", iphoneos and xros "Apple Development" — and that a Mac Debug build still comes out `Signature=adhoc`, `TeamIdentifier=not set`. Name the platform that wants the exception, never the ones that don't. It is the rule `#if !os(macOS)` follows in PlatformModifiers, and it fails the same silent way when inverted. #11
A palette block was the one thing on screen that never lit up when looked at. visionOS does not give a button with a custom ButtonStyle the system hover treatment, and `pointerHover()` had been left iOS-only because it crashed there — so on the platform where gaze *is* the targeting affordance, the palette had none. The effect was never the problem. `hoverEffect` and `draggable` on the **same view** segfault: a swift_release inside SwiftUI's own update of that view's body, before a window is ever shown. It reads as "hoverEffect crashes on visionOS" because the crash blames the body, and because both `.highlight` and `.automatic` do it — but move the same `.highlight` one level down, onto the shaped body `PaletteBlockButtonStyle` draws, and it is fine. So `pointerHover()` is `#if !os(macOS)` again, the palette entry applies it inside its style rather than on the Button that carries `draggable`, and every other call site is unchanged. Checked on the visionOS 27 simulator that the palette, the block rows and the value chips all render with no crash — the chips sit inside draggable rows, which is a different view and therefore fine. It also frames better this way: the highlight follows the shape the style draws instead of the label's own bounds. #11
Everything the public sees is ready for the release that carries visionOS, and the pipeline can push a third listing. It is *not* 1.1.0 any more — this rode that version until visionOS was held back, and the text here must not go up until the platform actually ships, or the iOS and macOS listings will claim a Vision Pro app nobody can download. visionOS is a native app on the xrOS SDK, not "Designed for iPad", so App Store Connect gives it a platform version of its own — and the vocabulary for that platform is spelled three different ways across one pipeline, all three required. `visionos` names the screenshots directory, `xros` goes to deliver, `VISION_OS` goes to spaceship. The first is the one that is not a style choice: a Vision Pro capture is 3840×2160, the same size as an Apple TV one, so deliver cannot resolve the display type from the size and breaks the tie on whether the path contains "vision" — naming the directory after deliver's own platform value would file every screenshot as APP_APPLE_TV, on an app with no tvOS listing at all. So: a visionos platform block with the same two lanes, VISION_OS in CONNECT_PLATFORM, 3840×2160 in metadata_check (a platform missing from that table is not checked at all, so the size gate follows the directory), and visionos in the App Store Metadata workflow's choices, where "both" becomes "all". `SupportedPlatforms.extra` declares the namespace, which is the sanctioned way to stop fastlane warning about it on every run. The store text gains Apple Vision Pro where it listed iPad and Mac, and a What's New section for the platform; the landing page gains it in both languages, in the meta description, the two heroes, the two requirement lines and the "one app" showcase. No screenshots yet — 3840×2160, alpha off, into appstore/screenshots/visionos/<locale>/. #11
Throwaway spike, and the headline is that the expensive option is not needed. #53 framed the renderer as a choice between (a) blitting the 2D drawing into a texture on a plane entity and (b) building stroke geometry. There is a third answer: `ViewAttachmentComponent` (visionOS 26) puts a live SwiftUI view into a RealityKit scene, so the thing lying on the table is the *same* `TortoiseCanvas` the pane draws, driven by the same `CommandPlayer`. Playback, the tortoise sprite and the command stream are all unchanged, which means the highlight alignment #53 calls load-bearing is not at risk — nothing about the stream moved. Four things this cost that were not obvious: **An immersive space's origin is on the floor**, not at eye level. Placing the canvas at y = -0.4 for "desk height, below the eyes" buries it under the floor, and a buried entity looks exactly like an entity that never rendered. A marker cube at the same spot is what separated the two. **`@PhysicalMetric(from: .meters)` is the points-to-metres conversion**, so "60cm" means 60cm rather than a scale factor someone guessed. The size picker rebuilds the attachment at a new point size instead of scaling the entity, because scaling would resample one render and measure the wrong thing. **The simulator cannot reach this spike on its own.** A visionOS `DocumentGroup` ignores `simctl openurl` (the trick that opens a document on iPadOS), simctl cannot send taps, and `DocumentGroupLaunchScene` never appears there — visionOS goes straight to the system browser, so there is not even a view of ours to hang a `.task` on. Hence `-TBSpike YES`, which opens the space at launch on `SampleBlocks.spiral()`. Note `defaultLaunchBehavior(.presented)` on the `ImmersiveSpace` itself does nothing: the log shows the scene declared with `immersiveStyle = Mixed` and then `requesting immersive or volume NO`, because the DocumentGroup takes the launch. A suppressed `WindowGroup` ahead of it can take it instead, and `openImmersiveSpace` from inside a window does work. **Plane anchoring is device-only**, so the flag's route starts on the fixed placement — the only one the simulator can show. What is still unanswered is what needs a headset: legibility on a real table, the right size, and the frame rate at 10,000 commands. The button under the playback row (`TableSpikeBar`) is the device route, and it draws the open document rather than the sample. Verified: renders flat and legible in the visionOS 26.5 simulator; a launch with no flag is unchanged; macOS, iPadOS and visionOS all build; swift-format lint clean. Refs #53
`AnchorEntity(.plane(...))` may prompt for it, and a run that silently shows nothing on a headset costs a whole session to diagnose. Verified in the *built* plist, which is the only place an Info.plist key can be trusted (`INFOPLIST_KEY_*` has no form for this one). English because `en` is the source language; Phase 1 would move it to `InfoPlist.xcstrings` like the other user-facing keys. Refs #53
On device the drawing reads as line work — question 1 answered — and question 2 turned out to be the wrong question. There is no right size for a drawing on a table: how far away you sit and how big the table is decide it, so the size belongs to the wearer, not to a constant. The discrete 40/60/80/100cm picker is gone and a pinch sets the size while a twist sets the rotation about the vertical axis. Question 3 is answered too: 10,000 commands play at a usable rate. Three things shaped how this is wired. **The pinch scales the entity, not the attachment.** Rebuilding the attachment at a new point size mid-gesture would re-run the anchor search and make the drawing jump every time it is resized. So one render is made at `builtSide` (1m) and scaled from there — which never softens below it and may above it. Whether the upper end goes visibly soft is the one question this trades for the smoothness, and it wants an eye on device. **A flat sheet needs no 3-D rotation gesture.** Lying down, its normal is already the vertical axis, so a plain 2-D `RotateGesture` *is* the spin being asked for. The orientation composes as spin-about-world-Y × lie-flat. **The sheet has to stop hit-testing for any of this to work.** The gestures target the entity (`InputTargetComponent` + a `CollisionComponent` box in the view's plane, which scales along with it); left hit-testable, the SwiftUI attachment swallows the pinch first and the sheet can never be resized. Committed and in-flight gesture values are kept apart so a cancelled gesture leaves nothing behind, and the size is bounded 15cm–2m because an unbounded pinch reaches a 50m canvas in about a second. The bar reads the current size and angle back, which is the measurement that is left: not what the size should be, but where wearers actually settle. Verified: renders at 60cm in the visionOS 26.5 simulator with the readout agreeing; macOS, iPadOS and visionOS all build; swift-format lint clean. The gestures themselves are device-only — simctl cannot send input. Refs #53
Scaling was right; rotation was not, and moving was missing. **The 2-D `RotateGesture` was the wrong gesture, not the wrong geometry.** The reasoning behind it still holds — a sheet lying flat has its normal straight up, so a twist in the sheet's own plane *is* the vertical-axis spin. But on visionOS a 2-D rotate wants **two hands**, so a one-handed wrist turn did nothing at all, and the two-handed attempts that could register were fighting the magnify for the same input. `RotateGesture3D(constrainedToAxis: .y)` is the one that means what was wanted: turn your wrist, and only the vertical axis counts. It carries a 5° `minimumAngleDelta` so an ordinary drag doesn't spin the sheet on the way past. **Moving is a `DragGesture`, converted into the entity's parent space** — not `.scene`. The sheet hangs off a plane anchor whenever one is found, and visionOS does not hand the app that anchor's transform, so a scene-space delta would be wrong exactly when it matters. `value.entity.parent` is the space `position` is read in, and converting there sidesteps needing to know where the anchor is at all. All three run simultaneously, the way a hand does them: a pinch that drifts and turns should move and spin rather than pick one. Position joins size and spin as state on the model, with the in-flight value kept apart from the committed one, so a cancelled gesture still leaves nothing behind. The fixed and anchored home positions moved into one `home(anchored:)` — `update` now owns the whole transform (position, scale, orientation) and the make closure only builds. Also records question 3's answer: 10,000 commands play fine on device. Verified: renders at 60cm in the visionOS 26.5 simulator; macOS, iPadOS and visionOS all build; swift-format lint clean. The gestures themselves are device-only — simctl cannot send input. Refs #53
…e 0) Two things the device found. **A drag could lift the sheet off the table it was anchored to.** Free 3-D translation is right in the fixed placement — there is no surface there, and height is the only way to put the drawing somewhere sensible — but wrong the moment "平面に置く" is on, which is a promise that the drawing lives on that plane. On a plane anchor the parent's Y *is* the plane's normal, so zeroing that one component of the converted delta slides the sheet along the surface instead of into the air. The constraint follows the toggle. **The bounds turn out to be the answer to the softness.** Enlarging went mildly soft, because the pinch stretches one render rather than rebuilding the attachment (rebuilding mid-drag would re-run the anchor search and make the drawing jump). Rather than bound it enough for the softness not to matter, `builtSide` is now the *top* of the range, so the sheet is only ever scaled **down** — which never softens at all. The range comes in to 0.2–1.2m, which is what makes that affordable: the cost is that the common 60cm view carries a render sized for 1.2m, and 2m would have been four times the pixels for a size nobody puts on a table. Verified: renders at 60cm in the visionOS 26.5 simulator, now downscaled from the 1.2m render; macOS, iPadOS and visionOS all build; swift-format lint clean. Both changes are device-only in behaviour — simctl cannot send input. Refs #53
#53 Phase 0) The previous commit moved `builtSide` to the top of the range on the theory that a sheet only ever scaled *down* would never soften. **On device it made no observable difference** — the drawing is exactly as soft as it was when the same sizes were reached by scaling up. That is worth more than the change was. It says the point size handed to a `ViewAttachmentComponent` is a *layout* size, not a fidelity knob: visionOS decides an attachment's render resolution for itself, and no amount of building bigger will sharpen it. So `builtSide` goes back to a plain 1m, because carrying a render sized for the maximum bought nothing and cost four times the pixels — and if sharpness ever has to be pushed, the lever is a rebuild at the settled size, not this constant. Which also means the range was tightened for no reason, so the top goes back to 2m: a drawing that fills a whole table is a thing people want, and it was judged legible there. Verified: renders at 60cm in the visionOS 26.5 simulator; macOS, iPadOS and visionOS all build; swift-format lint clean. Refs #53
The previous commit read "no observable difference on device" as "the point size is a layout size, not a fidelity knob". That conclusion does not follow from the experiment that produced it: `builtSide` went from 1.0 to 1.2, a **20% change in linear resolution**, which is roughly what "identical" should look like. The measurement was too small to prove anything. Printing the scene's own conversion settles what the parameter is. visionOS lays out at **1m = 1360pt**, so `builtSide` 1.0 is a 1360pt² render, and what that buys depends on how large the sheet is shown: ~2270pt per displayed metre at the 60cm default, ~680 at the 2m top of the range. That 3.3× spread is exactly where the softness at the large end comes from, and it means this *is* the resolution knob — one constant, costing the square to raise, and heading toward texture-size limits somewhere above 2. The value stays at 1.0, because nothing so far says it needs to move; what changes is that the comment now says how to test it properly (a 2× swing, judged at one displayed size) instead of telling the next reader the lever does not exist. The launcher window prints the conversion and the render size, which is what made this legible at all. Verified: macOS, iPadOS and visionOS build; swift-format lint clean. Refs #53
The prototype of the shape #53 describes: no DocumentGroup, no editing, and the drawing on a real table rather than in a window. **The scene tree forks by platform rather than adjusting.** visionOS gets a `WindowGroup` of controls and an `ImmersiveSpace` holding the sheet; iPadOS and macOS keep the DocumentGroup untouched. That fork is the whole point: dropping the DocumentGroup drops both problems #52 could not solve on that platform — no one-press way back to the browser (`dismiss()` only reveals the system's own 「書類」 button, and `openDocument` / `newDocument` are unavailable there) and the editing UI that carried the view-switching residue. A `.fileImporter` opens a drawing in one press because it never had a browser to return to. **The window holds no canvas.** A second flat copy of the drawing would put the same picture in two places and take away the reason the platform has an app at all. So the window is a remote control: pick a drawing, put it down, play it — and the sheet on the table is the only place the picture exists. Four things this needed from the code around it, all small: `PlaybackControls` now takes the **blocks** rather than the `WorkspaceEditor`. It only ever read `workspace.blocks`, and the transport never edited anything, so this is the decoupling that lets the whole transport — scrubber, step, speed, the four-meaning centre button — work in a viewer that has no editor behind it. `BlocksDocument.project(from:)` splits decoding out of `FileDocument`, so the importer and the DocumentGroup share **one** version gate rather than disagreeing about which files are from the future. `LaunchScene` narrows to `#if os(iOS)`. It is a `DocumentGroupLaunchScene`, so it now has nothing to attach to on visionOS — and it never appeared there anyway, which is worth recording: visionOS goes straight to the system document browser. The samples are `SampleBlocks`, the same four the workspace offers, so a Vision Pro with no files on it is still an app rather than an empty window — and it costs no bundled resources and no second set of names. `-TBPlace YES` loads a sample and puts it down at launch. The simulator cannot press anything (simctl sends no input), so it is the only way to see any of this without a headset. Verified in the visionOS 26.5 simulator, in both languages: the window, the transport against an 82-command sample, and the sheet on the table. Kit tests (119) pass; macOS, iPadOS and visionOS all build; swift-format lint clean. The nine new strings are hand-inserted into the catalog as pure additions (90 lines added, 0 removed) so Xcode's own rewrite never runs. Refs #53
On device the window opened at a fixed 560×520 with the controls pinned to the top, leaving a band of empty glass underneath. The fix is to stop choosing a size: `windowResizability(.contentSize)` and no `defaultSize`, so the window is its contents. A remote control should be exactly as tall as its buttons — and this one has two heights, since a drawing brings the transport and the placement row with it, so it shrinks to the "no drawing" state and grows when something is opened. The width stays fixed at 520. The scrubber has no width of its own, so without it the window would size toward the widest label instead. Verified in the visionOS 26.5 simulator in Japanese, in both states; macOS and iPadOS build; swift-format lint clean. Refs #53
…e 2) The window that makes this build *the* app rather than a way to watch a drawing appear. Seeing which block is running while the tortoise moves is the pedagogy of the whole thing, and a headset gets the version an iPad cannot: the program on one surface and the drawing on your actual table, both full size, with nothing to switch between. **It costs almost nothing, and that was the point of Phase 0's finding.** `RunnerModel.currentBlockID` is `expandedBlockIDs[player.currentCommandIndex]`, and the table draws through the very same `CommandPlayer` — because `ViewAttachmentComponent` put the app's own `TortoiseCanvas` into the RealityKit scene rather than replacing it. The index alignment the app rests on never noticed that the drawing moved into an immersive space, so the highlight is one existing parameter (`BlockListView.highlightedID`). **The rows are the editor's own rows**, not a read-only lookalike. Same `BlockListView`, so the same category pastels, C-shaped containers, row heights and label alignment — and, more to the point, one renderer to keep correct. `SimpleBlockLabel` is on the "when adding a block kind" checklist precisely because a kind missed there renders as nothing; a second copy would double that and fail silently. Read-only is enforced twice, and the belt matters more than the braces. The **binding** is what makes it true: `WorkspaceEditor` writes through `document`, and a constant binding discards those writes, so no list of hidden affordances has to stay complete. Hit-testing off then removes the drag sources, drop gaps and chips from a window where they mean nothing. But inert-and-visible was tried first and reads worse than either alternative: on device the ⋯ menu and the container mouths' "add here" toggle still *looked* pressable while doing nothing. Both are now absent, via a new `showsBlockEditing` environment value — an environment value rather than a parameter because the two views that read it sit several layers under the one that knows, and threading a flag through `BlockListView`, `BlockRowView` and `ContainerBlockRow` would put an argument on each for the benefit of two leaves. It defaults to true, so the iPad and Mac editors are untouched by construction. Verified in the visionOS 26.5 simulator in Japanese: the program renders with full fidelity and no editing controls. Kit tests (119) pass; macOS, iPadOS and visionOS all build; lint clean. Two things are **not** verified here — the highlight itself needs playback running, and this machine can send no input to the simulator; and the macOS editor could not be eyeballed, because screen recording is not permitted to this process (the capture comes back black). The argument for macOS is the default value, not an observation. Refs #53
…Phase 2) Two things device testing found. **The program list did not follow the highlight.** It was correct and off screen for everything past the first few rows, which makes the feature work only for programs short enough to fit — not a useful class. A `ScrollViewReader` now scrolls on each change of `currentBlockID`, and the two details are what keep it from being worse than nothing. `scrollTo` is called with **no anchor**: centring the running block would drag the list on nearly every command, so a loop cycling three rows would never sit still, while the default scrolls the least amount that brings the row into view and does nothing while it is already there. And unanimated, because at ten commands a second an animation only ever restarts itself. **The ⋯ menu's items were black-on-black in macOS dark mode.** The cause is `.tint(BlockCategory.ink)` sitting on the `Menu` rather than on its glyph: a tint set on a menu reaches its *popup* too, and this ink is a fixed near-black (#41) on purpose, because the thing it has to be legible against is a pastel block — not a menu background, which in dark mode is dark. The ink moves onto the label, where it was always meant to be, in both places the ⋯ appears (the row's own menu and the else divider's). Pre-existing, not from Phase 2 — the viewer is what put a fresh pair of eyes on that menu. Verified: renders correctly in the visionOS 26.5 simulator; macOS and iPadOS build; lint clean. Neither fix is verified by observation here — the scroll needs playback running and simctl sends no input, and macOS could not be captured (screen recording is not permitted to this process). Both want a look on device. Note the row's *context* menu (long-press / right-click) inherits the same ink through `BlockChrome`'s `foregroundStyle` rather than through a tint, so it may have the same problem by a different route. Left alone deliberately: changing it unobserved risks the light-mode appearance for a fault nobody has reported yet. Refs #53
Phase 2) Both of the previous round's fixes were half-right. **Scrolling was disabled along with everything else.** `allowsHitTesting(false)` sat on the `ScrollView`, which takes the scroll gesture with it — so the window could only ever show the rows it happened to open on, and only auto-scroll could move it. It moves to the **content**: the scroll view stays interactive, the rows inside stay inert, and reading the far end of a program no longer requires playing it. **And the ⋯ glyph went white.** Moving the ink from the `Menu` to the label's `foregroundStyle` fixed the popup and broke the thing it was there for: a `borderless` menu reads its label colour from the **tint**, and ignores a foreground style, so with the tint gone the glyph fell back to the default — white on a pastel block in dark mode, which is the control nobody can see (#41) all over again. So the tint goes back where it works (`blockMenuInk()`, now shared by both the row's menu and the else divider's), and each menu's *content* resets it with `.tint(nil)` instead. That pair is the actual shape of the problem: one fixed dark ink is right against a pastel block and wrong against macOS's dark menu background, and the two are different surfaces that a single tint was being asked to serve. Kit tests (119) pass; macOS, iPadOS and visionOS build; lint clean. As before, neither fix is observable from here — the scroll needs input the simulator cannot receive, and macOS cannot be captured (screen recording is not permitted to this process). Refs #53
`documentBrowserToolbar()` was #11's answer to visionOS having no route from a document back to the picker: iPadOS puts a chevron by the title, macOS has File ▸ Open, and visionOS had neither, so a window carried the drawing it opened with for ever. Phase 1 removed the `DocumentGroup` it hung off, which answers the same question a better way — the viewer's `.fileImporter` opens another drawing without there being a browser to go back to — and left this compiling but unreachable. Its one hard-won finding is not lost; it is written down in `App/Views/CLAUDE.md` (where `dismiss` is read from decides whether it does anything). The unused "Open Another Drawing" string goes with it. macOS, iPadOS and visionOS build; lint clean. Refs #53
Three things device testing asked for, and one shape of answer: **stop letting the system place the sheet.** `AnchorEntity(.plane(...))` is one line and cannot do any of this. It picks which surface, picks where on it, and never says when it succeeded — so "in front of my eyes", "turned to face me" and "tell me you are looking" are all unanswerable through it. `PlaneDetectionProvider` and `WorldTrackingProvider` run directly instead, and the sheet is a plain world-space entity. **Where.** The head pose gives a position and a gaze; the sheet lands 60cm ahead of the eyes at the height of whatever surface was found. Desk reach — near enough to be the thing you are looking at, far enough not to be in your lap — and everything past that is the wearer's to drag. **Which way.** Lying down, the sheet's top edge points along −Z, so the yaw that carries −Z onto the flattened gaze is exactly the one that puts the drawing's *north* away from you. Read from where you stand, the far edge is the top: the way a sheet of paper on a desk is oriented without anyone thinking about it. **The wait.** Plane detection takes ten seconds and more, and the sheet is deliberately not drawn until there is somewhere to put it — so the window now says "つくえを さがしています…" with a spinner, and after fifteen seconds gives up and floats the drawing in front of you instead, saying that too. A wait you understand is a wait; a wait you don't is a bug. Surfaces are chosen by height **against the eyes** — 25cm to 1.4m below — rather than by the classifier, which reports `.undetermined` too often to lean on. That one test excludes the floor and the ceiling on its own. Two traps, both of which look identical from the outside (nothing renders): **A `RealityView`'s `update:` closure is outside Observation.** Properties read only in there never mark the view as needing an update, so the closure runs once and never again — the sheet stayed hidden at wherever the first frame put it, and ARKit deciding where the table was changed nothing. The transform is now gathered in `body`, where reading it registers, and passed into the closure. **`queryDeviceAnchor` answers before tracking starts, with an untracked identity.** Aiming from that puts the sheet 35cm below the origin — under the floor. `isTracked` is not enough on its own either: the simulator reports the identity transform *and* calls it tracked, which reads as a head lying on the floor. So the pose is also sanity-checked for a plausible eye height (0.8m), and polled for up to three seconds until a real one arrives. That second guard is what made this work in the simulator at all. Verified in the visionOS 26.5 simulator: the sheet lands 60cm ahead at 35cm below eye level, and the window shows the floating explanation (there are no planes there to find). Kit tests (119) pass; macOS, iPadOS and visionOS build; lint clean. **The three things actually asked for are device only** — a real table, a real gaze, and the ten-second wait — so they want a look before Phase 3. Refs #53
Two faults, and the second one is why the first mattered. **The height test let the floor through for anyone sitting down.** Surfaces were accepted at 0.25m–1.4m *below the eyes*, which is a desk for someone standing — and a seated wearer's eyes are about 1.2m up, putting the floor 1.2m below them, squarely inside that band. So the rule was excluding the floor only for people who were not at a desk. Height is now tested two ways, each covering the other's blind spot. An immersive space's origin sits on the floor, so an **absolute** minimum (0.35m) excludes it outright — but only while the origin really is down there. **Eye-relative** height does not depend on that, and its band is tightened to 1.1m, which is under a seated wearer's floor and over a standing wearer's desk. Together they leave a desk and take a floor whether you sit or stand. **And it took the first surface that passed, not the best one.** Anchor updates arrive in no useful order, and a floor is large, flat and mapped early, so it won nearly every race it was allowed to enter. Surfaces are now collected (`SurfaceCollector`) and chosen over the whole set: a surface classified `.table` wins outright, and otherwise the one whose centre is nearest the point the wearer is looking at — which is what makes the desk in front beat the counter behind. A non-table candidate is held for two seconds before being accepted, in case the real table is still being mapped; against the ten seconds detection takes anyway, that costs nothing. Classification stays a *preference* and never a filter. It reports `.undetermined` often enough that filtering on it would find nothing in an ordinary room — which is why the original code ignored it, and why the fix is to rank by it rather than to start trusting it. (Also moved off the name deprecated in visionOS 26: `surfaceClassification`.) The gaze target is now computed once and shared, so "which table am I looking at" and "where on it does the drawing go" are the same point rather than two calculations that could drift. Verified in the visionOS 26.5 simulator: the floating path still places the sheet 60cm ahead and explains itself. Kit tests (119) pass; macOS, iPadOS and visionOS build; lint clean, no deprecation warnings. **The choosing itself is device-only** — there are no planes in the simulator to choose between. Refs #53
Phase 3 of the visionOS viewer needs a 3D tortoise to draw with, and the design arrived as a three-view drawing. Every shape in it is a primitive -- a domed shell, an ellipsoid head, four flipper blobs, a beret, a tail ending in a brush -- so the model is generated by a Blender script whose constants are the drawing's own measurements, rather than modelled by hand. That is what lets a proportion be argued about and changed in one place. The asset is checked in beside the script so no build step needs Blender. It rides in App/ as a synchronized-folder resource and lands flat at Contents/Resources/Tortoise.usdz -- verified in the built bundle on macOS, iOS and visionOS, which is the only way that works. Four things app code will assume, with the reasoning in the tool's README: upAxis = "Y" with forward at -Z (authored Z-up, converted by a rotateXYZ on the root prim); total length exactly 1.0 with metersPerUnit = 1, normalised because the canvas is a 0.2-2m gesture and the size is always computed; the origin is the ground point under the shell's centre, the point it turns about, not the brush tip; and the drawing therefore trails behind the animal. Blender rendering the model proves nothing about whether RealityKit can read it, so qlcheck.swift runs it through Apple's own USD stack instead -- qlmanage -t is the obvious alternative and tends to hang, the same trap the thumbnail extension already documents. Nothing consumes the asset yet; the immersive space still draws the 2D sprite.
The sheet is still the app's own TortoiseCanvas in a ViewAttachmentComponent; what changes is that it now draws everything except the tortoise, and the generated USDZ stands on the paper as a child of the sheet entity — so it inherits the pinch, twist and drag for free, and its own transform only ever says where on the page it is. That took TortoiseGraphics2 2.1.0, because none of the three pieces had an honest app-side substitute. .hidden is a property of the view, unlike hideTortoise(), which records a command and would have followed the drawing into the SVG, the PNG, the thumbnail and the saved file. currentTortoiseState is the pose interpolated between commands: currentCommandIndex — what every other surface in the app watches — changes about ten times a second, and a tortoise moved on that schedule teleports from command to command while the line it is drawing grows smoothly underneath it, which is the one thing this feature exists to show. And ViewportMode.transform is public so the placement asks for autoFit's mapping rather than reimplementing it. Read once per display frame from a SceneEvents.Update subscription, not from body, which would re-evaluate the view at the refresh rate. The subscription is retained deliberately: one that nothing holds is cancelled at the end of make and looks exactly like a handler that is never called. RunnerModel gains the last run's DrawingBounds, computed once per run because the placement needs it every frame. The export's frame sizing now reads it too instead of replaying the stream again. Verified as far as the simulator allows — the USDZ loads in the visionOS runtime with the bounds the contract promises, the per-frame tick fires, and a 200x200 drawing maps onto the sheet's corners to four decimal places. The simulator does not host ViewAttachmentComponent views at all, so the look is the headset's to judge.
Table, program, code — a WindowGroup each, all open at once. That is the whole argument for the platform restated one step further: iPad and Mac make the canvas and the code two states of one toggle because a window holds one of them, and a headset never has to choose. The window is CodePane unchanged, which #11 had already made work here by taking it off .background.secondary — translucent glass on this platform, with the syntax colours left standing on nothing. The source is generated in ViewerModel.load rather than in the window's body: the iPad's pane is only in the hierarchy while its toggle says so, but a window redraws on its own schedule and nothing here can edit the program behind it. It shows the empty state with an if where ProgramWindow uses an overlay, because an empty code pane is a sheet of white paper carrying a Copy Code button that would copy an empty string. Export is the same CanvasExportMenu the iPad and Mac toolbar carries, and it cost one view: it renders lastRunCommands, so moving the drawing into an immersive space changed nothing about what comes out. It sits beside Open and Samples rather than with the placement controls, because those two rows answer different questions — what picture is loaded and what you can take away, versus where it lies on the table.
A .mixed immersive space lights the model with the real room, and a lamp-lit evening one drained the pastels to mud: measured against the previous build, luminance 39 of 255, the gold reading brown and the blue-to-pink dome reading muddy teal. Reported from the headset. Every material now emits a third of its own colour, which takes the same render to 111 — a factor of 2.8 — while the facets still step, so it does not flatten into a sticker. Under QuickLook's bright studio lighting the same change is only +12%, which is the point: emission is a floor a dark room cannot take away, not a brightness knob. Lightening the colours was the alternative and is wrong; they are sampled from the three-view drawing, which is the specification. The shell needs a second PNG for it. UsdPreviewSurface has no emissive strength — only emissiveColor — so feeding it the diffuse texture makes the dome emit at full value while every other part emits at a third, and the shell washes out. A multiply node in Blender does not survive the export either: the preview surface writer follows a fixed set of node patterns and drops the rest, silently. The dimming is baked in linear light, checked against the flat materials at three points along the ramp (0.347-0.354 against 0.35). Geometry is untouched: same 2,222 triangles, same bounds, same Y-up / -Z forward contract, and it still parses and renders through Apple's own USD stack.
The policy claimed no access to camera or microphone and said nothing about world sensing, which the viewer now asks for. That line stays true — no camera image is ever handed to the app — but a reader who has just approved a "surroundings" prompt deserves better than a page that does not mention it. A new section 6 says what ARKit hands over (the position, size and height of flat surfaces, and the device's own position), what it does not (any camera image), that none of it is stored or sent and dies with the drawing, and that declining leaves the drawing floating instead. Section 1 points at it, so the camera line cannot be read as a contradiction. Both languages, since each is a full copy of the page and it never hides behind a script. Japanese paragraphs stay one source line each. App Store's questionnaire needs no change: the data is used on device and never leaves it, so "Data Not Collected" remains the accurate answer. Recording that as a decision rather than an oversight.
`-TBPlace YES` arrived through `UserDefaults.standard.bool(forKey:)`, which is where a `-flag value` pair normally lands. But **UserDefaults is one of Apple's required-reason APIs**, so one read of it — debug-only, but compiled into the shipping binary and run on every launch — obliges the whole app to ship a PrivacyInfo.xcprivacy declaring CA92.1. This app has no privacy manifest and needs none. That is worth one line: ProcessInfo.processInfo.arguments takes the same launch command and is not on the list. The release skill said to re-check "if a dependency or an @AppStorage ever arrives", and neither is what happened, so it now says what actually counts: any UserDefaults call, reads included, development-only included. It also notes that NSWorldSensingUsageDescription is not on that list — a usage description is a permission, and changes the policy page rather than the manifest.
It worked and it was cheap — CanvasExportMenu unchanged, rendering lastRunCommands, so the drawing having moved into an immersive space changed nothing about what came out. That is not the question. The question is whether a viewer should write files at all. It cannot change a drawing, so the .tortoise it was handed is already the artifact; a second one written from it belongs where drawings are made, which is the iPad and the Mac. Every other thing this window leaves out follows the same line, and this was the one that did not. It also cost the remote control its shape. Open, Samples and Export made three in the top row while the surfaces row had grown to three of its own, and a window whose whole job is "pick a drawing, put it down, play it" had six buttons before either of those things happened. The reasoning stays in CLAUDE.md so it is not re-added later as an oversight.
21 tasks
The comment said the flag was "the only way to see the immersive space without a headset on". The first half is right — simctl sends no input, so without it a simulator run is a window of buttons nobody can press and the space never opens — but the second half is false, and finding that out cost an afternoon: the simulator hosts no ViewAttachmentComponent view, so the sheet's SwiftUI body never runs, TortoisePlayer never attaches to a canvas, and currentTortoiseState stays nil. Which looks exactly like a tortoise that is broken. It now says what the flag actually buys — that the USDZ loads in the real visionOS runtime with the bounds its contract promises, that the per-frame subscription fires, that load → run → place survives, that the program and code windows draw with content — and that the picture is the headset's to judge. CLAUDE.md described that limitation already but never named the flag, so the note said what the simulator can check without saying how. The invocation now sits with the other manual-verification commands.
The row read 「つくえに おく」「ブロックを みる」「コードを みる」 — one control that placed the drawing and two that opened windows, whose only shared property was being buttons. Placement's own mode switch and reset sat in a different row underneath, with those two wedged in between. Placement is now one group under its own question, the other surfaces are another below a divider. Three things fell out of doing it. The two verbs went. 「つくえに おく」 (put the drawing down) and 「つくえに のせる」 (look for a table at all) were nearly the same words for different things — invisible while they sat in separate rows, unbearable once grouped. But the window only ever has three states, so ViewerModel.placing names them (away / table / in front) and one picker asks. Two controls become one, and neither verb is needed. It stays read-only and ViewerWindow drives it through an async action, because isPlaced is only true once the space has actually opened, and a refused world-sensing prompt has to leave the picker showing where the drawing really is rather than where it was asked to be. The window buttons became toggles. openWindow on a window that is already up only brings it forward, which made them switches with one position — the way back was the window's own close button, somewhere else entirely. SwiftUI has nothing to read window state from, so the windows report it themselves. And floating stopped being an error. It can now be chosen, so telling someone "no table found" when they asked for the air is reporting a failure that did not happen; PlacementStatus says that only when a table was actually asked for, and says nothing at all when the drawing is away. The reset is inline, icon-only and last: on its own titled row it read louder than the question above it, and it is the rarest control here. Judged in the running app, which is also where the floating wording gave itself away.
A visionOS ornament is the platform's own place for "belongs to this window but is not its content", so the blocks and code buttons are exactly what it is for, and someone will propose it again. It was weighed against the divider and lost: an ornament is always visible, so it hangs under the window even in the small "えが ありません" state and adds its height to every glance, while a divider says the same thing for nothing. Untried rather than tested — worth saying plainly, so the note is a decision and not a measurement it cannot support.
Choosing a file changed nothing but the window: the room stayed empty until the placement picker was touched, so the app read as one that had not opened the file at all. The state with the least to look at was the one reached by doing the thing the viewer is for. An alert asking "shall I put it on the table?" was the obvious fix and is the wrong one twice over — the answer is always yes, and the first placement already raises the world-sensing prompt, so it would be two modals in a row before anything appeared. So a load *is* a placement, and it stays an ordinary one: the picker moves to wherever the load put it, 「ださない」 takes it away, and a second file opened while one is already out leaves the sheet where it was dragged to, because `place` sees the drawing is already there and returns. `loadGeneration` is a counter rather than a flag, for the same reason `RunnerModel.runGeneration` is one: `blocks` cannot say "chosen again" when the same drawing is picked twice, and it is the choosing that means "show me this". It also keeps the five call sites — the importer and the four samples — from each having to remember. Where it goes is `sitsOnTable`, which is therefore a *remembered* preference now, not only the space's own question: someone who has once said 「めのまえ」 is not asked again on the next file. `-TBPlace` sets that preference instead of placing by hand, which is also the honest way round now that loading is what places. In front rather than on a table because the simulator finds no planes at all, and a table search there only spends its fifteen seconds before falling back to exactly this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#53 の Phase 0〜3 すべて。あわせて #11 のプラットフォーム基盤(
origin/mainにはまだ入っていません)も運びます。27 コミット。すべて実機確認済み。マージ前の判断は、もう残っていません
issue #53 の「注意」に挙がっていた 2 件は両方とも片付きました。
8bfac99、en / ja 両方)pages.ymlがworkflow_dispatchのみになったので、マージしてもサイトはデプロイされませんこのブランチは
site/index.htmlとストア文言に Apple Vision Pro 対応を書いていますが、それが公開されるのは Actions タブから手動実行したときだけです。issue が言っていた「出荷するまで公開しないこと」は、判断ではなく仕組みで担保されるようになりました。なお
.claude/skills/release/SKILL.mdは #55 とこのブランチの両方が触っていますが、別々の段落なので自動マージで両方残ります(マージ結果を実際に作って確認済み・競合マーカーなし)。なぜ作り直したのか
#11 で「iPad アプリ相当が動く」ところまでは作れました。動くには動きますが、それが visionOS である理由がどこにもありません — 同じことは Apple の iPad 互換モードが最初からやっています。しかも退避した残課題(メインウィンドウからファイルを選び直せない、表示切り替えで前の View が背景に残る)はどちらも
DocumentGroupと編集 UI に由来していました。回避策を探し続けるより、その機能を持たない設計にする方が早い。一方で visionOS でしかできないことが一つあります。**タートルグラフィックスのタートルは、元々は床を走って紙にペンで描く実物のロボットでした。**画面の中の三角形の方が後から出てきた代用品で、机の上を 🐢 が這って線を引くのは 3D 化のこじつけではなく原点回帰です。
構成
3 面が同時に開くことが、この版の存在理由です。 iPad と Mac がキャンバスとコードを 1 つのトグルの 2 状態にしているのは窓が 1 つしかないからで、ヘッドセットは選ばなくていい。機能を削った版ではなく、iPad 版より良い版になる部分です。
DocumentGroupは使わず、素のWindowGroup+.fileImporter。1 タップで開き直せるので #52 の残課題がそのまま消えます。編集 UI が無いので、ドキュメントが汚れることもありません。SVG / PNG 書き出しは作ったうえで外しました(
3e917f6)。動いたし安くもありました —CanvasExportMenuをそのまま、lastRunCommandsを描くので、絵がイマーシブ空間に移ったことは出力に何の影響もありません。外した理由は、ビューワーが絵を変えられない以上、渡された.tortoiseがすでに成果物であって、そこから 2 つ目を書き出すのは絵を作る場所(iPad と Mac)の仕事だからです。この窓が他に落としているものと同じ線で、これだけが線の外に出ていました。リモコンの形も戻ります — 上段 3 つ・面の段 3 つで、「絵を選び、置き、再生する」だけの窓がボタン 6 個になっていました。レンダラは (a) でも (b) でもなかった
issue はテクスチャ方式とメッシュ方式の二択で見積もっていましたが、
ViewAttachmentComponent(visionOS 26)がどちらのコストも払わない第三の答えでした。SwiftUI のビューをそのまま RealityKit のシーンに置けるので、机の上にあるのは iPad と Mac が描くのと同じTortoiseCanvas、同じCommandPlayerです。コマンドストリームには何も起きないので、ハイライトの index 対応がそのまま生きます。10,000 コマンドでも性能は問題なし。サイズは定数ではなくジェスチャー(0.2〜2m)にしました。ピンチ・ひねり・ドラッグが同時に効きます。
RotateGesture3D(constrainedToAxis: .y)なのは、2D のRotateGestureが visionOS では両手を要求して片手の手首ひねりに反応しないためです。3D の 🐢 — upstream が要りました
紙は今まで通り
TortoiseCanvasが描き、そこからタートルだけを外して USDZ を紙の子エンティティとして立てています。子なのでピンチ・ひねり・ドラッグを継承し、自前の transform は「紙のどこにいるか」しか言いません。これには TortoiseGraphics2 2.1.0(#46 / #47)が必要でした。3 つとも、アプリ側で誠実に代用できるものがありません。
TortoiseSprite.hiddenはビューの指定。hideTortoise()はコマンドなので、SVG・PNG・サムネイル・保存ファイルまで付いて回ってしまうTortoisePlayer.currentTortoiseStateはコマンド間を補間した姿勢。currentCommandIndex(アプリの他の面が見ている値)は毎秒 10 回しか動かず、それで動かすと線はなめらかに伸びるのにタートルだけがワープする。この機能が見せたいのは、まさにそこViewportMode.transformの公開で、autoFitの写像を書き直さずに済む。書き直すと「書いた日は合っていて、あとから黙ってずれる」毎表示フレーム
SceneEvents.Updateから読んでいます(bodyから observe するとビューがリフレッシュレートで再評価される)。購読は意図的に保持 — 誰も持たない購読はmakeを抜けた時点で解除され、「ハンドラが一度も呼ばれない」のと見分けがつきません。モデルは生成物です(
Tools/tortoise-model/、三面図の実測値がそのまま定数)。大きさは紙の 1/12 で、2D スプライトの ~1/30 より意図的に大きい — 画面ではカーソル、机の上では動物なので。紙に立てる持ち上げ量はモデルから実測しています(足が原点より 6‰ 下がる)。部屋に色を吸われる問題
実機で報告のあった「パステルがくすむ」は、
.mixedが現実の部屋の光でモデルを照らすためでした。夕方の照明の部屋を再現して測ると輝度 39/255(金が茶に、甲羅が濁った青緑に)。全マテリアルに自分の色の 1/3 を発光させて 111(2.8 倍)、それでいてファセットの段は残ります。QuickLook の明るいスタジオ照明下では同じ変更が +12% にしかならず、そこが要点です — 発光は「明るさのつまみ」ではなく、暗い部屋でも奪われない下駄。色そのものは三面図からのサンプリング=仕様なので動かしていません。甲羅だけは発光用に 2 枚目のランプ画像を焼いています。
UsdPreviewSurfaceに emissive strength が無くemissiveColorしかないため、diffuse のテクスチャを直結すると甲羅だけ全開で発光して白飛びします。Blender の乗算ノードは書き出しで黙って落ちます(preview surface writer は決まったノードパターンしか追わない)。減光はリニアで計算し、ランプ上 3 点で他マテリアルとの比を検証済み(0.347〜0.354 対 0.35)。検証について
シミュレータは
ViewAttachmentComponentの view をそもそもホストしません。 紙の.taskが走らないのでTortoisePlayerが canvas に繋がらず、currentTortoiseStateが nil のまま — 壊れたタートルと見分けがつきません。なのでここは実機専用です。シミュレータで確かめられたのは、推測になっていたはずの 2 点だけ: USDZ が visionOS ランタイムで実際にロードされ bounds が契約通りであること、毎フレームの購読が発火していること。加えて座標写像を単体で検算し、200×200 の描画が紙の内側の四隅に小数 4 桁まで一致することを確認しています。
CI が visionOS をビルドするのは、効かなくなった
#ifを検出する手段が他にないからです(#if os(iOS)は visionOS で静かに外れるのにコンパイルは通る)。既存プラットフォームへの影響
ほぼありません。共有コードで動いたのは 2 箇所だけです。
RunnerModelが最後の run のDrawingBoundsを持つようになりました。配置が毎フレーム要るためですが、書き出しのフレーム計算もこれを読むようになったので、そちらはストリームの再生が 1 回減っていますCanvasPane.spriteをprivateに戻しました(visionOS 版は .tortoise のビューワーにする(現実の平面をキャンバスに、🐢 を走らせる) #53 のスパイクのために開けていたもの。スパイクは削除済み)ViewerWindowが起動フラグをUserDefaultsではなくProcessInfo.processInfo.argumentsから読むようになりました(8241363)。UserDefaultsは Apple の required reason API なので、デバッグ用の 1 回の読み取りでも、アプリ全体にPrivacyInfo.xcprivacy(CA92.1の宣言)を要求します。このアプリはマニフェストを持たず、持つ必要もない — 1 行で保てる性質なので保ちました。起動コマンドは変わりません。release skill の「再確認すべき条件」も、実際に起きたこと(依存でも@AppStorageでもなく、素のUserDefaults読み取り)に合わせて直しています。プライバシーポリシーには第 6 節を足しました(
8bfac99、en / ja 両方)。ARKit から渡るもの(平らな面の位置・大きさ・高さと端末自身の位置)、渡らないもの(カメラ映像)、保存も送信もせず作品を片付けた時点で失われること、許可しなくても使えること。第 1 節の「カメラへのアクセスなし」が許可ダイアログと矛盾して見えないよう、そこから参照も張っています。App Store の質問票は端末内で完結するので "Data Not Collected" のままで正しく、それは見落としではなく判断として記録しました。Kit 119 テスト・lint・macOS / iPadOS / visionOS の 3 ビルド、すべて緑です。
コミットの並び
Phase ごとに追えるようにしてあります。
1459028〜e000deecdbe44684952f1〜a8c976d160b335〜7fcf4a4b856bf0〜0ed4d91a2cfbe8〜c2380962d371b5〜44003698bfac99〜3e917f6