Skip to content

visionOS 版を .tortoise のビューワーにする #53 - #54

Merged
temoki merged 34 commits into
mainfrom
visionos-viewer
Aug 19, 2026
Merged

visionOS 版を .tortoise のビューワーにする #53#54
temoki merged 34 commits into
mainfrom
visionos-viewer

Conversation

@temoki

@temoki temoki commented Aug 17, 2026

Copy link
Copy Markdown
Owner

#53 の Phase 0〜3 すべて。あわせて #11 のプラットフォーム基盤(origin/main にはまだ入っていません)も運びます。27 コミット。すべて実機確認済み。

マージ前の判断は、もう残っていません

issue #53 の「注意」に挙がっていた 2 件は両方とも片付きました。

  • プライバシーポリシー — 第 6 節を追加済み(8bfac99、en / ja 両方)
  • 公開のタイミングサイトの公開を手動実行のみにする #55pages.ymlworkflow_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 化のこじつけではなく原点回帰です。

構成

中身
ImmersiveSpace(mixed) 現実の平面に貼りついた紙。その上を 3D の 🐢 が歩いて描く
ウィンドウ 1 リモコン(読み込み・再生・置き場所)
ウィンドウ 2 ブロック(読み取り専用)、再生位置に追従してハイライト
ウィンドウ 3 生成された Swift コード

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 箇所だけです。

ViewerWindow が起動フラグを UserDefaults ではなく ProcessInfo.processInfo.arguments から読むようになりました(8241363)。UserDefaults は Apple の required reason API なので、デバッグ用の 1 回の読み取りでも、アプリ全体に PrivacyInfo.xcprivacyCA92.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 ごとに追えるようにしてあります。

1459028e000dee プラットフォーム基盤(#11。署名スコープの修正を含む)
cdbe446 ストア文言・LP(上の ⚠️ 1 番
84952f1a8c976d Phase 0 — 実機スパイク。ここで (a)/(b) の二択が消えた
160b3357fcf4a4 Phase 1 — ビューワーの骨格
b856bf00ed4d91 Phase 2 — ブロックの窓
a2cfbe8c238096 配置のやりなおし(床を選んでしまう問題)
2d371b54400369 Phase 3 — 3D の 🐢 とコードの窓
8bfac993e917f6 プライバシーポリシー、required reason API、書き出しの撤回

temoki added 30 commits August 12, 2026 17:57
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.
temoki added 4 commits August 18, 2026 08:58
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.
@temoki
temoki merged commit ca3b1c3 into main Aug 19, 2026
7 checks passed
@temoki
temoki deleted the visionos-viewer branch August 19, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant