From 1459028c76123696e8ee9e87b8e27f52d2fc9762 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 12 Aug 2026 17:57:53 +0900 Subject: [PATCH 01/33] Build and run on visionOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 7 +++ App/TortoiseBlocksApp.swift | 2 +- App/Views/ContentView.swift | 60 +++++++++++++++++------- App/Views/LaunchScene.swift | 7 +-- App/Views/PlatformModifiers.swift | 33 ++++++++++--- CLAUDE.md | 26 +++++++++- README.md | 15 +++--- TortoiseBlocks.xcodeproj/project.pbxproj | 24 ++++++---- 8 files changed, 129 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ba4a9..b168396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,13 @@ jobs: destination: generic/platform=iOS Simulator - name: macOS destination: platform=macOS + # visionOS (#11). It shares every source file with iPadOS, so what + # this catches is the `#if` that quietly stops applying: SwiftUI + # marks a handful of iOS API unavailable there (`ToolbarSpacer`), + # and an `#if os(iOS)` compiles clean on all three platforms while + # doing nothing on this one. + - name: visionOS + destination: generic/platform=visionOS Simulator steps: - uses: actions/checkout@v7 diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index 012d593..c76a310 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -22,7 +22,7 @@ struct TortoiseBlocksApp: App { // unavailable on macOS and SwiftUI has no empty `Scene` to return in // its place, so this `#if` can't hide inside a modifier the way the // ones in `PlatformModifiers` do. - #if os(iOS) + #if !os(macOS) LaunchScene() #endif } diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 1fcfc8b..7d3769d 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -27,12 +27,13 @@ struct ContentView: View { } } -/// The one layout, on both platforms (#29). The app is iPad and Mac only, and -/// compact width — an iPhone, or an iPad window squeezed into Slide Over — is -/// no longer a design target: three panes' worth of information (palette, -/// program, canvas) folded into one 390pt column never came out usable. A -/// window narrow enough to go compact gets `NavigationSplitView`'s own -/// collapse, not a layout of ours. +/// The one layout, on every platform (#29) — iPad, Mac, and the visionOS +/// window, which is a regular-width scene and needs nothing of its own (#11). +/// Compact width — an iPhone, or an iPad window squeezed into Slide Over — is +/// not a design target: three panes' worth of information (palette, program, +/// canvas) folded into one 390pt column never came out usable. A window narrow +/// enough to go compact gets `NavigationSplitView`'s own collapse, not a layout +/// of ours. struct RootView: View { let workspace: WorkspaceEditor let runner: RunnerModel @@ -150,18 +151,8 @@ struct CanvasPane: View { // toolbar nor `navigationBarBackButtonHidden` touches it. .toolbar(removing: .title) .toolbar { - ToolbarSpacer(.flexible, placement: .primaryAction) - - ToolbarItemGroup(placement: .primaryAction) { - CanvasViewToggle(showsCode: $showsCode) - } - - ToolbarSpacer(.fixed, placement: .primaryAction) - - ToolbarItemGroup(placement: .primaryAction) { - CanvasRollAgainButton(workspace: workspace, runner: runner) - CanvasExportMenu(runner: runner, onExport: export) - } + CanvasToolbar( + workspace: workspace, runner: runner, showsCode: $showsCode, onExport: export) } // One alert, switching on why the run failed (`expansionAlert`): // attaching a second one for the recursion case would silently drop @@ -197,6 +188,39 @@ struct CanvasPane: View { } } +/// `CanvasPane`'s toolbar: the canvas/code toggle, then ⟳ and the export menu. +/// +/// It is a `ToolbarContent` type of its own only so the `#if` below has +/// somewhere to live that isn't the call site — `ToolbarSpacer` is the Liquid +/// Glass grouping separator and is unavailable on visionOS, which lays its +/// toolbar out as an ornament and spaces the groups itself. The items and +/// their order are the same everywhere; only the separators come and go. +struct CanvasToolbar: ToolbarContent { + let workspace: WorkspaceEditor + let runner: RunnerModel + @Binding var showsCode: Bool + let onExport: (Data?, UTType) -> Void + + var body: some ToolbarContent { + #if !os(visionOS) + ToolbarSpacer(.flexible, placement: .primaryAction) + #endif + + ToolbarItemGroup(placement: .primaryAction) { + CanvasViewToggle(showsCode: $showsCode) + } + + #if !os(visionOS) + ToolbarSpacer(.fixed, placement: .primaryAction) + #endif + + ToolbarItemGroup(placement: .primaryAction) { + CanvasRollAgainButton(workspace: workspace, runner: runner) + CanvasExportMenu(runner: runner, onExport: onExport) + } + } +} + /// The canvas/code segmented toggle, in `CanvasPane`'s toolbar (#23). struct CanvasViewToggle: View { @Binding var showsCode: Bool diff --git a/App/Views/LaunchScene.swift b/App/Views/LaunchScene.swift index 6031154..a7dcc0e 100644 --- a/App/Views/LaunchScene.swift +++ b/App/Views/LaunchScene.swift @@ -1,11 +1,12 @@ -#if os(iOS) +#if !os(macOS) import SwiftUI /// The screen in front of the system document browser (#32). /// - /// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)`, so this - /// whole file is iOS-only and the Mac keeps the standard open panel. + /// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)` and + /// nothing else, so this file covers iPadOS and visionOS while the Mac + /// keeps the standard open panel. struct LaunchScene: Scene { var body: some Scene { DocumentGroupLaunchScene( diff --git a/App/Views/PlatformModifiers.swift b/App/Views/PlatformModifiers.swift index 7bd8fe1..724f4a5 100644 --- a/App/Views/PlatformModifiers.swift +++ b/App/Views/PlatformModifiers.swift @@ -2,13 +2,20 @@ import SwiftUI // Small cross-cutting modifiers that hide their `#if os(...)` inside a // modifier (the SwiftUI-way rule), so call sites stay platform-agnostic. +// +// They are written `#if !os(macOS)` rather than naming iOS wherever the +// behaviour is simply "the touch platforms": these are UIKit-backed and exist +// on visionOS as well as iPadOS (#11), and spelling the condition as "not the +// Mac" is what keeps a new platform from silently taking the no-op branch the +// way visionOS did — an `#if os(iOS)` compiles clean everywhere and just stops +// applying. `pointerHover` is the exception, and says why. extension View { - /// The number pad for numeric entry on iOS (#24); a no-op elsewhere. - /// `.decimalPad` gives the digits and decimal point kids need; the - /// number blocks don't take negative literals, so its lack of a minus - /// key is intentional. + /// The number pad for numeric entry (#24); a no-op on macOS, which has a + /// hardware keyboard. `.decimalPad` gives the digits and decimal point kids + /// need; the number blocks don't take negative literals, so its lack of a + /// minus key is intentional. func numericKeyboard() -> some View { - #if os(iOS) + #if !os(macOS) keyboardType(.decimalPad) #else self @@ -17,6 +24,16 @@ extension View { /// The iPad (pointer) hover highlight (#24); a no-op on macOS, which has /// its own cursor affordances. + /// + /// Deliberately *not* extended to visionOS, where gaze feedback would seem + /// to be exactly what this is for: `hoverEffect` on a palette entry crashes + /// the app there. Launching straight into the workspace segfaults in + /// `PaletteEntryButton.body` — a `swift_release` inside SwiftUI's own + /// update, not our code — before a window is ever shown, and it does so + /// with `.automatic` as well as `.highlight`, so it is the modifier and not + /// the effect. (visionOS 26.5 / 27.0 simulators, Xcode 26.6.) Buttons get + /// the system's own hover treatment there in any case; this is only the + /// extra highlight iPadOS needs, so the platform loses nothing visible. func pointerHover() -> some View { #if os(iOS) hoverEffect(.highlight) @@ -25,7 +42,9 @@ extension View { #endif } - /// Holds an icon-only control to the 44pt finger minimum on iPadOS. + /// Holds an icon-only control to the 44pt finger minimum on iPadOS (and to + /// the same floor on visionOS, where the target is a gaze rather than a + /// finger — 44 is the iPad number, not a measured visionOS one). /// A borderless SF Symbol button is only as tappable as the glyph is big — /// around 24pt at body size — so the ⋯ on a block row is a small target on /// a touch screen even though the row around it is not. @@ -44,7 +63,7 @@ extension View { /// this is used sits after a `Spacer`, so the extra width takes slack /// instead of pushing the label. func touchTarget() -> some View { - #if os(iOS) + #if !os(macOS) frame(minWidth: 44, minHeight: 44).contentShape(.rect) #else self diff --git a/CLAUDE.md b/CLAUDE.md index f395117..cd1d2b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,7 +303,8 @@ And a macOS QuickLook extension has to be sandboxed to be loaded, so it carries even though the app itself has none. `pluginkit -mAvvv | grep -i tortoise` confirms registration — the `-p com.apple.quicklook.thumbnail` filter does not match it and will make a working extension look missing. -`TARGETED_DEVICE_FAMILY` is `"2"` — iPad and Mac, no iPhone (#29). +`TARGETED_DEVICE_FAMILY` is `"2,7"` — iPad, Mac and Vision Pro, no iPhone +(#29, #11). Documents are `.tortoise` files, but the exported UTI keeps the `tortoiseblocks` spelling (`space.hiraku.tortoiseblocks.project`, and `.block` for the drag payload), which is also the bundle ID's — the @@ -370,6 +371,29 @@ rules apply. The app takes `files.user-selected.read-write` for the `read-only` and is sandboxed for a different reason (a macOS QuickLook extension is not loaded otherwise). +**visionOS runs the iPad app, not a port** (#11). `SUPPORTED_PLATFORMS` gains +`xros xrsimulator`, `XROS_DEPLOYMENT_TARGET` is 26.0, and the same three-pane +`NavigationSplitView` fills the window — the scene is regular width, so nothing +in the layout is platform-conditional and no ornament or volumetric anything is +declared. Both packages already shipped `.visionOS(.v26)`. What the platform +actually costs is the `#if`s, in both directions. **A guard written +`#if os(iOS)` stops applying** — it still compiles everywhere, so `LaunchScene` +(`DocumentGroupLaunchScene` is unavailable on *macOS* only) and the numeric +keyboard and touch targets in `PlatformModifiers` were silently dropped until +they were rewritten as `#if !os(macOS)`. And some UIKit-era API is genuinely +gone: `ToolbarSpacer` — the Liquid Glass grouping separator — is unavailable, +which is the only reason `CanvasToolbar` exists as a `ToolbarContent` type of +its own. That is also why CI builds visionOS: nothing else catches an `#if` +that quietly does nothing. +Two things are known-missing rather than done. **`hoverEffect` crashes** +there — `.automatic` as well as `.highlight`, a `swift_release` segfault inside +SwiftUI's update of `PaletteEntryButton.body`, before a window appears — so +`pointerHover()` stays iOS-only and says so. And **there is no app icon**: +visionOS wants a circular layered icon, Icon Composer only knows squares (plus +watchOS circles), so `AppIcon.icon` produces nothing for it and the system +placeholder is what shows on the Home View. That needs artwork and an +`AppIcon.solidimagestack`, and it is what stands between this and shipping. + **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. **Localization**: `en` is the source language; Japanese (kid-friendly diff --git a/README.md b/README.md index da35bb0..efdafda 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Swift](https://img.shields.io/badge/Swift-6.2-orange.svg)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-iPadOS%2026%2B%20%7C%20macOS%2026%2B-lightgrey.svg)]() +[![Platform](https://img.shields.io/badge/platform-iPadOS%2026%2B%20%7C%20macOS%2026%2B%20%7C%20visionOS%2026%2B-lightgrey.svg)]() A visual programming app for kids — snap blocks together, press play, and watch the tortoise draw. Powered by @@ -56,7 +56,8 @@ graphics engine written in Swift. ## Requirements - **Xcode** 26+ (Swift 6.2) -- **Platforms** iPadOS 26+ · macOS 26+ (visionOS planned) +- **Platforms** iPadOS 26+ · macOS 26+ · visionOS 26+ (the same three-pane app + in a window; not on the App Store yet) ## Getting Started @@ -171,11 +172,11 @@ arithmetic saturates the same way, so a value can never run off to infinity. ## Releasing A `v*` tag is the release. Pushing one starts an Xcode Cloud workflow that -archives both platforms and sends them to TestFlight, while GitHub Actions -checks that the tag matches `MARKETING_VERSION` in every configuration — the -two are otherwise unconnected, and a mismatch would ship the wrong version -silently. The same tag drafts a GitHub release, with notes split by whether a -commit reached the app or only the site, the listing, CI or the docs. +archives the iPadOS and macOS apps and sends them to TestFlight, while GitHub +Actions checks that the tag matches `MARKETING_VERSION` in every configuration +— the two are otherwise unconnected, and a mismatch would ship the wrong +version silently. The same tag drafts a GitHub release, with notes split by +whether a commit reached the app or only the site, the listing, CI or the docs. The store listing is not part of that. It lives in [appstore/](appstore/) and goes up on demand, by hand: diff --git a/TortoiseBlocks.xcodeproj/project.pbxproj b/TortoiseBlocks.xcodeproj/project.pbxproj index ae216f2..8f4e594 100644 --- a/TortoiseBlocks.xcodeproj/project.pbxproj +++ b/TortoiseBlocks.xcodeproj/project.pbxproj @@ -394,6 +394,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xros*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xrsimulator*]" = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -402,9 +404,10 @@ PRODUCT_BUNDLE_IDENTIFIER = space.hiraku.tortoiseblocks; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -429,6 +432,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xros*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xrsimulator*]" = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -437,9 +442,10 @@ PRODUCT_BUNDLE_IDENTIFIER = space.hiraku.tortoiseblocks; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -470,9 +476,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -501,9 +508,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; From 9f49526f4f00db67f8561c7abf1d477380366063 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 12 Aug 2026 17:55:47 +0900 Subject: [PATCH 02/33] Open the workspace column at 440pt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- App/Views/CLAUDE.md | 22 ++++++++++++++++++---- App/Views/ContentView.swift | 15 ++++++++++++++- CLAUDE.md | 5 +++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/App/Views/CLAUDE.md b/App/Views/CLAUDE.md index a24ea7c..02f3066 100644 --- a/App/Views/CLAUDE.md +++ b/App/Views/CLAUDE.md @@ -123,10 +123,24 @@ damage rather than fixing it: with the label served first, `0.6` in the next row broke as "0." over "6", which is worse — a chip holds a number, a name or a colour, all atomic. So `WorkspaceChipButtonStyle` pins its label with `.fixedSize(horizontal: true, vertical: false)`. Priority rather than -`fixedSize` on the *label*, deliberately: a row two levels deep with a long name -(「はこにかける」) genuinely runs out of room, and there the label should still -wrap instead of overflowing its block. Each level of nesting costs 18pt, so no -column width wins that race — the wrap is accepted there. +`fixedSize` on the *label*, deliberately: a row deep enough with a long name +genuinely runs out of room, and there the label should still wrap instead of +overflowing its block. Each level of nesting costs 18pt, so no column width wins +that race for ever — the wrap is the correct last resort, not the working state. + +**And `ideal` is not a starting point on every platform.** macOS and iPadOS 26 +both let the split view's divider be dragged, so there the ideal is only where +the workspace column *opens*. **visionOS has no draggable divider**, so there +the ideal is the width, for good, and the *detail* column absorbs every extra +point the window has. On a 1280pt visionOS window that used to read 360 here +and ~690 on the canvas (#11) — with 「くりかえす 10 かい」 wrapping か/い at the +**top** level, no nesting involved, and 「はこにかける」 splitting in the middle +three levels down. The ideal is now 440, measured against exactly that program: +every row fits on one line at three levels, and the canvas still clears its own +420 ideal. `max` (560) has to stay above `ideal`, or the two platforms that can +drag could only ever drag narrower. Judge a change to it on a *nested Japanese* +program — English fits where 「はこにかける」 does not, and the top-level wrap is +invisible in a flat one. **Drop model**: a `DropGap` between rows carries `(BodyAddress, index)`, so insertion semantics need no y-coordinate math and every mouth — an if's else diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 7d3769d..86d8f4e 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -57,8 +57,21 @@ struct RootView: View { PaletteView(workspace: workspace) .navigationSplitViewColumnWidth(paletteWidth) } content: { + // 440pt is measured, not chosen, and `ideal` carries more weight + // than it looks. macOS and iPadOS both let the divider be dragged, + // so there the ideal is only where the column *opens*; on visionOS + // it can't be, so the ideal is the width, permanently, while the + // detail column absorbs every extra point — a 1280pt visionOS + // window put 360 here and ~690 on the canvas (#11). + // At 360 a three-deep program broke its labels onto two lines, and + // not only at depth: 「くりかえす 10 かい」 wrapped か/い at the top + // level, and 「はこにかける」 split in the middle. 440 fits every one + // of them on one line at three levels of nesting (each level costs + // 18pt), and still leaves the canvas ~600pt — well over its own 420 + // ideal. `max` has to stay above `ideal`, or the two platforms that + // can drag could only ever drag narrower. WorkspaceView(workspace: workspace, runner: runner) - .navigationSplitViewColumnWidth(min: 300, ideal: 360, max: 440) + .navigationSplitViewColumnWidth(min: 300, ideal: 440, max: 560) } detail: { // 280pt keeps the canvas usable (#23) — narrower and its own // playback row starts contesting space with the drawing. diff --git a/CLAUDE.md b/CLAUDE.md index cd1d2b6..c8e9b1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,6 +385,11 @@ gone: `ToolbarSpacer` — the Liquid Glass grouping separator — is unavailable which is the only reason `CanvasToolbar` exists as a `ToolbarContent` type of its own. That is also why CI builds visionOS: nothing else catches an `#if` that quietly does nothing. +One layout constant *is* load-bearing here, though the layout itself isn't +conditional: visionOS has **no draggable split-view divider** — macOS and +iPadOS 26 both do — so the workspace column is stuck at its `ideal` for good +while the canvas takes the rest of a wide window. That is what set the ideal at +440; the measurement is in `App/Views/CLAUDE.md`. Two things are known-missing rather than done. **`hoverEffect` crashes** there — `.automatic` as well as `.highlight`, a `swift_release` segfault inside SwiftUI's update of `PaletteEntryButton.body`, before a window appears — so From 670be04a118abdc2a63d917b39c2153875180734 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 12 Aug 2026 18:06:07 +0900 Subject: [PATCH 03/33] Give visionOS an app icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Content.imageset/AppIcon_Back.png | Bin 0 -> 85073 bytes .../Content.imageset/Contents.json | 13 ++++++++++ .../Back.solidimagestacklayer/Contents.json | 6 +++++ .../AppIcon.solidimagestack/Contents.json | 17 +++++++++++++ .../Content.imageset/AppIcon_Front.png | Bin 0 -> 150185 bytes .../Content.imageset/Contents.json | 13 ++++++++++ .../Front.solidimagestacklayer/Contents.json | 6 +++++ .../Content.imageset/AppIcon_Middle.png | Bin 0 -> 73526 bytes .../Content.imageset/Contents.json | 13 ++++++++++ .../Middle.solidimagestacklayer/Contents.json | 6 +++++ CLAUDE.md | 24 +++++++++++++----- 11 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/AppIcon_Back.png create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/AppIcon_Front.png create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/AppIcon_Middle.png create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json create mode 100644 App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/AppIcon_Back.png b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/AppIcon_Back.png new file mode 100644 index 0000000000000000000000000000000000000000..ad0bfeccb7f8819f717ae7dd1170803fc0188436 GIT binary patch literal 85073 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⪻fMM1x_D+uLa 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 spEz9Mhx1Kwk%M(O%oD4!ut5EVLToo>hzd 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

OD>s3DD zt}c)t(f5@3OfCOlBjMtT_h0rj3j&~fq9jbEFR@{QfGZZ4c)|Jgu2{)4X`@drGkb{? z@hTJPkYyWiKfW2-ZT|=#G9z_xH!7*x#A;#MKnq%9>WelpGj@D2f$iYJF(=X>VKwD+ z`J(`Z&GN~Y zBjQ&A+(2}wD#?7995Q@E28cQ_xAK?mTQJrZDxT21lhWXz^NU!aO@N@fyR(MavUBOB z6d@hD25hQWb7x$dH!6E2)!K7vm*%$p`{)qM7OK3vZ@y#a@qCRLVl6{L-_P1O**{Xl z|M1ELyVuf4DWl`ehpUNTo}!yIe!|v?X5Cp#SP6%g%B|`S{7YgWCt|s^;%UJz`n_S~ zd32r*Xj$TbB!pKRyo13fLdC~hLR9W5a?%rm&CD?9%8**9$Z3V+Qoo+)k<-4J;&idR zw10_QK9Ox*rzlUA4I8P=dH%-A_6% zOKOA~_mJk+EQADNJp1q}Sv0Z>g-(ApgS=@tpJUV5O(;@j>?P5v)4^-2cqPPH=+H$n z=C9x?WfXfBWNk+yD||;8b6~qgZ57L3m8~RH09`>epIdp>IvyZ|Py5rr??3sE4~6k4 zfg4%^W0;z(8z2bcwK@o_nU^}zXCv)@%}zp==D54z&kWfCm@-r1HAT7shd^}juq6#Z zcE~(;_sz_ik&^yCs?*hA5M^uk+gD7kqUx_WFA;qo4~sK+za#~0y|XQ=0JEoDLv23C z_mK?yvZk=2j%H{1cAmspPj6IxcXX`{Zt%RQlOdw}RL=~47UTy4isAti&p1xjAf!S! zGe}Z6rd4SIB0&y2QR@8Fa>%wumC<*k+O_|>d$NptzK*=4OFin?g4yh;FWM!OP5IT)bPL(6*9|O&l-!M2USG@x(cl253=POh6ig6S^tM_r{I7tXhLs7uI zvfOPT)Rdz*Z1m05;go6`e&4-Jo+lUQnd z(^rGq)3gP61&l{?!Oo*D(OG6WvE zIFX2i78{<)_fv}vGOo1%G&L^709UX|r@h?%yVW_a>uf+p6F=V*N!{$PzwdLr$^+aW zMy+4)0rKJJZ9XgjRFR?Y7obpZbb=+e?#U+%DoIb&k7u`QE!0#~bB;o|8r>Hrb?Uok zvC)D6x!BfhEB~Gdw@)ENs6CgPj9a!EissZ_mHgW@Uief|+SEi?bf?t4zh0rp(Y?{X zZkQM$phT)+=TR$X%YG-Y;^f5nn-F)($#4M7gyflDhyWX|WA<+^xt&+%&AJ!%#_ZnS zbbZ}ZyYmtP&Mv#pxdL1#?2KOUFq!{*HU3U~S2 z%SbVQkrSv0v9ce93SX55ljWFcD0lP50e%1)%q{}8O_pFzm^hAb`=U(ipUvlE=ESBPQrn%eAoD~Buq(99 zfaE$FVPB1!Fd-uKPm=mhTOm9t;nSk_1#I)a^t@VWq!@*69Y`4QN95=dlfHLWy*P4d zw6EiM`TLpo$2KF!ng}3OPDa-_d^R@gOis00goca&r@B!!Nh}Bx4oK4)#P?n`0|EF} za3Qg&{oL!AbVWN+p?7!6$Ve6IS;ON?f5}P(k%Z##Cg52VNDZF^uC433Np7T`82nGZ zm=3uFd^kj?+z{0cDk_KzUBc?8nNA;kyr5-`uliYi$@@9^7 zxl|n*DfB-B@?u|(AV!wr{`9<~sI5ZsyUhMt>FR`te-hEnIgH-F#fi|{SC4+wKUM2@ z=iyQe4KTfL-Bj8kf!RRn+ABQg%4rs(+T+U>M;6VRQ$(ReF%jA+oG z5lRYkU&`{!1o$a89>?F6{nAW^h>Lxl1#x5-(gB#m0qsb1Y7xYi51GF$FVcfp05_5d zzq6C*1eX}A!^>~4E*Fr818`Wve6^c|ua_7?2|D1+j}l$DNQ|je&sqK}s_Zs;J-3Js z*^8-D%g^5eCZ2r~K9Tg`HsWy3`GxZLSn;2yu9b9jqRufDa7-TiNlOkf;~mjRbEd;- zPBFV=l^Ui{EQz8;lu+AnBpY%xP-*orNG(fFMJ7c{g2|VR7z1J5CR#?8lw?dYvsh1V zu;ZWaZn%s|bEFzUzP7Cfrg$l~MVusn=ud@q;cb^T1R_DYL6j;PY%*C|LonrE)Tvpr zYIffPls!q#nU5y8N~0VF6X60MWFB5kUPzK0qSzZ`Z_Q0PBRri7wi5&r zI#8ilJa)!1dzar9?oteY6h|nDc33BvOhl~zk{I}^=JD;YZZMrA=|#+}v2Tc_9Ebp( zhto9_=I^u?R+|^_eu`@KvaGolyCAOTwN_Q4Z7V96z30m!N$Y?x3r^9jx8rv3`oHMh>9~-u;N**F)Xr?9gM3RPjYxeLib^!i8PV?cHXcKf z_eG3Dg*Og>1O0v7EVmb$S-wQps4y!DOM`@ zO+$WDZOrzBW+ZD!d{U47eMRMuGryRRGT@eo9~PuRp=>q|BMHtO%YYqaP*SgG$N;PU zMP4db1H=Nv9?q%*(sq!6SzcHvzTyA>8c|Z{R%Q=^)=9^|=r~O2J!8*eIRG6)u<$1b z=}sTuULkG`JF&8TH;JRox~EcdOU}hqsRA6yrEe0U0dP8WD|O`jozlOKc#^rHY1S1% z6er?Wf&TZ0pNn5v;o#!p=4a*C20zq&ER;!BP{4ko0(qc9l(Q1_FF?Xq$HvVgcZ62{ zSC#3nVCbh}ig5}-hu1{8cOeA6<0zhV>Azt8vX1O8B*dGRQiO89WXq09@Bu?ZZNRAp zq%-f9CoRBY8yiBYjvS2NCmc+{2ZXlqeK+-+vtUgYN@PUIoG>dPZ{-RiE^HAPEQUYB z*1`|Kef_3xcvU}Hw_!1V1`VSv(H1^b$}^Gy5fT=QS^8I6X_=Rv`jF#uCvo+Hfm_;j z5{NQPk%lZ)K3L|w+agP%JjwqbtV$B}PG)om^m((7)iQhq+C#kvsaY(Qgo zN~Iw($XySUUcx1bJvkeE&jvnluIzt)du)BZtZVZVrfXf~k1mk)or=l?^cSbo{WkUc zzYvcZe7DU#V|8P8J|PJymWvGGJS1y;5y9TfIE<3^s@=@1??Jj0@r!52Z8DeFe%*n; zEVA8x2>_Z?PtSEC-)@SKX<<-TOL@Y`_XS)F1ZZUeN$7Sf0thG7x^fVR94-H9c3+TY zn|UMI$;grYIS4@P-`+eegub+2`d26!=U~C#F36x$DNoDpE!yN)SZVdkWApI=pIg+v zZU$?>`O4QVNL*sAm{)n<;9%8K?z75WqAOwQSyWH$7tchpCB{;(vYiYKM)W>27mkUcETz>~wd2tgC}#IIC8w?`%{8({a1L?xU1gfa=lucAy^7@Sc*E-S(Ju#@GXY`D2>=`03W z%QP?3{K>zwN3wLPB;0;w2Gf!jQ{odA!~1KxDSxICwY67bB;prFusqXPGJanjSn>KG zEiCA^hYC?F&@&k&+!xsBalb!yk;_^Bsb*d8hbeWM zk4>bn#s87$PuM}QS8rzC!X<$HG)>-c?}FpAgj7P+Z)m6oEn{-K1!UY(m%ebXXZj6& zE@!G@B+Hw|2LJfdTFzB1|1viaK1szo`er=1dJsl7st)4ecy(iim6IqC6hX<7z)>*e z*#O8j#VQ%p4ETd4^5Z}BSn7uvb?3gULIw`Oo^Uygf>t*4pb2|%s@gRckYF}=7$Tn& z*(AI8n4`o|F;WZ(*dVk4z9dHMLN1QSxq*12lx2B7Q0gV=VDE4DuOz!_7}lP2|3u4$*%&&pPb(C3j zRGY?I1Sm5(Av8L^evo;nRs9!zLYMQebynNnilo<<3Q=l(xkg9;r|Iv3e(6+m?${wD z;N$z+<}y^^^dz+;AxOMu~l-pi9xm8V)@*bo8^n3}uPC zk}0mRnOPwkd|F>g2%9utKdg9{J%CjJxVnBTYXoVvPYv?zkk5S+#idhm*LVB1+rasxD10#R&gbIahPOn1yL`QK zcm<|>SD&iNR5^sR#=;UHbiu~1)HPKR;qa=>*voreN$3$H^ZEPj(+AZ}5(9o+NXU6^mmT3SZ}nvl8hqSo za~Kb&4Ge0!smT0C@utuBP$My^81)!*c2kH9ejsg}*IGqoS)Phn@GO5}>r<7%`-Pi$ z$g1(EoEy@N_Yu~|#9wbHKZ$Nl;m%%IRySUhzm;T|&BxtF$HTZxM;hEl6r#(Q zPPCu;C-h>Cjhi2NLn5m-4-+AplOrWGKGq4W#C|fy#hpa&#>y$I4>`p-dpc1qT$fE` zU(b4{WIC!5mVwmoisTrrEm$qM@_svXgSDV4?fCX$>w_JL2F4p&WGwX?!>OspuM>%H z!4Wq@$r~re27+nlGn=d$(jX;ahlAisVx7j(AJD;H<9$5r?onWibAJ6XcbdgZW;p+X0MW$ZfP@Xtm(aeh z-@R`&TW&*`DUs7WxYb_poxOug8p8bK5AA)K_V?`y+f zn1a?%s{W>33IMB;_O$@ag-6>zQuq~zCmmYg<1YonvQ&7fKgjJD5F?_yC5jFPEG0pj z>BwWD90EFQH2Gr5?<^5F+6EHG^1ZRC)AtS~SLEN<%6Q0^WH(2rjdOoJkHZ-l<<^JT zp18>|8yf;xAOs}w01ok>1KEyWvIQ@}C!N}aC>DE(PNH_*PG@F0xHSK#!YGt((alY> zrrgL@iPF}Fsru({0MK;0F$=`cDa80ciaYCm9~iI+2p35x$a?wScmJo02IcQH=`d`jGo|0!a7Ep=fd&sNOL8zbR0(s{4<>gQdU|E$@2NKQrFm zGUTXb%SYJHt9Z9FuUb)){dAT0|LW@;n#gcLg6Ct-M4R*NVeTXZO}l1p?-vl2y?S}o zzn_a&`5MP)2kH62_2-%h-P#tsYYON3sj(Fi3>HjRWa`+O@8l!Go;wemN}Gwe`-_{E z*8?(1e8L+=J+tx37#cXj#XYTB4EyNb@%D_g4|cur4c$v0N2yg~^lBre#m>V4&|E6K z-pHXVDixNd2@Ng;@X%jt$>n*c6Q9se|k?c z7Cu<=b=ySWJS=by+;m8Co7o{Br<+|2Dje@@%DhA*wSA%G9vpoBEhHmzO#J2zYUh9W zXIsuCLV3|E(rrwN&Z&T)zFF+t8w`eDg6tLgzaIrWt2m%IW(p_gD&fU~rzX1er4iW} zA9G;VckTG^>ZVo)b6y`o`wXGXymMGssY_W>gG;h{5t)9{P3ANt_hj=@@R?gVY8j_} z_K|(pSKa(~5upfSP{$s)@?5g3I6s8{@02uB7co({KD?%}-`2vxSgu@!Lda(-j&r~% zsfa8$Q20*9=N}d>ZVxU+e@wXQfz0`q?8T4;&6=ROu79qGq{!UY4y-6vo9(7q;O5`?HWGf;?D@aXXC%PaWU#;PyR~zKRJ&8=|r}Czh3rYPX8X7fo zh_F$o;&domWmKKZ*Sj%HtZSj=k-a1|B!#g)4GNz@cdD5m1JtI$I2 zJxi#lBCmxhc;vKI^W>UeK4h>z8(!bo@2VH8%gBi6xXqMi!BPv9@JP_@$b>v49Dr>0 z^me%yMl&PzsX-$*il}ew{(~za6_276`9V%cX;OH4N{r!7ls46d7C#(- zu)vYFNjxxnG*{iI{UXe5{VQTd6?F z;wVIWNa(}C)$YY)`NR9x^%Nu~cE!N)bM%eqR`0Fu)Dp+wcU%V;c6PKbH=ZKi=-cG)Bu?w;01g?bf|1mBa8psWyE| zVCY6@sS&s_?Z{={o!EbXwl#wMVYJDW_-gYhGb!ha>2}DOYrdvMg89b zjuiWkWU>{~T2Zvc_}Ki4{V05f(#Q!9Gf~Eje*N%m-7FhNT!bwisU@)C55nhv8C~yx zOB#&25n9tiuvkQlRD0%S0a%?gxT~2EEN8@gX_u6TB@y)=iq4h&N-r-He=2t`Flz}O zGLA@+SCdOepR-0h7RLJb^W>0m43<)o*}0B1iJ?o$u+_YP0!eQY&yo6KQzqg%^KrWm z>1VLoxSxM*yGDKX&@e`BMp0Bt12ohHqcDkQT2NcWiO1&}y+o$fa@RAq>C1NL=Cf&k zWi38!R=QtlV9GAZt82^V%gx<`nLAammB7z^Dfo`uZ~SA}J0>^Oy#pjXw>%?q!V zmxS9;7l#Caz5`nOF55#vTe;viG`x98`j=a=D-pI@;ll;+iOl1JJAB%GLqhsZLYqOw zbS%1v{fb;0XRe7q!ta5b0(aIdh*kMX`(<<$G@xhzS32*XJ-%ljs-BsW`F8x3)XId7 zxGfy;HDY^uVuvI53A`B8G@xV@J{X)2bxILXi(SVRO~Pi8!&fZ}QAOY`N3fidH^tV# z2K?l^ntqMm-!gpBo7pt1dPJHA*H5(${ltBhSN1Tm)YaJ06AEt zZWibKX}QUs3Jpujd=2DAl>0^K_N8)e#H#F+5#mac%4jn+%Owd>z@!PAs7QNs5@5Hi|9{ zClb~v=LX6u=tHNdtw#PNFf;f;6iv(2bd5c3caxeR2`fg z8hOGMNDN^Zf~T0XAj~4@6y|w$LXY)-xC{2`!)mla#r*>Y z3g;hgKvftOT_Ot0H!;mxHrLkGoU2jiW=j3ZcT@oNK6FL;=BkV15X5ULYq*y`^LGY9 z7$)@v?KSpS(-+vIhqNrhF4#YlDQC?B)|9WoG63ZG;j<$%K zaX$UT9A;KcpuyBOgyRuTZZAI^%qf7|bS0;x$f*-V4u{s$z-)WNDc>8IBB|}#z)KD ztViJ_tw5-O@vMI3Cp!dPY@|5X8C7TQGDY-_bF!P<$~S&6B%b))fBG3hM$@z`u8sef zG;$-ZCwJyU<}FDPTd;A0I3LA2rFA(-Xczh0g`1JC!TN528I4S-n5i?*Q|3=Jt>^C> zB}42Btw8JFP<3Z58k=KUwn0Hxuu?b6KJ?);Vs`hG*Tf_OG67~0iWiLnBcDF1{O`M6 zi2Uu7aC74O@}R%z^)|jRIK9ZARmEaUiq)D}5Y;90Xd;r&N;X;zot-C)E@=*Elm+Y}PIKJ*x*D7y@2)=@ZTP@T(W;}2Kpr=Xm z7=5xXxo+F|R}70zxs}6a4%sr^zxq*fP+=6w5RmSfR5nNF9DrJaRBscM($wKap{Ile z)v_?8P*2j3*}o+ePMn?I`$$;Ov=WGjM^g%)&3HXdN5!~WmN2%P>diN8k!@i@<07+^ zEx<3mmG?w?SGul-`R3IsS%&a}nC&GxQBcJ-x?w`ZKYm!CF;^>Rh(Z9md2&daYFe!B zCh55RAK5YcLpx}?h<<;PUp6!?BwvtU$!ixKN4UA8974B1!)&%o)pPj)2$VqodH*ge zrw{aQ(jhu36oT8}8<6nH0S9V9zFmYQf?7R=V3_bH$-?JZ=aBt1(dWa0o^&EF+rzfk zvlJWNwFKhG5EP~CcbXaMW)e?~b4B_c{bq$g|n2% zXeT(on|hd#Or6?P)6P;5@eHF6VG!C2^#QTIis1Ddq6~d1q(apq^cfSl(jeVuhsIeU zz?k*wW`qOM>{a!?)_i|+Z)hx-Yb4rHSzgmOt8R1mR8W9^St_<~7z`S|L3=;o_2-8- zW6c@WTi5UG2)aQ0KK@$Iy#|;4lL4+f!ZoIb61lo(bj@k?@|+rSeQ+Cm>cBd&@5bKC zVqD-dkNKTgegQ~Q7Wko151kV{92p0T=6=#ka!`98+9P(0)$O8X-Z$DQ|N zSsN-vnYisP^OKqPsg(v@1@hEk@HGmGatfyt2KznEGA@m{p7A+ENrY|}k$1y9hj($# z`GSh_@g;sYb^Egkkx-Sngv~rHpJxghd&u`-S^Y`kaghyNy3QLgA*;PvW2O-@a@7Lq zPq-1>u+F3n?Qa;wu;V*@e3;6h5#Y`NS0fhS%HsO-P&W%oJp zSFHgC43(I{BlWy_o<$08mPr>%Ul=D3JZ)C`?>O^qY3#Aygv-NA-05nOk&L}xg*AG; z!cD`KEAK6L!mcV9UrqZq6b`!c38S$hms|e|i1rl%O86WuulGIvv)a7*gE-E?&89Xy z0`HrMz0gy1OzUvG7w~owBeD3$_cdbz7@CzkH>fT3wCscHV`%b;nEgu8#^obP$57q*r z!~IQ&)kdG{A-WcfGJKq8D%T)9DPTv?{v=iIQ~lzqDj+K`m!h}I)$mj9NQ2TDgN)rd zA!c&xTuOsW5T)*rL1V`)ZMdCGYsa|1n7lW)HD;oEf%P5V><{<;{Z!v6Q)bP?&wBP+U<} zym?M=oUK})f5Z@QiOY=**?FoD`GZo~`(*48QlkgcNmvJFD5E`TdnEedK4ujl>CS0; zgvEUe)%i$(9$86-W#{(jm)v~ASxLukkv_}OD?~w_&!Y?1`H7N1XNUD0??KYQar(JB z>;K{EEW?_9z1BS&`%f*>GBOP7FzgmfbF$({jV^)FHQ2NN z{k*u3<9@$4+wXVn`o?*FPW(;M_zx*@il1oqsPboUk`#ln@wQNh93m2ZYvnP0j)0>& z<+6kdLVvL#*Vmti%yCgw;a`FW`gg+P=M02|vpD9fqIQO#H&xm>b%KX|gdU(uY1T7O z;ZXq~;rXzLXFAW-!1<#@inGxutzhNX5?&ue`yTbgy*ea=Zazlc@BS2|VWl;O(uU?d zkW?}bAw4HU%3x$2%C$bjgonqXsFZ|VGBgrymM~k`;@MTx{$VFuao3`>9u%Px-z{yy zgHM)g@z^imULD*!0@6e5ab87Ui}OE*OCeUoI`4VRmLzaunh*oIn5lhYY6whVe5}Gw z2`8RFZ#R4}|91j)qn2!*V#lIT1r%lS{wVza-(W)M9epri;7%pd;;6XxCIkayPA@L~ zrTeE9NL>DXNI*@-oDg%I^zDjy@Gz*&e@o^tFYOnp;GI$5i$dV4v`#qN^a^7sf})<;F?QFfY}y*M?yvG1<$^h)uHM%UQq4zQah-Rz zQXQv5i3bA(UcZ09ml}{t`~xWcPa(m=a#b6#@$fBwMuUd+=W5#9M zb!QnbzJoyp#+0CI0*ag(j*n~2>`%@kj63Hrp2GaOgX_+vCo)9W@DtqnH5wP)L?pfn z20e>(J#H5#RdEA03h2O$F=i)DS%txVH;CF;5#p0GhIel!ZIDC2qjbsAKb37xTaidPSk%M}{&skeJV2J{vUx0|0y1|mIX0PI-7Q7#0~cYF8+QLh+G>yMb7 zbTb$cdY3c3X|F9ekRsHJGe^@H_ zPJKNvA3-6%L^5K(BjvP{!o{U@P8G(g?BK|d!R}vhAJ3s!@P$^I&SgD_%!o7Jx9u;_{3hF|WKqsD2F7bQvU zc+ZrA9L>JE47S7l0Z;I9Ok2*qdilQ=cILwPDGVWrA(8q}|JVQ{R*${=uVUIYirp(d zIdv74g!;O3cer&}^ddgJx1M8?S33JVOh&vcrrmbWgPP?4{dR&6^QfZXE!pX3;R2bT zZYmcqhH73rmmz4`upT>+Q&$TvD{geoZAw20IkShEQ z72A%hwRB2yCV?^9@32@h+P@^k%#-p4J)^7GvW*E7!5}|grs7~XIQn|TV}FJTm)Un| zF@&yUSyc~7cv^#O@w8H?_BlsqZmAEiKVt5t@8pbaV<50(4VK4EJBErzR>%3M%lP_iyYnt3sG~Xo6U)AA z2;I2?hd`KY@aG6Aey--dWshP+O3}K)<@83UZbu62La>!0;WlE25ss054Mz9*A&$EH zy8W;4(|`Oyvl*e3E$VXs{QwrU#6;Mi90xjk#sn(j?f)`; zLWwu>Ywjyd9C8pN2g6IRFxxwHYQ!e#ug z!Y#TZwOXWV(td&>=QtaU{gxV=@CnIZq2c*xTBYt@xzfFdNVpOQ0Nxk*S%CgQoGt;e zi3Wl5+}inMRp6*l6*bjRp6BuxsB$-)ddwUPP}*a|hn)(C%ORBmM{dwwty4*6{0QFM z-^5@$AT)|_0gh1h1r8}TeO<+As@F(^3VBF6k!7~ z9x3A^W4aW2EmSG*p2tV=Xlp6mZ^SVJg_bGE7-6W^VPgq97v-wCky4SE!>R7guCKx* z<4rFvqKLDxNw2>VI&vySIB-2G4!@0HoT-lmA%L*rsFk5UQXR`g1K09b=1plM)!Cu{?>RI(nc%2-@y=~8A zDO_0&Jsj{g9JPFKhiYr7{u4$ez=bJ*iv4|l>TS)1Sd&h_>dgi+ePQ|Ueb#!4L%|6C zAmPixIgL(ppeV%wCPlao-~%s>GhzBO7FVMA?%o>GFG)!o)Qdy0X#LEC_u1Xo_zwTgRAtUtk+bgOJ4kx0S4l4Uw{9eg z`Zgq-3LY!+j{2ils=-mtuI8Vw8I8U*jAEx;gzvEVFqx0dK3D$@x5!IIzaG&d$@#=e z#>YjhVT+cqE0Y*rV44+stP;1)wDLJNOQ~7$)4vS7Nqkrk-qUmZb2-cyzx;hh^U2St z6er>#|Clx^4eFTBt%-m^t{ zhY18TQXQS6wDQct@n>;;;HNr4SH9P1@b;a#R#AWADP_=HOp<_j{~z{>=O)Q6%5S2* z0-e!p4AN0x@~=s%LQ*^*MFMdf!|VI~rP#RgX~&5`R+;sfgWe;KL!KX<&z7Z<#9!|m zl^zw&x>}VxQ#VCN)$sC4T=Ea=nUg!@6~1RNI*zDNb+goz2;^o89el2y>`!xNkqo7x z>8~EJFEk#mAQ!=c&Fk z84JQ1p4{k5p^ao=2Be)}u~a?(D?)321cEEUFfQV2U~+5BNWf)Nfa8h{k=3_Oojk141|Dz6S#}agMBpa(7-8l-H-a%a2D4aktA58x5Br=>s7DklOMu(MkI6iQ zmGnCn+XAKMpbs7*6AsOf$?u~Zh&d3TdLv8K_X4F#RVqmYro4~%ipI3BIwGI!Qs(y2 zdG-AVc)x!_PZRu`5(~8niT{+?q9GAo)ElT)Jp|w(SgbWL!nT{cC3R`;SfY+H`9}C63e`6e7Ii`jE%N^CKpy7tUV<(T7 zs}&*WNAt4{lBmY*FaskaIPuzW{i?%G)#O6B+c{n8QAvsYFEVQhBZZ8+_c%zoS}!_f zARLd;tOFta^`DyvnnQNRC4 zUs&1y{rw-NkzbCh%-S|C<0)dE?5DP1BTrJNGL+FN`IOXVbmI~8=H|7z@0#r}{@(XR=~)9=%OHZMQ2PRJVwZMm(BA`Hv z9F8YM23|GiWx8gbJ$5NCk|VW)|6f4syHN!8-VRo1s>b)C>5^kJnJp>1F>b9xmL?`+ z=guMZ75g4*#6Lx%LRtvrmO9BPQ}$K-Y(D{}X+>i7Jk1~EMhxN8vJ2wih5>Ry1*o}1 zNw!fnI;5Bu6Xm|}fnUPA7;3gOw*pnph2s=<*kVXEhq_RJO$pBx`7={JT_zRlO%O%$_g7#-WlMb!&qynH&VAIbh?9Aej&D8f-_33{u=+tW9D#O$}c z7I!F}g?PBEeWhC1gg@Dt+ZJ(9DHk{X#dADHti~BNLpE7!5FcaduL7XKEeX7irJ1-( zE;d&6L5t;$i2&O3yPl$d?y5N14m^WBz9V!2Jt@dxxN(T=hQ52a^z99{dzOulsZ%8` zEGP>^bWXUvK=##nk{64-b2f}tjS5RXA(ud=&pLg`UI|2>NP|PM^=|LmxONA8wFz+) z6y%S*+%y5YSps%lzB6`J0h!dWQW?qo?cD$zgJh`rF(8Mb-44LS(N@rDFYr>>;761! zmTm|9^p_4~T@QJfvS947yi7=rxBjjsw40D&yBz)Y8RTJ@Z4s2ULBx-%n)tbt_oqzu z?S>P^k1Aqk4*0SmrV;-%n|R`8st_+MlI6S|S@b-y8%8bWs`(PPveSPzh8B2hMk@DC zDe~Qm+3sX^@qRJ3Hg>4|6QUZL`IxrTggXGOrn%%R$p3gj5iO(<=7>23(So>fzD(K| zDJ;a{Y2v>CeAwv=UeKsY{g|vUdr!N@B-52cVpZUp8AyrMssQLX?PM33A~AY$Y@i+X z*qq*|E0$U2t%b${09rd{ir-WlaewjS+>(Fa2nh~ODDtNKi0a^OnZ41BUlZn;;^gtI z)LVr&VG;>pZmHGYfBC_2jd+7w1V1HazaNy{k)*cx%H{q^)TlK}REJmi=s9@+HBTZO zy9tZvwHc%*QH(+SQmh&UJLs@?`%Nc>!CQ{H1k&);cOw#k9G9FU01ZH73VzbH=R0_o zONZ+$^$MG*4wr9Mg-!@V_~79VjR=F}-c%&T>e1iGcQ#Za%DViyIvdw$9YyBfAG0qa zEEpop!3v8|nbICl zlKzZkJmcik&kH|I3m{)M(Lp?z@UMNrck&%t?2a_frRlGd27!>>ru}4A4feJMg@k&L|FKJvg@6f;}yvw;~AJ|myMzMuu}_I8HF`HKsYobPibVhtMTgbn|`X|eOV6oOd z1$pT3MZ(LY`Iatk)cv57fGl0Klj2*qp>M@_`-FcnE#g-Xak|X_AkbukOMn0k5kRA` z^niUt5-FEyuxn<46>6nbfCZx_7nbDZxIgB;MpFeHnlE4zakPzna+<@=E2w?r+K1o# z`i?F0m`_JUbThq{>+KT)EAnH4K4$QKL?y?MC^ybz2s_InT%?De>(@9pmr#nSj_3gm z*iO5;D~}6jOEv<~R!$)?^4j8J4W>Wy-@Mw|gkY z_5Xv<|IBKGF`ALNZ32SKTrK1Lm>Fp0QF!}9&(!ZQg%P>Qk_+{-`+Ov4GwcT+lfh%_OV_3gJ zy;u0q0Q)6b8oJDD-W}%-T$~N_Tw;9hdunQ~#CF+66;WzZ8@gU@)+o^>p!^$(0qQdo zSz#G(s?x*8Q=DrDm2>>O_HStVpGfm(v5BPEW2MBuouT)IB@}9Y`e7(lf5DeQ#L>mR z7F-xTa)gFn&^|lPVzB;YD7Bt8ADB1YN;0)@DqOQ+xgs|6mCWL2!fu7X`sYJ8Xe*&M3wV3Jkw8L>lqeff4 zO|;v|PdUB5G?#1T`ue;11>r+dzoPsH`0(T-RQdOPbV~ z-_BW(%7q4Z@f4TZoPXQj4=5Gc<&dq2x)_pzy@|3_rsx0y$nzqkWj{NHW+@JP#TB!# zHstMR|BI`1K1}mtb7^8TJwhz`FiJrWRDj&nLHz!A9Vkyh;&k{)p(l49PsW|Be>LvD z`0HVnPYj@m(Iy$y2^4fU3ZdgxV;}btJsqy(P^9(`uJBX*^_`HFe)?&TwDy8GHENUm zR;A(fjuO=ijoBd-d#ToAvs|U%!`}}w1S|IcO@DbuPW!|rIhkkn1$%`E%-D@cB+VK$ zsfcn{m{S_yF`WO#OWtHn1c9cE0~rM#Z~1(e?OyeksMdlcRiws~y(LK}Q|_tR6Su~k zaKHZBNPDiR>6D`i{SjAEX>bQ`_tY`TSIQT_FB|bS{LS-SDMws?koU=Cz`f;;+nbJ-sFzO)ok=YrJEP1Wb|Z>3&x-orvcAB}OcBF$ z#_T%BY+O6FM;xDu2Q9oiiiBc=O)xM!@`I&TEULTGq*GE2$$nOHWBHGEyEe}SOZgW8 zJ9qMAL3zrzFT;Svs;N}`QfYbEp4S+KRNxozTC`(o@@Lq2HuNogZ_MJSiTGK&JjA3q zf0cbE8Ip>cdw_WnIE-#qwpn{+$*?bhQrg&!ftJbKT|emOIcySKmoBy|B9wPu%OR5 z$U(8t|7+TDlMA}i2W^(Xq-g(7OP|aw1%bL@>|6)R!0s%69hr+$3ZWb%+>Zyj>+-a8 zlY{0#V2a)4+#_1pLk+kmTr#3jY2IFSLgxuwZ}jq&bi|Cz0p00ObczWxogl*E*8o126~@Su#%+@rhB(I>=4J}-6hEuK3` zdI)WB0TT-uSaN>{mlbN+!3&dSKV;4$^G7SSzGNfU7J*?UywcpwZa@WC##%7P5CiOd zO=sp~*t2iBSfRLGFLZt;?NXYTVw7)kd634wuf-MCka!!1+=Y%2J~?1~%nT34KwarW zD(EbLFJjW(vkLMKpD>7$EjPTlzLyMWE;TY8mSUg5y_YWWvO_Fl0qNqi`RGc~y`ju= zp9uZKfO-9CX(%?p9}G`5EbCj6%-fkD3)*L;M!#yTo!=p?^b0+)b0*0>XS8MvP7RsH zJB?rO1scEOxwU5Z^$!x)+rM)R2#~pR;Rhrbj#keU^jrOw;40*{XSJR(0Lz1>1np7t zk|=s>+)rBe@r^Gsm`-<)qWdCqX8g|2`xTLmAvMP<=L1LTetR!E&le+40$t7C_Jd28 zj}t&YfO+)oAIcv9L5Lg`9xBFH5|M)X5LitGkuV7t(^72RP0JlxayceSckvqtco;3_ z>ghyWqg7qj-2^{Y@v(G?ORXlvcDD(WR0*;B$>kf7XJVm0X%A_5t1f-A=QSRq zu5Kx|-;fRfP>P^zx80a66V`gew}c_{(-H#)>iJ+uK+|n3P3ta`Eg`)ZYUtT01IbYM zDfz)Ec~2oGsc3dy!sk)L!X^bOiq9i}nmZMXVcT9pG`b0^TeiMR;>ddOXrE-M-1q@U zo_{3{I}RnJ#yotc6}uGy*oh;g=v@f!hzpVCS!C50C9I&~ltA{Lwrq!{A?`a?tgXxi zz35d@-SzQ)$YEVUZ9>PX-vw(qkGSRIBsuQ-Fsz2Jn>_dvJr+A{^OUmtfso0+D2Z!f zy3Tshs#4d|>z`+Hn22JC4QFDiy~ZYiT2v zj{PRei6jANRx!a0O{wphy8Qr%ELXM5jy1R}bd6XN8*8a%O35_3Fj^x#il)-(ASeeB>CdT6dcwKK*MlRAdz)AFXuTUHv4C{ato z1SQRU^BHqT=W-u*Ft*C=DbeOjZ^v1WPv&3uszh0e_en z#jJY2)1U&U_9~ZqbBi0tj3GAD*x32NqUU*P$|rO)u57yVGz5m#yoc0=E@KN2tPiWS zbytR-Y}lVrTUno0-E!5*2{pCJ@$oU$2|9_-`$JDhWWHEKAG3@@fm`)%G9o-Nh*9Qn1~B;)%%8K>1J^moLA1?m-bZNBhN_} zRi$nxK4gwjT8DT=-sfKmC-D9a_mobwSuLM<%245Yq#QQWVgaC?vE_|>=Q&{TqL9lH z^3JWCA>QpT$-5D|gb?57Zv1>`@|y6>$PK}oG;f@9V)7C)No*r`8s|^8nLZNYqB)NQ zb{e)LMk!@`1jW@0cC(cOs*P6)UF#5q?4p2hESYSM@HO}~i+w&gUL-6!+OF5Wycf7pwu$F<}wnv)lvd5as2rNgPdcnZ?kt_&fTPKn>p1uU;NPV08Qh9+VFca#Ck z?{jDrQQgP{so6dBbDU-DD{h2UhsB+|PmI0t%6@@RPh|Kp?aMD!VRceELh+`SJ--~6 ze{k;41#|ApXjt?AQ$2ImzH{>~?$pb8-AMNrT?k35eW=P&5=Zl4ik2KU@}^R!S@->s zj`PE>xfVlp2f^Jn^IsAlZXM!qf0puVbJii$+qE*RRc9OSTFREk#k$y57P^t-aiTN3E2FyCvZEL^0LR%e#Tv5xu|yupi^k5@ zht%%rtMCw=ttZ$}l-|qO`=Wf!+*tgFw}v0tLV)qNn4KDQXLzieq@8{L&^n|vxcF{% zx5z=tPbfgLy}5U@HWl6Tf4LtMAGy8d#w2<7DKlh%=^-K1BvcN>h<*BW*Z6l=$_zyi z*aNFgAv8EBoEf$(myW$AXfw9pKdA4ybgvo_OwWXxzmy!~Z|GbvL*Q@$)Sr|0a6QW(Ap5*t-#CD z8y*vMw>RP2Z5DKWGTHmlWGKB(C4&9Lpd-Je14<3>W%QZ5X0`8?yv*Sg&TaLa^D}^< z!)n~RGn*CK*Em-9I*sT58c0t^Ug_nuUuRc~tRN2tJ1^UNISR=RQg@yEgn1h$Rz6=B zLAGcc2`ZNB-MziX54*c-=OE}s-7-OTR?Ye}FA4C&D82%!-j#bdFmtk7ZHQ0@l!a?@WY6E^P~m@=s~m<0RK# za1ELFQ2dPO;+Rm`&g(M`b_h1viM+CNsHxs+SYp6Pple4DoV|XSz$lazNW4{v%+~q?J&O5cv)q< zH!6v{5LWJ$Tgc?=frsv{7J>?W8hnxp-$7e7+NZ=*z6%{%eDdrg!3uo5P={I%8+uq)UXcRb-jacNL<8Aok{ORN1j^Z2DGf8k5|O`WftV@Ru_Dj z7=q+HlV&5d#<(rg`_c5YwC5a{b=r$jAuV5yu2jBM^!-(IiXA)I?9TJV!F>Nm0k*_v zgU_i%oK`Z;9(RO)xX&sgcb+U0=Mleez-nz7>0Cw^2rI< zDBct^^_l;(+8xBn7-BQ~;ukh&o*7#&CKKGhGWPh>`WE0Of zv%Xi_+F%T_Onuy)e61E3+nEsPf5g#GaO6cXxZud195H}P>Yz)$zwd3)#rTQ>YXG%O ztc5+ug`@G)ks)LVaZPKrH)E&~veP_#C`Fk^Re_Yoxw72YWmht{O%^;u>P!N9Bd&~> zyu=`$>uq`~wZTc>hIUbmsNk2CwWY}c9(?wVC%}*hJZC}Y@nOiyVU~2w49=xENG!fS z_&ode0(-PL5lz+-JxMlm07v&$f+kyFI9g5&!$}KkBO~cYI)C_+D)sYx4i;^0^q!{g zsuLPL_<*o_o1x60%!ueASK_(M;*ijRE7y0wctY#R?DhYKQzo0vXf@sqbvtmZJzxH$ z^6dBDdayp8;bmprP}YJyMn1`VT5i8MMuDB*VP`nj2C{dTW-7xjIo%ZXm!7*@#51B% zak=9Z7-6>r#n=~ApEKbwp<*(3u(KBStwWbhUI=o~G(D(&8ap$x)hBFH4HP!1E^Oyr zDEiR5J@-Zif>)5N{!uR5Pw>-R-g4<&3vC%_EMnLzj-OKK&HLW=-5mv|2(@7Txi;M# z2aLF~d+j^5cppuy?8U|PfSNzLPNhAZTb*fL zO1h~rbCFhbR~J+A*D=Z08lfcI=kwsN{U4k6%jEG?*SLvnDw*o_vGJtpT*L5QG@q;E zjZtZE$v%TAgfM$yp^{?l1UQvz-&pIKe-h}knY4pB=se#P%si;CQ7geJJgMc_HynkQ z`0sdEE}uKN{&^?gJ1UrUXcHL5v5W9ZY4=`@7ts5mw}c^NLFiTe`_56TavImddG2Mn za`p9eoLy z^LNd3`zDi#R&CsxyBSd+Wxb_zc0a1#5_$uwK}%?*Fv^3!IB1b-a<>r#n47!PeH=}n zQnzHW*;@BrKPWfzcqA8NxSe-WpI{&IO!1_>Y7uKvZG&EzW6iQ&j@dx9ArsXTF}SC`am2EUPC+X zNfQ1Y7PB**KfRH5R@g#2o8vPwuz8hs;vaXKNbUnS!w%8q{NTZIBh9Dls_z5aLe_n$ z47l)2JJrVoO8{*Fu1a!lyNq(s5aD*j56V~q3R8S=K!^eaiUOOF$v+?;h>zy+J7FxH zp5x?9jIoDczB2g;wL1ZHH44n|Pm)5`C>Fer)LNHTK+Le0<-U#^@0s!zaZCBCc76VX z;`&V}WkohsE6qI#$*7b7AsT+_*5?ec0u}vB1Ex$*~8zgD|YjezH5CRB=x=vIzB$@p=u6=l6 z`>UXZFdJC%s=eyZ$fQu5T8C)}?YbzJ*W#z>G&g7`f$yAmMX%FC@pKO~2zI}aTOYXH zP!Z4Bt&FDmrim75KgJ^p%A^J0zMX&Fx;6BGg$XtCoGZ$T=m>atVx$)r&APcKT+VZ6Vgec?i=9wHAC=jo@}pKbhobr1K;ETN@; zHrCG=(E&D?!6WpRwF4CZjqy#z&dMr)_d+=jRnUdXr1!&+#u?_=+>+M=MjK1@gm91_ zH#Zb7i?L^Y#TJ>xiIEw&$&FUStjTqI)Z|DbfoNaJ^@R;-zu`-Z!pdScU(jTLt&ZjSFFMW- z=~o67L8g7BYwJi!SvPe?d#9(_gH^qI!8iX_u->l;oJBOXA=;9pC1)|vgqLEIznuZf z5#=^V+D`@tyyE8Ulb4@SWFV_b%Eo296nsZooP_n_U)wL&Onv95kG?pWKH99W%P}$^ zKFmI@;RZ{-4LJgJp5Mp3r=|%lqC3urXlGU^b;b-{BM0ak80Z3sqNS#9ftl$$_r6>c#>|id91w@t-BCfjnhMiFCjbcCKm$C z{R?mD^t%(NkKTpabcfe}2%o1|76)~?FDsdK-wgd4r&Z7+^zL7AX?2HkT~fBLMYfUy zz-!%Nre7xN6e19)joayzzW!K>Wa^P{xLKH>B}5N-V-|Sg^ZJEqNnZK((Zo~+(@_P3 zZXgAyT;E*4B~ zdRwq}EKW$5it*^|&Jvv4FgP+Qo3@9tH!jWV0@{wPZR6T^Qf!f$-8Wx?<;+5i&N+{qmhZ2 z0k&MZ4%fTiZstER=Mya)=AIAm#AFS%Lry5oyax`wIt~#xgal+0ur;Pj>iYZBa85LK zGSGtuTV8y(TU(7yLH$nIsYq$WMR+Fey%lx-q38z61ta{{l zId%J2&rlJG+#T{nf+I&iUjwA5K3SD|PxEx16xfx=I@-Kb%opBK&pPfM+qSSd<#m&< zZ{?2`Jzurl`>g+_CUH+xoGOD4DR9T<8|&!U;{21Aii+vfcRYNOf&OIb3-Zr(1VwhpwKFdxdD8FVozd*h`t<;pD_;PUEWHi`k@K;!-d+g!dcZ8f zRb_L!Ocl73Lqhl4dvAltU}x-uDQ>R8->F_l%asN}DV-?dpJNo+cS zm*%3V2OHF;=d^}T<`05Par=>5l+SYod(As)8_**WCzNusIV+TAiujkEc6Lp7kN>i_ z-ItsR7jWE(b)hwSS}gsC+@|(q?`{ zF!^SulfvJH^zw~-`F)`Z!m_@NrNQu(lQ&P?tJhFl zBjIE0*-F5^i~j3i)PbRlHFF(kT_DG`0*PF*Hg6Kjh^nm&BVj~?(%ma(#gy4+%VBjB z_~h98yh?a1>N`*m8};jLoWmUJE0IzQF>W<)wTl>q`vf18>X}V5H^1Sqa6o8o(R-j2 z@zIX7$_bEOWw}(}yq_MH%j6k<-Wx@yRLY^}-n#IJ`dut1=DrA6kFg-GBymJnna%@) zA90JNRAbZDZ%-C-{N8Yxah2agPjOe29V-4fGkHHaOwca*%-r()UOu`$x#Vo#kp%p8 ztq$01G@Nv4-wAcVQhBn5O*LVsonU73--Z>s z%z3!8(z934Gq#-wLw(QOK_C=aGLJ$`Kehb`+{jT1ntv%7E1U2(B>%+V%cOFzo64Vx zjpYLMpTsSr9q2@t%q_ur2HuQxbkG0ipQ*~?Sjhe4$N{P4=f^Cf@Ula>=4!#Lja<$KGA0ne9c{;_X3~(YK!anvsCOLpk9eZg(xTP$MpHTZkMK zB`o~wKm?8{2oE@peB?GoHhI(V5>w&loo@J8c{tW+i%S}H98ZA2<1gCEcK?jft0)q9 ztC};95>V2AceQ4w_o=Vf&oI#AIHWs&;r;@f2A}?Jt;wY0plEXaZd4AHlxjklN{oP+ zcftnH9`S|V)6vns#LsQ9TvP9Kojuutnd8wv7VrZW!@1D`#d0GnfCOc)#|tllrxLSf zSoY7}@P*&FD+{Tofr?$pNJCb`iG{%BAVgtM@oGd6}?`H4R=P5?rQ0o5u zE^I&=N8lmgyTa5D0c>RbzTGSUcJ;?*W)B|l+y3G{@m!rzEO3w1PrW(m*}ptX|1GeS z(Rz)&r=q2YM>1m1OdMGsJ-vCgY6t=c#F^(CB(CJv++c`>ap)~KSkD=my7dnqVXptO zu3$#_;|P|Y6Q)KmBh>UT(j`&E&X^iavl;Kw;-E~&L@^=fcfw@jryV=zm106_ZHY|_ zFec1*2tyHeDyBx}n!Buc)>R4ss8|obnt`VszF_7)KYSDsnVpkKYTP5*m(RN-^!d^H zD01SB^%^H`hZmd3rJ%PhJ^d=_=@k8Mi<<4+i-@BMZ?!RwH$?qle;xEAfhGYjda0yt zDJJW~5tM%)1(sI?oA^pkS@!haPbb&B_9zpm?%cgx0E6!-L*z*N`lfQz1@caqq$Amy z1J71t8ru#IRhov`sCAswbSrMBD^O3f*NvNty-4aC)c=Upwwju!onV!M1hl-0Mx^*h z$E+=Mh8lOKdr&J2)$H5{b)Fv52h%>c1l3Zl3mB%*#WEIe&FgSkiL)txg+N~IlSul= z39oWpQ4qDg>=amfDM~hOhMaGB;bL{n-&`JF`HY4H&L6(|5&*&q8o%u_3cBLnYvvzt zlkI%z+E#hiRWH0T18hmQIHC)??5obH1UDrX|L?}vU&gI0+_A=PyT(-}G7j2x(z(B}vcQG${3i3;f3iF^9*MtVv4M-_c}f*7m$ z{xg?~2{LU{!4z)u+$UeO3-Giil9Nj8oOlVu4?28!fPNW{NE7$l6N>=hPJWbh#(y4M zD)j;v`TUj}(D6sF8`$&ODkLrhx5UxFeSy;1*AX|Bf!U;D;aP0CLsx5L8=gwl`Q%zl zcjvuZ<_AqX>L0>+rdFiPX_MX$x)|Y%{?E zamy!R6y$$8U(Hh>prd2Hg|H$=9C%jFXXuX>#Iom@S8)M<_|zVqDgeAD1i=Lle%iEgH~>azPP%Y=Me-O7PXY*_BsfTZ^Q=G<1*DUGo7RSju_fZMOWn!@2RP|*X|pP z7^Q#*5KHo!wpI&s&|~X}jHw$vouftB=(<)zp22{)(Dwe=lcn7UC~|Nmx8W{=^Zf%P z^t9!50@n7U7%)+SNgOM9u5=T?WitF_p96k000QCCzL&Dz{5I*owIPRE7k>*JG5A2I z(G36RS#3=ZHGsjU(4a^{dJtl$@&UX{#ZEpEv_xLghPGX2NOpil5{R#QTgwkw`*Agf z{#PR4flDy;jpQ;cGpCoF+_7zg62DottHdj`?7%fbBb`%-g}zh7#xzDn!# zuA4Rhq@T?wD+~(%AqOm3%%&$qS}>Ci3fdbv98=D~n6Qq{8$W@1$nGH7wZZTws`3#LEStWg&~L!YLz zX{8x%?CAemncV~{9@4AT^W|=i14{MOOsBfY=kroj2eH4L|Ey?j8h?_@QWFb!?;v`Utnr4JqAwtYA zn86=vB*AC={KMQxe4xiEzdIVqzpjPkS8)_u@zgUJ;0Is(=KY8&6s~P{UN4Q1zp8d4 zGQ((onFQV;Y|^-EJ-KpL-isbS+NG9G4G{nVYMp}7GBG5AZiN7wT5Hd+0-0b#=C$4f z!=zpTCj)Cfrg+UW_waA02ZO&Fw6wn&cWxm}XdgLwBoCHDw9b|)7Ms}?Er{zqr3u5o zeRx_CJG*2)Ob%kXwM6~7w{INsPrPe>VcTwX`g=(*VX8PhaR8Dnk$i^!T{lv_-{k@| zckoP4waD@mQ+uDeT7Ml~jQY&%#`Us;dP{Iq6Rb*dg5ZocbdwZk90FKl>dr?uEz@oe zJ$fM~91Hh7xHpm$>7S5^>b&ntv8~FY@+M6%=N1v^lcs#Km$e&@Z%JJ21TS^ta~@5s zHJm*1cwd79HNQJ{WnI5=PdY0mNcSW+O*v0^+ftkm2Ss5QiCaCf2Lc~5{7s_t=qXU5X6_V_O!Qm+!#-A60S~akfU%smAKvGG4@xs>&5d_Ec7XeN#N=1p?k*;RCYN?zOUkd7FZg=rA)RoOOjq++i)Z zF2Wn1j*bNT2Dy;gxdOg(5PQs3O1tK6ZVu65iv_2BaWoLSU*kc-a5@OVVz>RxK&_nU zRdGAV9s&xwd-6sjtAYdPo=8v978Mi)Ft=*$sV$}n`L3JgsJr{6{MU~CsjhH$fWH?w z7(!%am*;x;W@SF`%3b(4C$?qJ>6Yx#6uci*U9Jmu60vt2`*Y%B1U>etp{Aq5$m7oX zrF1vg!+~7Vyc{0pY5lmMqAnFsIg${QVL>_`Rc3SJVYA80A3;T9ayb%Ph5`+YpB-R1 zM)GXwg(uESn)fK->GiGbPA<>(5a|8$eOD&^9xKwADThj_>i@O(m48itf7k;AzJh?L zNHZ0cRJvm*0!kwd!W5)objLsh1nHC(5C+oS-8n*XjPC9Mt9!rupSYjgd$31)?Q_og zoO9lFy|0Vdx=i@b5etC)t~7Bv*lT*mQ>e9+}Iopn*ehels z9X|Un3I9g~EQ3CdW#!Ebn~kPvyr^faJJ_NKj$?FDCipU9G{rWWmjqihmVa1=UPqUbU zwP$$RBE!n*0}GZfdvA)S$1{^V$hl7E1P24Mlx8wp+%Kqr0jL*{=IECM)S%RkMJ=bj z5nq$F_A1@%q2D~gw<2Dwg;EPvOg_jOq5L@nHQGM3#GjMyWAvh8s6P>4RkBdsVJ?Ty zuUs_bA1j#j%e=CGNZQSHh$Y#}`ZQD*Zl8WSzDgewwtD>grdB_*J*^&guq`g`Mml&Z z-|&rp3n;t@=z2pPpxWa!(Y`m$C%KuweY{{08>^*;Y75l9U=iOThpy(97Wa{Zl^1iw zsJY%7tx2U^$LT;*(}N`<+UnOMON%+=7E>pB947!KTQB4D*I*CC5JU1y6!%}Upm?$C zJ#*) zHd{)+#wV3~3H_L0Rs6n`-q%o7jU9fy=z|SBmZ47`^d_ZNAu)1vA6b>f_qO^Dsw3%g zk2={k8Mx`z$%%%@ralai4g3vqGh#5v@6rk9*D}(nnq(se8(?BfCg&0Rz`icz zo-lbo1`A)%cG{tkEc@~#JPDBVG_WBUz^e1)6V&^XQo|d0eeJ~-@}j)1KK+NiEhT}! ziTpaV9^B+J>a{y-IKlZXSf0W<*o}8Wr7M-FiIU>aL?L-+SMiTK>}1D~1;#yYfaEG~ z(%HsJMf{hKF>aAWFHbw0cA1zuib;taH9B+b45TkKq)k`G{yC61X$I}`JqU3Pp~$`Um9q+%(_!5Xb38~3-o zN3Bx?CM`r*)6cbIqPEK<@HMsVd*})}7M4c_Ex7Wcy@$JQZBjSw#sqrwBP(hRB<2Q) z1TDM7Q~jP=a+jmU>8yq&eOaJIDQq9Ej4SN?<5ETTa$;;+UTW5H)Z^S7L+W8siWST( z2XB3z@YDK;_0w0{gaBAr5>KS(JSG?k#8U*G%nT8Vk8xTR<>QXS&n2nfR6L(b41Kb@ z7I0@UTiNq!rWBWrfenL2BtQD3D}5&1@gL=G@FM`ZBBgkhn*<@e;N~(TX-TlU`m8M0 zD!Xa_9MrNcr?BC;l5DZ!kVi6|^rd*pLy;sbAjD=k!{Q(~N9)wAPeo9&C|v`?u5aoz zS=ae-)&6R4Oks%e13TcubqLP>D%-0h>WV8iNh*B%Gtuh5M5UMN$hO;#S%~CCN8Ii% zBrQ6*TionvG@;#2sesF*{vLoe0D9h8T$ce|NJG*4xllKdO}_~;Dy=LxdKu;iPyfv7 zf*5)V!|+iJP50GY+l5b{rmoU$N6z_L|MY_aO|OcBmiB{?{M&u-f%9+wW#Z2{h36 z6crR4%=CRJ}hf_HIyKP9s^tI3R`eDdxLAbvBmw*0~z+=Nd(*8t=54w*SZWtI_Zvj>&b97`p6u9 zg5sc2ot3>jRAOUfJ#$>Hfoz<7F48>nuSzpKLQt!^kO8DVN8rP4OHYyV{oAs)zIaqW z5RZ&mbP+$wd`>Q+!$VRL@RE!u61sFo5PBa8lz?&aNf0nEChT%yh<1Ne++gbbf&=}L zk!OX@MbWYEez>trDkZV<_7+c6Gm16q_5#+1>rh(0T0LH@+)gbD;bAOqbwiBXAoRf> zIlY((?~Y1PR!|eWj`!X6d~o#WR7!)l_g60?;PVX=ApYpDJW&WMk^{Grc1fREd5TJ{ zs$*hdx&JG(d>K`r-HKm63fORRn`RMaqG~uDgQ@$SM8>4p+tX(zm0>Il$L#Q2c}jQ2M?adVlN|p|f_bi9tK)Ei=AiIeS}g zyaWN_U_!}VXMdQ;t~&#l8_k#Hdx?(G=H0!`WXb`JJ7_6GpcsJK@<+Z?-kTV<)Ys5m zs~EQ1W{rS#oJQ*f8=JsDJ{4Onw`IwnbkgJh|fN*Z(U!zoi`?aH( ztN=-Ca27wIRCGTRT7KwCQ(?1XGr%tbC|nxs24DAXA4%J+Y4?y2{I-tYZTOP|jxls2 z9%VH(m9$e)Lv3#;ww2vzXLhpi)3dDY$k6z;xDB@Fg}HpbkcxsM506Li2#bPEmGItrVUg37vfZ^Ecs@$|1H{YQIN7UF`|7AC$xYr(u_77OiXBY}Z? zuzJ;}RtxU06MP;q_Wx!#{XWAKWt+#Qnqqgkt6VGEiaJ>g;#+dVvu9r~7H!Iuh;~0I;3zJyNmNz}cLf;C%^Ia480S`^$Vs>bIuj_@E=+8jhq8 zEYiI&&-UM8@C~@*GvI63MD=2BW~3Q{akF;&?0UED!(!9K%SI3Qn-Ei8_F?z@0@XM0 zXE9HZ7B$a6BT_w!l3$_N{^sp@R~oms=#ij%!Y_{`tExlua=RJASJLZcytf;hXGR3g zTmR>v$uS>_DHXodOFVPQJ(RpXWRj!sm;LYJTVQHeA@hGyzxj4t%X90OPu_anbow=ry^P!z2GfUx9sHD}*C&BAng2$^2Y)YUsw5FuU=60@lxOZ20k z;Q}suGv$ANewiH@OhTN9)h`xfS{V-t5CW9E`{Vv6_9PEoV5dY(LFYv9Z4K2tkEKqd zij*5LKtw9#3$ANj7oC=04TD`Icl?bSs=NlQC%ZH|$zr&vo0iMdv$@&m_%{pFcR~nx zHn)d{d!OTJ5TS(|13Lm(MW)gSMcAkYCFsz1@_Ld0GHP;=oY8=RVkh2v!wR^gkZ6wf zLHIbq2$0UXS>1G0F3>vd5P)wbs_&WS_Lp(<{l!r$Xu_-RP;K-;;ISB)`j;78E0SpO z)*&D}N7QU`_F#vaB24PNifeZPjVM*6^Ly9X5F0iG@#rogb7FDfk~(u&L(J3=A+C## zzPnt5)tJmb-K2##WOM0w7sqEB$%1g3^|Z+mBY>i5N^WzW+jI2iOYc zS8Wi4{#}^}Z@<^H%Xu#5VD~*3L@>2k-%4*&^GD$n_U*BK>~?TnKf|D?FbMF6bN?UO zx@;v>y4cd~#y^=Dwi^Z@EHF6u&LhsvTk5n=aAabcm0sEPI8-xB*!BqxN!r^O_n*yn zCU|<3xXWi;2T!@o<|ZLn4Gxy);8s!}fnJe7Z?I78DK>0Kz!MonIl7ar8#=b4qOU$_ zqV&2}E;#Osa=Dast-wI72xpy4gNK2v!Jv10A~}&Im2nDaILOm`e4#;BGey0Z{5Puf zrHf7g@`Qd8nEm%o7_}Oq78!9^tVBj>Me_7W&NYns4wcu=Ht3`lyS&O*I<4s^X%219#2?PCh)mg1$ zuqVFzo&6{C43)6gly!x(x{6Z74-&OF8y@OMw8`Q2aelfP^ww>+-K|1?8G=Ik_#CO3 zg{ODRmNR~pVGg6}kINO*ogL|8U-IAbz5iwj9@UTt6~fIhxsqrF z?J1+}yIQTW zUbowbOD>yrn`}#+3(NgF+h7gV7H3VCvX2Qq<0o^oOKqoi$f5{%e8b%iVZK`-oVwHI zjK~@sh9j=8>!{2*=zlKhQ5jP6JiN4RJ-tL6gD#)}tL=V=yUE7M&VM2xjghPwM1b2_ z!1x>drZG?&m&@aMXj*T(^LCltxFZMjCPzRD+*a(qFlCwpV^TR5WOf5 zJq6*HO?+e2^d{9g4{PZFHn;*m10&<1y2!C)0r{}8(D?HBc`EUcrOu~P_K_q_Is6{- zmHLew*eXt(VP9BX(rt-y6P0{m{MDS!i))N05F{uo?S?Mmh#@ENpCw4YRqTwiK$Et> zv-$Lc%dsa}*kkY6o&!Yz|Ywy~qMG5wqyM{-((8<>JwH6@x#PqQOu3g4~9+h^Q1W>b~$M zGeMVX5rG2A*VX1O@c$h)F}rI6lEBEBIe^o-21>6p50zQzf4Q_8wD$e@VOz>y;BY?$ zc~Nq4R5J}*COQR*9XpD_4gryF(8f=*4o+v9N2-ZeB`u=KmsVO??vm7Up$Q_xG=MF`E-kdPho7us)&u+qK^Fn%FvfTpLTv%|9SNj*4v9pr<@=Q&trX}+3Gg;V)lsNAzx0gIR z81M1e*A2VZ^P-{~ujEtu^aA$AxoqH44)Uk^rmj~X_`GIjhSis(Z#!){O&N!hU=fxb1_lDKM-IdU5I?KBV>UH4 z75infai@h+D)tb;(XtGkrDHB@y!gX2Q_gnX8@~-6;3U8M^o3qB#macIwJ7lN$?qvD zWsQ-feUSfIYLVme#X2nD$}_tizmlh+HX~W6_O?O^p+W&o%Xa7mZ&}(kGA4sJ9F23} zf3KzTmpgOB;(8fkVQas1CC1DZ-qd$hs+Fsl3xVBjq`cG-vRatN{V{{{J^Cz{-LcVi zJ{Bh4PaEhZAJO~3uR0KE#Q78Vf(!lBEs!?VH#9Xc8xK2P@?NPU1aHSZuy!{vrVv!p zRW8MqFbxRQ#Sxt`?i145_f-6;<@gu@iO0tEkV_CgXFH?WeR%GLJPB^T_v7ltHx2cx zK>Oz-oB(PSsu|JA8%4Po5vB|N>PR*-W&A^58r9`X4Y1EjKYqI!b!MR@YH8|lI;VN9 z>sFVD6K;oa;|w?UnzkpjQit3k+T~fW25iYw;%cHVwI;R|Qf5aQ9pt}7OF#6~2BqIj zQ7Q)AKMufp7cb+gs$a|A{5iFvq4I(~romA8OTz$@y z@_7tzOI;+fH`kJAqF*EnvIQQmVdFRaVbGO~B8-siyl}Qbun^oIjDlO8WuL>8nYRPa zMboM|wkp(4IVIfjd`lOH0Jqs4R>Ng?|CDl2Ii@o(E{F^7I5M7KrrWluf;GtJ-_6Eu zdk3@K)u-h3%?$VNioZ0p@2X%wAfvKX70I+@6S9A1*QEZXKnpe%rMN!#&mLRZPsqq! zLouTc8n~0JV86}3MIV=`NCG27_vgeY-yzsDh&lw5hX7A<2hU|NGP2Ygh6u4o@K!Hu z$KJ&0WI_1qWZLxq?V1}mFSU9%QsU)M6@UCpnCT6(+LP4}3B8ux+TC}*ZCczfoSoNp z__xLF8nr7?r~#k04a-U`mGL1cp@(_0{^gvj7UuOHxiGUi*Swy>I56I~XC`;+o^r3B zmPp-#@^MY77kL`k82oKh7yS^lId!R$H>|QdlLH^_7Tu3`ycI}#3;Q?n9cfO5!#;s|v0<6tQHNq=@bD%Qs5B z`3x<37 zt5i$Ik;0sFYl8?lnZH=&Cpv%LR)-Ma`tm5FpRtQQH%@fdM`71fxlO)34}tx4Gz4I- z9yEO9Wa7!J>|yGnd*pqdHo5u1E}`Cdr_X>pMnFL?KjYc1j*G5f#ri{}fQbeZ;P%q! z9y0p95=8HomlZ%HBQc{-YTdHEYN=c<9~?AVHBF_^YYEGhH&@YH!WGoK9FQLr%kP}r z(h9Ke;zCnXi*J+GEiKc5q%o;v|km3VlDA`}=7YJ5GQ&u&6 z!RS=T=v9W~vjzXq_mYF=rceO{Q4j_q)slaPT|%2$jp=7!a}F&w=6_qR4*o5)v^l8e zgxg5ht$&qo9CPLWE%n)Hh+tukbJP8^O54PL659x}3AoQ*c1H&j91<^8i@nKTlU7{C zJQnu!PmjYNF;`=r)#sZESU+o@?U#P`ziUX#Yz}hAUeu#N5rCu~b?eJy7~h-xnlqg6 zlE#$LUX%@4L9u@}Uo~fWcR2YYB-alSpWpnCO4uTGyl409=uT$t@k%W4Oy^e>=ZT?> zJ=bU*ojtQnu2ks1oX#ik3e{`-H zpZGXP(8_(U#D>i)9eilgJAWnKHS-r>Jk(r7WL9MDwxhaReK!9e`|-T}L%eWW$@+6- z5P^5#xfyyl5iwJZeq^RN z)7h&&S=kJODZ!E-(2kcV!>V>Fy6NjKBS?TFl)d|DC;}g9V94xs23@yN^18$ z*?hr4vmx-t-24GY@v~(WL4Fs9iL`O6WU{Mv*#M1CCsh0Z2d3Y8*8+50IzxzxyyDR+YJ|i~-4)A16G! zp+&oAYj9)BdJY%+D4D8A?MXf2KNAc8New1gl>8!95wzU%BmpS#d761M0eqtR{;Twen@m;EFknR)jqyy;BNdA?E)nW2mKzS-*DtHoHulcaf6wxEdJohKQcgC$*~>loa$W49 z!qT0(d$iAP^V#}2*9p&Uof4nhQ(eOuq+mEB3HCap_)QK2{%?M|d z3^~{9QT!UeI&*=-uBF6F?(z11v@=#Nq6%5q`lGIgla8X1bn}kxu1c&=;$QkX9yJZE zp@eY%8aY6)py%F+aU?7d9(AJ^U`+K3687lEYWJ7wJyL@-UN-{}D-)}vN#TY^@x-8O zABaaoO!C#e%u#9qjnugIQa4ep+Y}#G4C4adMM{eC@?(Qc|jt_)LKL&cLh;tOlc&j zu_8=T%9vTZKK^E=XrYdwvH*vTZB+?tGnssr>MHr)lCm@rrDN^tlPh2o?sgcnbz=)R zZz8KdzxU&%r>_4Kpt*AzERzk5- z;>{OV)sV~Ce4!lpM~|xH<|KZy5lPE&!ag>|{<6q_Qy;6JwL|=Z5KW$m0N$NBj9VRw z^ACy7j1~}eeAn9yJFV}XDz2EW*s$T;N$$#4tWH#J4+m!@+oiI=Y6!fvb3Mw2w4b?W z-R#X(zKo?=ic%4)lxCFwaRw@}RnK@wWfS-mKU;jcwvTb}u_@Wm13Vd5Aq%W&_$mVU zu~f~u-jo?!MEhnFG7RD~MHuXn@x+WNq=?#CeLYWi@XS!LDdf-T?qZ~6NhUeth4H9O z3{Wud=12t$58wa2HfsLK{Jh!Wd&>3!RBWgD6(41?i&M_7B>(%@%hm@=p*|oT#bKBQ-g@WZ=gkU~;6+?UZ%$Uy>1oQvxh< zlRAZLnB03y_whVWWBcBgEg;>TB=a;jq5$!b#jsD`U;)B)fNfiNNRlR`mVBx{BM?{m zijn~|5Cj(;FBV0YKKS!g!~6$C()TwX->@=ZJaL{f6p#e$TNYjZ_#{S=Lxab1i8bzZ z6tzDa57F0vx>w7RyGgaGm%&}d#!4Bq9ZxBBgpfR6cxOxLhzcm$s2!Gs(4xa{#in>P z^5odue}ntJA?FHyk(U;!BKnAsva;|~IT95EK-lJgre1E$1=8LG17z`zF2wrexi`_p z8tiWd^59XjU#ceAQp*}d`v&eS!IJ!v(!V?eL9>%o9Kdo*Vnd=DIoZq-J2SgY8$DWK z-^cn8^!G&j)ZF3=A+~;J!5|dc9ZHjSreTr;U#_zltSu-q6}u zUOVmLv<{VIm+Y3~a`_}%!(U%Cv`<-k zuY~?pu{Q?kLAyP^zxb@~c*1hfqZA!!U!Fpncja?=0QG69$y^@G-)h4lWb-qLWX!{i z5h?I~CIECu2e2dZ{a|RcQe?GU0`+5C+h=6pP$@mGM1iB|1>!28t(B6bR;*{ip}fxB z?3wS2f!LMmgl+gaMY!6(#K0W~Uv;X6s?icNxxoNN3aU7C`N#hn{?<;tl$^1N3NB+2 z+PkJjUw(9xr2s&0OJ`fB3!llA#{pjSM_RAfFQ-775ehAe$6uw)@H1CNj*xsrqP>2z ze@tCfE1Z4t?>a~W-+4C+bRGe?MA!7V#0^4b;)sMKKl#wh=K7`WrF9OB*^0S+DE* za=d7Z_q*1%c`K>??`BlhrCmze!##cAoBRf@TJgb%eR+MB0ya*2)7JL}im1C}$pQ{P zHh!e0WFWG-n~#zNfWhwcH9Jo)%j?2bdz9!%Z0oq@A?|s;`AU(mIc@UX4d#>j%j^+o z|AyNpE8h1PCzwxkfR)y5@h9h4fxj?uNxdKMb@C`ECJ57}pH?jY_I4wnI6NZV$UJ{$ zqiq9K3*Q_H$Xnm%Ii#lpL;|ksY3YM$lXfO4eceKQ{>kPeKE>UH#tNkFdaG-F@EfiZ z@u>w%<*wCifG26(AyBDidLmmyQMgH2-8|2mu?mOdI%8Bi(j##M)w=Li>eg!>0r^59 z7o0}vJnWo%Rm624b9J3$L;L~__UH9!j!W9evb=W=l%Ti&lT6_`DEh)@>5ivHXtpo< z1zz)bZ=fUf8Qkt&n5s&8UI9@eBt5^|qW1hSzs~jdR%P0ua_t39smCi09lhhkEgMSQ z(T*%$HslG<^MU6*!2Mv>zM%39QHK1amA^5r1NtxSe3z|?NBT;3@4&m>$DE%$$KBt? zp%NPzVjKT+a!)TtkNjfOtacc^qCGswTC9(VE`7F^oO}mAW!{>Aj&xSs)u+YH9))ZE zr0tDMqqK7n{NyTd823+!p4+H?zZUw3jXq;ScZ60UWWx2(w~bB2K_7XCI0n>Zp3Aoy z<_tmgEK$Cgjc5YbOSjL=Z4Cvm$Dc0sGq9##{@ASARA;BamLmSiUhSTy{VFNwaDw{1 zV=-PYFQ>rcd%3cTZ}d@(=zvo+BYo#KsPOnF!v74}fWOBx>Ns>^boIW4Jtn&psd{nI zEgb{(l|m(6c|o+w5iq-DLVtQUdghj?-G{1;vYxNf%+Kqgb98mKl|z;3!qU*+>;x{r z@nsg-iC4x0eZH(}*d0urM$fty`jBbFFWy4u&0&B{=oreD$IPeG5dlvNoj|PLyo+`M z3|a6Izss0vj*b~vNBwZ{>62kseDLpRsu@YQ-W?}1&F*t$x6sfunuN78i_xmoasTuO zaI$nb$}FldXZgEf4B->G}{{`QN1>Vte$`5__RWHG_{BWD~t>iCrz_HWiRDOFovjPX?wV~}Q( z+D3p#zZQVu$v$UbIuG6T&Uarof!|-77Y;qeZ0Ga|I!g%8{4ZZd>2K%{E!?Ea+uNVx zEB>NuZ8ru6AH;3zRI7K4WF7rr;B_JNT(-`gXb35ORHV5qM_wUuJkRl?cFzc77TOCH zUCY&zlEzP6y&?@H9G3d1IlgLp*B;Bq%u9z-?3Ec<$&wyoXhlp3YM#xGXfzU$8{I7L z=pVxcl-ixnnXEdrmhr_YQAj9}2HGj=*C;)&Z}r*CQ)C`FI?eVbVWAsQe`pJDATMzPajeZA3DpdKrM?oz0L&85o zfDu~D|2YZerZG+1;cd_Z@4zv5cSFTHk-Wks3kcl#*0Rh3e9gJ6eFHcaduY0d_Oo;| zi##CB&dk_9b)1qa+t0j6f%xWU&Q~5yn==ye&vEAgY_ARlUad?WE3?Hc@ij$F2)_P4 z+gwU}n9O9jIPed`GWHd28)2{##2=vrSae1bxT7yDKBM$J`+q)(@cKvs`v0@9NuK_-?q- zqY$Zu9r1kkKU)Ll)AnM<`G zE7J@(9+rl#B$bc`Di!y#2+gn-%BWwNDPFC& zx$gbfF-hF1+>LAQ(V_sj>+t{*>n6+_aMS*l$PV?UA6CFFQ=VZ`lP)RCet@QEI3s!th*Ng7P-DVzUp+WsTlb9rXL+(uSA^UcR^-WNT4U$qCu;q4AuL}hg0v^ zb!Wb>xK5pQ4{DVIT8Tif1%kmh$B1HHVpgPHa*ub%!j|1$6d_r?h%v@DR^`x7xM+;XkG=dqhmkx!C~=B}RZk8(9B9&;VA5X4;s` zW^AQKgxI5EtwQoi3S%CR-AII(x~aHhef016^_Z4=%U%1S#UoA=snkaRvl4?KOp(1~ zUq$JXHtqFP;m@rSN9IBYS5&%F6xutQiHhi;SoC%3EA1Gsf;u73k(z`0;qg`9wzbd$=l7E!r%LsgZy7cKJ5&$DjNBsO#43 ze&tNdwf>u*&*26ejvk-#02Q&UZQs7?(24AGS2xdF>~$8dei7ok=6NwOL0~n}cto^U z!ms>W-*-MHUH3RHQKk;M=y=)ib+QzsGtmp{h z@5VM&FZfF%Zps#_vsc|8$zRE+t4!kemTN8hLnr{0P0-9yb%nqM4=Y|`hEkM7iDis@D)lY z(gOTPvM5eC_cKb6da0S4-!~2jr$y-CLROkuJ^@3**sA>BBa%Kto1ji3j-I&Gcw6>Jz%6i^0lge?mcv0I8ZKC5v+Jh_v0o8 z%Q@^F*^-S)i)r^&6?Fe%kyP4tRPI>!_u_~0?hwy#)|2khFG)F8Qn%jc4?kPP?9*l5 z7b`hoY3IX1{#6#u^2|do@vYH92$|DtA5%qwZjrOW!rh-G2Og`-yF52l60eAouSuGH zaeEyP4dj0V1uR;$3#w$5Pm_iy0P7hi*mA?@q}7IrDh^v7j(Cn2og7rOt5uOIsdB~cpCY3wYH&r5I=kHHgVtemEK;%#&~(jJK} z4SRvJYs<7}W}oR|EFdFZ^|O8X#?c16v+i> zyuM9l$;}4CkJXTIU$2DZF+{gQ+$&7w8ks+4OGU^GUgVf|eymI-mXmE`b_d1R%(lFq zeSb}~<=u7^;azrabXjYzTCwKpbLqfd#0A)>IU}9w&9aK0aVg)r@MR_1^75p5e4coo zc51(QZ8^v=3jCs8%u9V84xjOUUg+%S50rznsq-f{obopFDz=Y^lIyJW6*p|7Lwi${636VZ&N0Seuss$r9s`);EIwX1|{Js6p=S8v% zWl!7Bm8zo?B1B|U%-@Um8+$Y`0CYWm%``qMzhU6$jfeU>bp022nc?)tb>c|l4Acit zQNRGyQGUSt+mWm8FS|ShGC+}+(q)t@Sad&EdB}4?n1=jKs}(@H%A+IA0}Aco!F#dn zwIlbR zjmv71kW2L}K3BOW8hED#SU|3~t*=RI1FkH50Dc8lz(5l}Forc_Bux$c`W^?rNZ?d! za|a$v3h?!zM~9P3B$Zp>fb>DG(rvS$;g!|;;jK#qF)*t=0L(k`Ztpj|IYmcmTXsNK zL;wYp27uXZK0#G{D{b#9Pa^J^_e1gW#O{#D0rOD?mCqPc5#SD>fG6Cw*H4t;Q2YLx z88zVC&^VAcDp6{ zI{>h-`&&BYmkWRtVAP7f$3=0lKGem{T?LT)t}#Wn@fz)wvh$Nm>Rjm^pZ+Y>2_p+k zG~Dwk89?y5%7i#E>R}Dl!<)eBZ^hYD)7zmYsRS<#Oc=wzo_L=A-!D#KUalk*fCI}*VgLZJ pZ`erq{{NrnSyvsSl`y*e-of}sH6+Q9`yYSvutgiq7sN}t@dqTY5 zB|^=dL+t_|hKAn@_5xh@@%F!$hsp#4_!LnV*RI$<D#r`F)ddSZ4F=ji{8T{Xxg`_%IFqPHrX!D7wzpLGCdf_Iu zq~+7a4i2?ET6kYiRNHzkG&WXb6)nyR(AI++8;BJ+(!f~Q;rHK+XQ)kh#NwLCyH|u> zuW&1=e;6^>G4wUl7fQL$COjmASL> z{R63yJ>9`2dI-j`J5T2i6ae`{1pWc`*eFmR8?YL5_;BNKhdJIO)~EF}^9AQE_w0*Y zdKRkmTJ;~8=Y|^rmRn@3KB!%U_s)+rACGwwl z<<`pg4sl!PfpQ{Dz&P@^{!kCacjWIw13z! z4v*AV{$x91H+`Ft+(^)aQxN3;HiLl9=m zF2M`j^U}Zk0I9D=9b3D;axTa(pkVCi)9$V5!$~b*C%*YY;8O<&4F4`l^Xr=gnEx;# zf$8yxx4{CIj#YgndpVZl(s`8si7i<$?jIPy(l>S&Y>g?q8E>`W(~L9(EM4L_1L@~9 z88WGY=4yaQZzI+J(S4*;eA9F<{0JWT3>UV{^~A1pt*DUf_3QewX7(qj>zCX%{C8SF zhl2=v#0xZiOMLUf1OdeyDd+wObN9>SSUq~WvY3?e4hdvJ*3to%A5$I)&F`uJ0ATLp zy4`w{-HWlxMuDjDhdJe-yg+GF(Va-ZrjgkHv#vmVr}i_5cj7V0mL{8-qA|8c^}YnP z5IoEFZ43O7o?W+5f{H1?{C_N%)vl)bHgoS_iNiazH4WVRC&k;Rs-zY424Pf{z0Qm=e!WJ0U(W5{myqv`v!9Gj_%D4|y8$uhy`ICw=ON#XmWR0aeE4DF z9Me7}-!bwlrpvn5)|2Ps8gsNI&|~HqxBrpyq}MIaR)BNXC%>l;$%pg!XqyhOwb0YM z5=ih`$t-DoxGmT`ul~zmcX2?a^+PmFd==7fB+j|8%DojOw|9#m&;9Lc__0*P1m!FK zkCvg&5<9h&CiIeTANM&HiMqek9}V{IQ*X)%SoC#1_wSqlt!r|d@j)!0<)81!EAZ@M zhIgN)dvS06)M*XM33-bjJ@EpGJWiWHQV{Z=#&N5?Gf-CaEArOzw@0CHs*tNc=heHbM7 z4&g#DEoyCkMUjZQ>W%!RM$iA*hB9-xP2Uu58c*dBvxFG#LKyUHtuI$kR$GIc;@_)% zFD)J5OnPu&chyRlhiJ~(hZ>3Sa6cecb+1t~u9=U|gypvN;va>yJJ(%-5-!n05=62h6+izy*s<=} z)w2s969DN$g10UZG1BzL0j>IRy5Pw}&eGEv7a?xkPI7vM1%PiC?s&}=oG?=<0a@%i;~zFlDO{YU)AGPd_mChhL<loydHz`1{W-Wb@Fsc`->m9YBoK-M5SM*2k4`q5XG( zhx)>wOt#Y3EZa`lcO$ta^77wTZ}pGLw?yO>Cm=@iBxHW61D-zPp#c1@%B}ipGmYz>>^R6>Q5s3OQtrlp&NAQBO!f$P*j@8%JPM<|NyL>)K5N6ds z+KWRk!3!IaS@TSut!ZD2|MsABYsMZ-3+Zlp;Yhn+h&NMb$dz1s_HDQw1Bl)34-C)Mb9XK4k=EDK zlJ1NP9fS(DQb3lZ<@t7UyT&AX;Pc?l^LDGZf>1vUy}0p!WmA@`-C~JgY*$1|HR_tt ze#k0ZgNF||8zZ&_$>eyG%mg^ewU~DR^V+5SNH<#DDZSS*<9q*{(Jllp?gxzD+6B&C zEHM5#zr?nv^E>V`l=jB8eX zZy~FB2jwWGe918WIRPJZc+HHuc}xegkM8($Eho4gmAt(0q{q^zH)mYMo%s+aBdNdV zklxC>5K{u2x6A~qZg{75*Ul0=L9R@Y4`KT-Om>fChTn4IK=WmM4ln&wN(Vin`ucK9Y3qDG4=FgZo0eTb_cs^3n0?@a< zd)S;0=nP|?<5OQ@dSV}GaILD8v>Q(7Wi@FGFDBNYEF?Ur>-wNSA_{WU7ynp#Nn_Vp zY|C!?I>8zZ$X3hc=q=FO*#MbvlGjbj7Id(%%otkPv~A-XoKcwoLH4~-+_p=z?^)!b)or28AJnLC`nR`U#;3r zzDt1uOv@|KrV$@+37ULsBTL_9^Asb-|6DmUG7ss_taat8Q)c5o`-~xf2IJm0%cjj2 zxJK#iTX0UX20Us;YI1CM%}Vo$nk#V8t$bvbt-183Zx*MBWahesIlP(W_cm8Q`d&#$ zj$Z}kPLj%>yug5duMQ5lYxq@5uLbU8O({;8tKZE{;;jO3t_|aPetNAOJo}riD-+TV zYqZbzs1Hmy$^^HJ%f=$D9*O0FU$$iHyICeO-Awf#HrjtU0Z{xiwyT)VNOT6sb^XFO zf*4b$k6^*7$p8t!#kb~UV;k&m-3VWXsd*gtn4@Kn{81(eJl~ZAPw1}Q+%nesRS1=x z-;E~yFP5rcC44e~defH>UYMT}qv*h>rP=6*A@M1Gbi=2|cvt|N3jbMx8J6z5D?#wu zMxKKYljpjXv$M1g>HL*|nB?Ugd*90uRNV!R5qfK4?DpI?Hfni@7bWCpe5p0og>l^k z6y^HF#Tu}R???Vj_Gje$Q7$SHkeIzmd^`B(**{xd2{`Y{;{^2+ZGC}GFAl5La6yi0 z4GKni?ekFp>P5&Sa(|^r{0p(0UoSQr3$m4pvyun9iwcjDkdzA(p!;Y}_Niz1H-OEY z=e?tIn%dDh5k1pZeps`8$De3v@%7n!SvR2*EC*e4Z#x(ZDo@LvxIX&U44ArA=pOI@ zzTNh|q@*K{ClC`R(DCBAY+0Ut#)VM{j@-Ei#$b2=hCVvfs(dBVrj+9d5kF_{k4tB* ztQH5y8HI@vii`(S5LU};Pd4A>LHP^BdA4hyZ_tto>NFxsf-6_TeX?AKML3vL5|c?g zr%1p^Qf6=G?t7}j5iR2`D~CBz8wMF8sPRG(fmmYfVLarvlDOmplqT?Y}?( zx{0%D6ElQYbt@&iGnPO(eY3{VS3q_C0bconUw?w{)AydQi~At-&fuvV!8%IVKmvbE zd3P(Lk&Ro1A_Hq|_!keD^Sd}@rL8P|ksMj)j6FJW24SbB%dCE-f2X`bsa4Z+C>Cuz=NTlSCJ^qy^x0bS z9(llD@T=x=GxDwD@?WKjjK|n@4Y^9@#F)qu`ZM{&Ue#cb{)$robpBL<@&0%F`o;nG zz5+~lB~!OOC-HF~H=VZrajBQjPBTd`vitPgvsa8gsDI=J{@e=@i_!g4@(lOz?uAvt z-2VDm>d%jqfSF5AF=NbSXL#y#CZ|I1yA48GL3!-t0|HY#=#ga8OQ}jxm3mC-K=x+BI_{Qiiz{ zc!IC*;ayrceQ*B*7f3W*lttA|otDfFti`yq)YhN+DdF4+)3EnMqe)nH%Id3e-d+HT zQ-1ZrHfe5gR%UV5%SSfsw4P+_UCCc)K0}4}QyKFwg=Y1rYU@%8P-OTbYLIx1zkpLU z__M^OWIeVj-`-0dugNKnA&YDo9hguTtl{2cH2|9CV*Do=jeOC=sBuxr5t8~l(XZ(M zvWM9$lI@Qs zIl|>U6YBzF*DAEwDtOJ!4?k}?`VnIqEn~!&Ol8i3@UBkiY_MBxZ^l1rH2TA~xlbEe zv-Gg}?T|AkPaXO?lL-}T6|2ANAJUcJf3e2x`9N@7bUR*R;nE!ABcrZdo39V{yU8Ay zpUHk*g7!5llWQ8R^)KBm?WFI5Kc zm&iCe!(--~9Bhex`py^NZ1*Fa1_pw-)#4>sY93rZh^}NuMzzzjsgrjAU|^O0sar;>$T`EL$sxEX;4IW7xSGl#j?2#IxSo&O>XhT2aS`R;?7Ub%+X|bA~zIg-oBeZ z=9957!@Klu?q1sZKO!Uaa@=fH6=U!#;r=p|IGojzGzksl>$@P>O#VA?+ns>?RLZ0iCKH$MX1xqQolWz~o zMgr!&>^TiO)1_A#r%B7kqp##au-@=G++zay-LhI$c@4fkV_F%YvZgHj!9ZyhUvb8h zd<@5ZqrY-wH_N!?L0Sf>7jS(0Z(jek?T8y4A{Cc661y!zXPVFrGSi`r8Y4AoYB*w$+~~nVI)kRV*efIwOq9 z74m6!8a5#auQ;tZs^;yl#K3ahmG<`d55q2}K0O9kC^oHqpkT3^G96sI*~rS)spc3T z_kswW`22i-k68REn(TTcqx6Ik;@deS>9bq$(Vcg^lGP?Q8s%t!WfsA%(0PjSgn#YF!1W} zMsTvf19>CoE90NR>cK}IktA=Oc3#=rgdMVI(RUPD zJh{1hb4PB4TA^f_A->342MSU-hs3_n$;6H_2|O7E+dM`n$v1Kv+J8GR~Z zG5VJVk2-KI8>3C=?p*BMIq%ktiGFz78Bp>ka6fad1_zS-+L;mbcvF?;bGs;-EmY%rVaXZ)Q6m(xv(5r zHbZd_W?q(j@X-HONqQ-(Kqq6gR&v|KaWv9mbfh_4X87Xh;xy*3<0y)~7FMz3x`0f? zjX?%}D}5?O3^wa48s_iNGD}KW6dsjN)6)j&Oots>6Oj=( zqzG8fTH^f|X`b%)(%liLd$b_%9`ZqcnR)G(@Y)q-6;f)Jn90(>KtT$~n+m1ZhE1bR zyIH^)@Sxs^&PjP5k*^_e5N znPKO}%H4o3=Wz4op*Mah=okU5`6mwP?YnQ*A9(A`H{>W7Mj8kkY+fz|lLk0V2<<9_ zUa!}q#$9Euk>)NG!aq1Yw!Co9-RAA*E{{)-r*uP_JlvW28xfntIbGU?PH1EAtl`P+ z?Uj)e%xYs-{^lAEWd@FN;__TG_l=gm_76I9aaFO3n}V;rsQddI#T(uO#3h@j z1Gc`(huy}s_>o`4G%*{@4W8IQsS#W+d)emRAv7mZzUeG_P@k1+ zDQNy67kEDGHP7lfBzz8(OKs3^?>cmp?sn%H{6YbOcXF9>|0BOQ`fF`iS<*`f3VV;w zUHB>1uLtbS+K7ZSjyJ3v2=OwhO&m=m*tp&Kc1sYyqU^g5@Uyhz#grSC-e)&BgC;hN5jp33XEHb zY^-jAzXAzY{;K5MYGhUFR8PfsliWKwY2lHb|H%!DR8FQv-B*;^z|>H_OLdj0kf=Y& zC@^Ii66M=1hH}x!HVggj6+eHd;pn3 zg}5R*ODY=i1AwZ70s4#&78i?KM%%Pt(97un>BKyrB&9-=+r7_%Hz;bC+< zD+D$pkW_IEFZ;YbWZyL_!y-G}_}uFF8<+%i_6LWXw9VBa`5NEgH?cjtxl>|`f#dmI zQ=ukHu1#hL0T}%K>0b4HerK1YMX~0?C}=4nvMo)@9r9WMZ$v!@d4q6W7J6dkD*?#C zp#QUjhZ}qF0=d8I*!uavs-CA6(|P&6fiw*F)G*g^KCo{(GU5)9I?x?XnXQeCXw+iZ zE|P$R?)Rz!mG?$89O8>Al(W~8tWfyiuA^9Z0vjFhcy*BGfzS;3iJqIV&u1$$dxIL% z-yP>$J>Q@6Bz#G~wa(+&oP*DEC_72gos0YhJrwuuzqXc; zWwKIWcmMKu)`Rw2;l&IU|3BxR@^5O<{{NHG6 z%LZ?7*LaCaZ}B7k0(KhPOE2f%pfKZJT?S_e;w9ikex-<|VrQHJ-ana=ro5siQ?OGE ze+>m%6-m5!I$$EmOd`s#4NbG)12nh4Z3f{l@S0N3u)>#==P?Ou*{7?v@E1<44o7ag zHL~g=x3?p=**iq86O?&KijVkm?(@+-A)3=$(km51CeQKfyn3phK$q3UP1;|Ghhq<; zcTD$l6kxvd`wcJn`T>c90sca4vhEYwp`va zWSD#JkNnrP<%=`UP)Ln>d~^iwR%^!Cwc%slpO5Av3Fk3&3ewFmjVg^jHmg(qp2Z6Skm+=?X1*`9e)oFM4`P(HYlKytR z2_Lk}%Vx86Veo~oH=Nk_@$MxEUP;RnTr-RF&}kkL+xpJZ50~uZh#08~u&NRb(3EeD z2lU?UEhv3mdD7pVy%4#byf}*|DL!kqs5SHsUB}J+T09+OOb;i6>qvpAhR7Pqon(vq zuBi~}*%#oO4s+gcph;T?Md#}a|>AvsE%V-lHY^=Hd_aEbC7x>)#N&>61VeMn6* zb7+eg^x0FP73>aaAZJGWD>qD-0Vzf4E6@ga1-TV3c*qj+YS2134mUi;vPpp{9Gj zzle!O0h(XcRTY$33&tOj z+w|+qX$k1OhR>n7C8i}H^cJ;eP~~zvk@4j5_~nn&+qJyfE;prtKqjrS|H5&mM;!`B zOYHeg@F$v1Tk|)ga}wJG++D9=bItW97*}e{Sr5!q6#>jDHB>p=j}WwEKaA3Emy+hx zF@r6KQhAwGnVZ5{->lhQ%RdmkAz5IW%^ngCjDv@LH_5L=sEM%7fm_MZ3a!+X9h{H{<*&e&trqj&4nDfQn$#m^@1Y%OJ7n5Atd8>h0{pyAatLigqV znoUZW>}ve>WP@`~Zk0v`ps(T;(5gYm;L!Yz2S4l#{7g1Y^H^6;7&UgT;@DWl-tuJ? zJm_li5T~LF+^as62CQ;Z;JHnnaJX}g)qhcamM{Y%qs1oVJU=FF=lxMRsW3_j*!Jdk z*i_fNY*P`;22-9-fZuf(3>R|Fl(6+;!HxK_v`Ir3$ADYLf7!p6FAycGHz@bLDHv3x zQt(BWV!@am4iZ#@8j6-{Xyym9sGMdstDUI3d>?g52 zU(&Hg&2dN09nn8G0V>ZYb8o5hMW^v)r^7fkQA?Sz>L5l=vnq=`s)n{U)U8#VQ=N+# zA%u2BrOG|{qA2IuGjx-^#3cF^=ShTdU5Arr;ZF2)hG76ktdrI?IuiWq6py*xWsmrq zoqB{npiG8@i5y+pUx(CXGX!@!p>R$hr7Z}|2=8~t5se)1O;RrTBQ`Ual=qk7C-j5W zcvU(z1tja7p2SZx2^uEL?4IS`*XK2{!} zTWl`>bN8q?@x+nfCa>PD*i0Jl|4hpk-Hl&<`_|0ge-7u=!damo_Vxr%s1_2 zbW>vVQlZ+LTFO%`I+6Zje&o$PhO}M)6a(2ec5<%d6BN41hMnOl;#c?$8e`*6zv>@o zB9^;m0}y?m8}T6I-<*l63uWczE^rENxEL?bW>`Xlv}?TGnPeFLYhmH+%()(i?oT@Q&S(5$BomZM~~fFBB_uM8BUP*6{Q!f>IfAE`or zOZ!f5kW4YAFat4A9c^-AWOzjkFz^{P%`|+3m8@(1!M*6z1MAdy^z_h^m;1zD}DK3F` zz-#oVs{97v&mkq45;UBn#=Gj*Z|xsT#AsA+NlO}^%cUwD3znZBaeP7!X%3)Zssqyk zX>?jbuIRSv)ze=k)vw#C;P+!}PkSaCQa|$!d+EUW7dE_T6G}e*6UJo@u}%kr3Yjlj zGXvppe?2TB*f02)ZmMUA)(*2Ugm2z~k7L1Zz_~aaYW@90%TmxQL^v=cvr$(q&zBdB zJ%G^TH4bKS)c-mw)89pwJ#GWLcx74wh)OWhtlMzV!aY}P zk4XI_?1)^TQZnQh20Ra*-I|F}f=$Fe#6tb(Y?8SGv@sgP)JW*Hm;l9&>W@o&{RGw8 zjW7&rXdpbBWVQGo)t^^~3@EqP_z!Z(MW$;bk-coe4kMCJVUe~fFTjf-Y+fkN%MB2K z4Y{cGe7S)vbN-1G|09WpnURvBLs_ZPg@Mc5cn;mCnHzvLc+=XwSzJM$PHPut=US=) zt~%j}dnaRuDr7Wjd-87<-jxZHq7F?)Nfg@LVs-ZV&p2Z{^tnpM=-*V)R)e>NhftJm zTNN(>z>w)&Zv?7QiJ}`w7{;+97lwNR;DdG^PYo_CHTNFBWk$Cacj^m4;GBWn=>SYr zu13G-J;8;Ig*`*;u(~Vy2ytlRGTrp~W{!4mf|0kieS8A52?QjkpCB?e*0-{Uvh372 zd1@)P^M;PaihyTUmudvtRi^K)Kw51~pi*UAv+Jf6z=V7b1Sr(DvUbkihbQup>7 zqrXTaFk_?hfniI6JBXNnX7u{vfl;24&bzuMA0;Ji;beZjnjj5q!Dg5COV~8KtX6Bo3li?S`3TqYO-01)= z@etq+{5n09NXJw2HU=#$xkCfK^EOBwT3!kI6gQHPYQ_HR`rWoYz<8y>V!myOorbdL!C zY4U1U*I}WSgW42~Aq_eD!l=x=(`&N4zBe%R-0RvBJio8p*P^>*UdI5~D#Af2M~2}B z3;<29SHsU-ne2}E;^9%8WqjDv=-#85Tc*!Utmsp-GVV(A@0mF}HE42HcP>}F%fWP;7fUXj@_5~%~V z%)un!qmR}>HoX{wYmQs}LNOQmGyq!@Z6X_smJ;csx(DPb4u|(XjXo+hY^wq%1#nU6 zZN#Hd6{c=`EOTlUjL0Ol>N9}o1&le-@YkSp?zZqT> z&qN)Db6w|L>1GnK8_vKOVxXTIUd)RdCC$`K%1AH4l}~#MW;=azWYl$uFGBf)9=RNe zCtwYrzpj=xwdaxjsT3|Gi5Xz$l$9XJRihLJ$Fc@=!P4R`mL~u@l=X9Z27IE=p87r4 z$GUHk!k%H0e@F?`rO$6e*IwrZysH5J`-}42_3*{bmzf2;NEh0Vmn9NMsj}zU=hJWFIj6L#G3|;ff z8iCCQI$MN^W0|Y6B-wF1HBb%~_hz|QCS1WQ(8cLV&Pdp9Ydm~uw;aB>#gr!}7BlA> z#@%OMVh71DZ!CWt+kvcPV;0GXD^96C0&sW5j)v3|t3RtR8lw(+aN`(sKW$hMz@qiXHTyx=`H!P12P9AdV z{-YeLRFM_9=d7goks9Ahgcz*LFjIOIB4j>yP}Jt!ETki%@wfBh`MQ~?1Bi`Ecb#Se zw^bGdLwbxO!P)O$ulubA0-^8a?0Zv>WS<{CJAdS}6o8jF@oFf&ok9C$PX`^3S3jr{ z4;Ip>&NbGnx&kRvGaj{dhc=6JWXRhZ9NAmLtazBB9|!z%LLtD-on=APx{arZK?Y_M z`gje>q3^=W%FPI&aa7PNBjx>3tf7npFJ6RS;uSkiSG^1sVWkmuFWj8`5xM;>1<)KF zI)w}z?4Fyw#22$JI$G2zz8Hd`lI9M6&zPwxLU_ERj?wAmVPn6DaoxIG7%xA4PO;Cy#&4>{*#wxn9+qZw^p()8 zLm{F`!)s|rYi`p|t3Wcc8~?m8oYCX!VaJZ+_z;fA_j6t!1T<9kB>}WUKJ|X{S$=1Z*g!fN$)D?+ z6Nd?8#O=A^dtj9ZG5()(*MYfF=xe9<`k< zvJCYE(jrM@AiIh1B1b}w(9ARNwTWEo00PJ8uQuHDIrp3peVIJnkC5`pKQrn+1l*@PSE0Qe?%#ovEn1r47#gkID!dtZB4|}kA z`t1RBkZ6_Y1it3Gh0ozis`2>caQK;dr2Y^|JFMdMfFQg{O9hY9LoreK3YA&PvNYe{ z&(ikif6uI2iu8Q$HpNIGF)PSU4AMe+ivnf$5IngLEA%?GW4U!drut8L9kkQtD*#o2 z-u$%$z&;-H!cs|ED``Z>nQvY#A~fCV8AgH-{xSx%_^qkvDzS;Oy#>bSeF{{zpuH0H ziYKh;-oAl@mLYb&g~dkw6pR5>%EJ7bEQR`vTvpbj3)*86>|J1OX4DD%-mB`O(!ASwcspE*tU$abj%}%zkaWQBMR8Hl5 zlz-^Kf`A0r{ zF%VIli*67_db~+!Tpg*A5RR8j24TT%cj;z`D8WK>QpPOhL<0UZn>ph5>DjOz=#zp* zfqNn)#4cC#iSGvB=iApOUBYanyh<}+z;?yJPJ)N@<4N9(bS7_>88@tzD9mnYp5RTG zL}%FzdOPu|k~qUr_2WnyDFVpl;&l-0X1UXfe3xd34m( z0b0ZT^fNyebB+DMZLaZ?;11tbvH>^K}&Umm@iGztubg zExb+bG@8dzM&Ml@?SF&eobZq*2eenP_byImqqP4LjY>lKO|a3dfJWBqHuY~C>IaG| zD0J)Bkk5?dVd!^w#lV9|rwT2U|FwR178oLq?@vd)H;)*MnUmkU2`<;*5W|?x3e>95 zqKtScg0mbZmbLI_cJ1!e9%&Y6cIm_d z`KnTW`7>l7XzD|_`)@&$%hQYGVk&1iGU7S{&SH07aoX>fpFO6YF}YSQ-d|nrU(09} zRYm1q3jl-9u@e=U{fyZqeR{?&^517N{lg3s z_YsZ5&Q3(>v?D5Cz z+ZNdQOPoNf4a4$Q?;096>_e+b_q-9{WX+W=l>U!^!P>U;!{~ijjz)iuJ-=0- zMG4$-M;egzQR8>&9$B#te7AMzYw^YS^-hee{2wk$&cq z*Qy80IYbqpC$nwWV=K~VMtJmcNz|}p@+0Q4jmoW z4`A@>B**S&-?>6RJf67!_zRDgkrHGp8h6}UgZt|+F8Nm*{CB*Xf=*U+1yt-mWsA6+ zYi+l3A#acK6@X1SK8-B5#%Rka@EY_^bAEGSJ+`R5lf+&oa|GMz`-q)E{ROY&&ksr$ zEE0KHKO&d-gN}l{>1b-Q>A#JC^2-{~Tu+?A*gBGCkhW6suKBApO%~cOe@227pCB4| z9KXECQ6e>^t;}Gdd6E1(^?>djL!Y>>uqP9ARI`@)qcaDNu|3+x>!Sr`17JV4*jU;X17H)vx( zt8~CE^p8>D-Y`e7(peIdGxNhpaW9OGE;bp?^rxIm2!;+{RG?W7{>~b!UACU8YfIaU zHIE4F`_2z2eY2;~e|7ZGOXhMLYZ;T6^vf19%kbKHaaD~SBJ;8^T{w~n@u`2yZ`Hr@ zi~Ocl&;-<1M=OYwFqKm9-O~V^G^{z7)Gje1=c?YcFP-kw$IbJoa__lD+JSS6-dG4f zZ~{*RB??oe8QAkTBmtkN&0(b;FiSe9rfIe%{5?ipTm|nwW+q{K;xuMyrqbc~P~&*` z7zQM26tl9p5}2$Wyrl%J9rK^kmA^Q|CN~MI;4K7Pom_9QB^5b;1OSd-az)Rw99=k= zv!e{-O@}Fz^VQJ`r0|B#_j#c3ziXEXAe)*rSRZSigON<5gtP45A ziHbv@Dt*Zq34yJ>!O>Hb)z4AQmmz6Evt5-}YC3{|fP^>c`e@{*Wkeum&0YS7LJ?Kf z!p-HfQ^27J#W0DOTe!+!)`ctNhfBtQvb$$I5zs&qIYSQP&Z{nEeNM`l#3YsDlKiHl z_{={xG}CwXG9MG{#J_*j7q__*l^^}d19r>ivP`pEyYLPNWwocn>c~#y@CI|N*+d@`nu>~AG(i=efT?oeKGj28^!o~_IZIF-q4V>PrK_vS!r-Ud*A_&ipEe3&XATpT0kdCv+2RRd@~C_O2maK$*kl&?eNKm(85QaleLu$ecsJXY1EAB`iLe#mLZ+f(o%F*7w{hRlqN==JGPj)lBj9qbk6HY(h zc<^ZllXIps=YK!JjQR1V_(})4cgXDDk=wJ85tfKpuc+YruHV>&&rmbZ$VNl2w9+Kn z?HG2DgL)FHV9Ju1^ZXUh?w4v(cT5G^3<;<2*vz5{zy!jJPAl5MF!6=8!%Xocb{s|RIX6K^y|Q^KbYCbNzEVmXL-OXoz2Juu z)A+b*Coq;qR4b^sfcPThX!IDQq?%ZZXU-9+2WS6gM&Q_G;DSZT#qdSo z2-o4=nq&S&5lr{k7pcHpnT@B5g(|9i1O9kAYC=ELV7}xNu)5R(r7vOaw>FVy`5iHCUL5>Uh|w-mvLzp*Y2Ha%&e`Ep9UjEFlPCJCpA$6?2Wb+qUat-DFG8W z#Sj(zhG-#TEL@(;3Jd1F_rY+fLBT=*l4m5}DEm}Ac>$%hRa~)M%tXDoLJ+JjZXQ>c zi?$Vdufu4S4RtkqJJ`wfke+xkqF4e~`IS)uyZTPC#P!hLiV*%4RLl0jOta%~2m~AO z=;QHd2+C0+s$V5I`$#2)#p1sQm%ACBG)sP~eXairJmYKBQiM4^C_L5Q&Jdt8PXO+J z$PtMVKW7nRe@szs+w#rAt+0^?w;sfZ_y5R{)*uB+yRzt!603~QdJ94}71s68;wGjDZGA(^ z_%`m1XM|?YGHqz*aX8Exwpb~>{JFH86WIf6-Htq$So=<%v;$F>Tw71VnIZ7)yn@&`PB)o4a|yNI=2nF?e$`zS3~5M%mc|=C z+e7iI3=r6He%``?9?}at&6@?;*GQiuJaXZS@qY|gr&UnvmB!rq%cUJ2T<$*6laYVs z?3>|1-)8l__i-W5@>V%vjnS!xwaP3mA2b2Fl&3quZ@udcDuFbn`Djvob#+%2*IC%R z<5;xPdL(j^hG~6GlXvr;BM8^^b#WzHgJo*%yO|?Wdrf(>CWChj0dF|*eRTjMJD{qK;$XmG$qY>H#2yDj=@r4c6{3s`O&>@`t{kQK3 z3VMMxqRXMLqS6M+gS_V&=Q1Z2hYmrO2 z-3w}^+~`IFASp%e>D-&Q@8_Mtw><7H>HSAFy z#bW4f*S+1@)WWxWCpP}xQ#!&V*>~5C^@#MMZ_2%h&x9DC17(~fVd7rO<9;_S4LINA zs<~LO>g?0<4El_2Xo6z7)F(WJ^==U!p+^}H9x|rOWy+rRgqPl*-eEUM6~}Yk6EhAB z`49;3TU?{Yj6}JQ; zG;gH)J~F3dnu?m;G|YtNs6_!DNlgUh3T_qk-~6_&G}NuGs(6w~YOEu zp4cshwb7$kYLot}Hsnpu?&@@b@e_-bsKYNH@7xE@nZdtj@g}n3J8s3q#vg|l)sH6@ zN&}?oXbi9^Kou2u=4gfKA6TOY$2kV_XUNs?;$X-Wuhrk`C6*=oGRuDP!t0DrS%3d1 z-_$?PdxlMU0o9L~2`4cx>NMqELpiSIRoOAD6u}k2k;$k>{3e537sWyKi}0c*=DA_e z(PKl;75Byk7UF2*!oM!-!45><(&7aw#^_~#{MvUqrUHSfP;^f})c%|$r)R`hJ-a|u z$-TY(f%m{I4tqMLgL4sYdy6vHiXz1^-M#D+)~_bVp2b)%@fvzj;*Sz}ueIPNPR?cS zOs=(SP_24QkI<@*o}c~t97rm{ukV9C-MHvM3|!0TrL2B*%SzViD098ys%3B_2)B_} zgV~ALx*io}9HGR=U8r#am>Zo*^NRqfOyu1e$`MPNS(C8^e5LotaQ&f-5Zb!a=T7ua z`fK|q0j`Vw6lmG(cg(z!!;ewNgy8#~!k2#@gZRn)B|3}`N_(R>j|Kl%9 zqHJkVAxojnS}DX3?no%I$0(^}D?(uwDsFd0mTcK764_%$#w=|{2nmD1D9JL$2s4;5 zGru#vKi|LN=W##k@wju&dA-i-I@h_D=k>hOCO%mN%)T5=MZ)tk4uc9*pdxWF&BAHf zhj?i?NTX40cljhIv_C~L+Uv;M-r@@n3;w}RKY?kz`mEI;pe^6nPSkYJ>zC_T)Sk4T z-BewsWr9&Vncd@2hPU8f$h%}u3la=mA?e_Q^*$*3u-q8{z6nkcKO)d^2mjTa_6BL~ z9Y$!3j`R)fr1-mc#MT4WK2>ezCPuPkW?77XvcR66ugAi@1~`_5UX zd;t<4mz!l6>=5gU7C7ZCeIxGs+5$avvwYPUnmY8``9AZE!7DIzwY(mCkgXiT)Rb9m zBv%^c2Obhc4--|lE>AqVqG{up2VEy#XTg374u73==l^$fm%Gw+Dm1upybdUATo==% z{X(4qw@%#hV48l3Bin!*T`^w>durKE8DWQYnq!0Y%BE)>5a> z4w|SlvS%li#07&U1=tsS0b7rbs~PA>{c=kIN($d9=aQk|!%bpp7E}V#0_^Z)EQ7}U zqY{bvJ-z*j_toU*;>H1wkX@zl*WYIKsHp?T0bzi^7e9@l7L1J%Z_ybvkaqaQ{$b?C z2l*$g7>1^~hv?Avjq(HIUl8Tqt9yp%f(k^q1AuOMfw-O6O4;yah0mTGE~UQ6om`Dg zjT~JGD8YOuFPCuSN@h-Rjj94qXtM;z3*;BB(I?AF1527wZDGT%XMZ=Vzn=-cGGsVj zH&P#wYD!+}V;1O}5_N~01#Yo#L7 zO8ZFL%Z=VXct~Eji(+d%|?Ci5SyLiO_c=ZdpUgcJQW%gcSDem$lBIbY6Yx5 z4Sr}WJa(^vH*BDlU!64^7M#1{2K-4DYA||s>oa)9Rd&C?A}`m&WuhkWid&o^iI)0w zsL?6d)Gk;r+0Z7%ss58$_3e|BMYcyW7OA2T+BT>1tW(0rTJ#g?b4U-1;4Q}UX*;}F zJQFUKZTp?J7r*JT?CIFfWTP%u?hvx3VCOl3-g-Fxr1p1KK@yu+2xpK)K$}@(%bsd)&jDkGhce1423GRl7fDY4isW zYP=CQa6Xrd2mlgI(LeSlgylyf5N;MI>U%ife zjaEH1PAOL!f+N`-ryjy|&newm2lXYV@Y6kL`ruU`8|lF)${&v|j1}c^Ia#6G;Ot15 z2vK5nSZpG-WG3>{PX1=2n>#J~mj<6yvk>H6oRx9iwd6?6StYa98%MS>o~=9M*MnG# zASsg3n?L?qU8G5_uk9;0yRVKRZ>Gc8S`+%x>W-ALTbFnIL-C6SzRv)eyxCah8M6x0QFG` z#&-2~X!!-0PEyADC_}^H;WOQs`YP?8(N$p$7?Gq|0etvGX@(fmGEZHEEjS;JEhI9U zp9`kK(;?%?4jt2}rXLqQ6nWL(3T;gCZ2?C_4Ia_?3Gwf)G{|<8xw(Rp3GJ*GtZ70C z90!V>2&&GP58#0fb2#{e>VJ{{@T+&MRfo8YvUrHt&gy+19uJL~?;t>|&CG|aMKWtq z#jpTErw?bms=cqvXRip%ha6Ur#ok=O%!8PuuUFa{5-8wD!(t(DZ2Z5h=)(_`^hz z1kiTizZy!%wJQSUxSI^hS{5h&)|wAqEGo@E@e|tCyy&mt9e4FwcRqn?>2Kocn)S$J zaf=&7LU?$}TZXvBIpQC%`stF~G#0!n+r4yVY}$LuMpi7Gb})>xHqZAl6Lp?gm-W zANK_uMN1b$W&{;S{W*anAA&b&LWy&xnj-RiEfM`P&d1_-0m#b%7`5pe_NF`Mo3*8t z^Pqn>uzaw)N9c+#kW$DpeE$wX*pXH#Ap=3*)C&{o@guB7t6pijp2_lsTG~lBYqEcD z9sWud&E2gs*k^gf==LP)bRHn5jg7WJFvt^QXPM7|QZ zAb8okEV*jS#g5JP#Oq%mX+zd2Ng~Uv-I8^Y>bIXf3=ODYx+EhzFBPV8CW&gBP1Yby*4JfDGP%LPK-{b4wQk}w_NbXFA7&QN_ zkk4e>C;zSj=mdv5rvO=U-}mTw7fr9y#!$Uua@l7# z>45lE5VnjmY;%o``V`y_?Qo>Ro1NpWaWDRGPm-YN;+wNTC_Oq*%@2t*O82d>vE8T8&}+>Y`*}7%R;-QTrMl;hVY_m; zvamJ}B9&I$+Zl2v#xd_cT6TMny%b@eL%K!AhUd4=L>6GFe((ob2${LaIRe|S9{}Cn zSKz%=dxN%nV-mzIx@gASuZ5Lr;8X!Y_n=x?OiA{Lqk)WKd`>aZK@qWnNk}grZ(?1m9ucq{l6utn6yGj`}F@kuWT!3HuY}s_1t$6a-IK)|DD2b$s|!U z({0u;!Jj?EIysQr^S>>HPHZ-_L%Yar^_X2HY)sHS&RSfJ|2yKQ)={Z*IGobmJJ$x7 z3e?+-j3tk1XR?%~pz3J6z#^?z^VDP<;d!zC7@S*eBiJMQObjbERjX+p)~HyZgP zER2a`Pl@xa=n$*{Vrpm7RZ~VLJ_=_BCG~C(Q zY@;(uZm899EE;wT4(j6l6)c21LHcaqzNz*-YNv+&M$@}RVUx74+n2qU|85d=@#OLI zeusDU98aXd>$}aXQAq~t&->w7lAO=3|J(T!Dj&t2eE@X;-^P38uNePtfEq6}P$rxoaVsvXVu+I+X=X z(dc37@!3g4jF1*>+!-9=bAZb&h{Ef6EO^NPyc4)(xP7!e|H*v|M^RD@pz%o(kHh*B zF6x2OBW9l+`tM23HwMf769|}EEPyXO{8X@)u^5K~7lz6d)0<<{stJ8)R+j>Wnt75v zU&M}WDYg>l*ZLA#e+kd;iEBVLNDoNxhD(K17Y72pR*P;Zh5(|kbxZiemgUM+p4l;x zamoWJ@y636Sa~*O*A6fY9bqjBkVZyGh(j1ZNBU*bX(VxQEgLQQwT-h`W);UlEfJ7ea>Qazhr5-@FlXwUJ zc+4T?6D8K;k0ztTD0XNi;eqIaq@eMHeoJE15T%Y|a=xf*P*wMvq81Yug;noNa_LRLhQEN463_ZMgEAdw2z&Lio^nzJ{bhw-3=^`mQfdh zg8zMYvIwwAF3?L5s$q%+b#U)B)du_IFU!u{D4vRU5%oV<$x~#txCO;%cu~NMSu2{I zeD+r#*J|zqb6meh2WQC!P>-SNe5UlewGB^BtMn$owET7)$cCo5rqMgCl6@tC2@LWb zsP>y+VJLWm6y*1yb0;_hfND}TQ>zE{TnMMmw}?4&oI|*^Tu09tJ zNvfbEo6>RoFO#F+<~@>V*VljTfP4?#+ zF5{L!B+Vnwego1;_{LB7ia`12RF>%i%a9qAU7r0i!aA=S$T-b$?0CcHSXtawm6bkD zA(n6&c0mtbP9xK*D`x)@4&4rh9xe_UC1&w{AwV*F>k7g-4^8XtQ~fmXIPlw8Wad+o zt_wcolL3bBRbeZ~YPms+zq9F+qZ~87MusRj|9)SA5;}67ybx3@2*zCpe4LY%9moPw zw{KzCLmj$ihP)M4FZaZLPn5%N;FX@N%+?b!9o9O7Z9XOB>3aZ*F)axa>#CuOK4nz` z^#wV{Zv`laDrR}4y)yQS@$j<=#o2DOqvWc`-zU}(fzI|w@J#j+qbB4gRscaB0pBn1 zFRxqAkky&+sx8YpedAXV%5bqVHW+Zb%;8vV4R!H6d3~5d)$-V6%aE zE$dgU0}TSakTJm{&r{j_L!Q_=N(G#L1?lp%nwZ4`>;cdDo$GH+r1aS=A$`|XOHt^0 zV>~ja#Yrr-;D_0xNmLz=DzUDy6z!eLP&J)bu#71mSkE_k8iTjavU8ihc3MwnwUo28 z?2`_LLmpcdpr-3rf0`G3F?&R%%=2m*um~RxrEe4A(+N%$J%(Tfe@J+~TNv`9bbut< zitreFNP$r&7(4m$(=eJYpn3`dj9woNU)vs_wwpI-3byGiYIYn&sQT`&E54- z7xlsFHMu4J#oo6_Iy!FP^DAT!1>2RMK()3?e0`L8Kdi1``M(1ivFG8$q+>TD*+(KxVy&~w03bDO1vyDMS zi8Z}1NDPuzikhdlyv%oT{Ebaa7RS4nu04VY?ZWZ<;3>BNI#l_EAbTRf^4|(TleTFGB{}U3Ga9CXSqpndl|bElcnMjsqTvvvOoUYRwF#IH&5+@^fyP` zHRnBg@P&I+ve(a+Y)$Q0EO@b*pB_-~OfylW6CZZKD*f#6%gK+Ygu4l8|04>0ZYqWY zqTu@o5WozN1viq?>ch&tZCz5=vFSTK?1Q+&hmVbXBy@4^3Tpq~m<;Qybh&uJ5~KOr z{qCXs=H|uJ1Bc2UHL{vc1n{^m;1UOWxFtv38ysc{_Hcbo{9_5;^Jhflt8gvmvteia zZS0bVrv}-7#mQg-`Nco$p#7EASW`B>fn3YN(#f_gDD4sJ{YJXrxRa|Z>#t(jsxE@| z1=UbwLwe*_K)|d0C-xP-s$|ZCZ`pq%3_Dzap!+OO6_zY)`(26S!@r5|`fKUuOG`l` zL1KvQfHHT!F;ZX?m;jhknY)rxX+=Q;tK~W$B2>dF(%*8zSoN{*bQqdNQ_g0`mIaTs zdH~1)d>U(8=;DbZx8O}jizE)3AO#;8m&N#9FMi$D`QSs|=~Xk54B0HE8n-Hcgj#)H z-PFMhc322h12q^MUm+ZrM_TY;_?i<4Q7#2`cB2zmNp?1bnUg>T77+M z-n3}K1q8dzEcV~4X{&}vtz{?%iI9tYg#;Ftb1ZG+dYR;P%C<*Ii{V%EQmbI4npfLl zHa)}#m$quozN``mFc==E5W9xR^ zz+|=CA=wI@A(woqpW@19aZD(5do)F*ZmQUoXsFo*=!Jt1=3TW|>Y`OobE!D^V^w`|w- zHeO;@!FLnWS9?meF-p#@3Zn~ChfVuu57)1YKPhb=TeL>kQGD;AehXJ7*`+}_X}L{K z_wFL9BG>sz)XBi1jq4ip5KC2RWZX<~^=-;Zz6H+u@~Nu_W83Pm7g4Ad53^U9?iH0Q zvy*=fP6@+5C2f~d7YaN}Dk?^M4DFz80|Yx0H@NoPcQ-`HB!ty=#L7Vgew)@ul51Cjmc37s2d}~)9;S+1LOxfxR!6H~^Yjeo^%$3Wt z5ruEapEK1pBT#G?khtS~=Kg(PuSn@~5n>mY?Tl|Z`1Rz?=D1=X)!pgGx}>w?`>xve(2u0 z$uqK3i8x^Hp(f|4#G0=$W?}t@&fyPyy*mwTa#PkHP^;uuY%~p=l;=-j4V)xO-Jv_f zA3Ww=GJSdnl1`O)rP*Y~`bIP)JPckz9Qc#Vam2>~EC6)#*6<1Ed+Pu|T2?B>hG{ui z)wiY@jT31OdA=nSMwQGQ&q!TL9<%wO*4BpioPKs#NG3;D3L-l+EMzK2F#vcTrkRvs z(CNJ04tt?Ha^z-bsrPV_@2OBb%|zeg7Gt1UICH4dii5f$;1<8BKAhw1e;R-WiUp?w zQ~}X1(zofQ&*X`=l~mVxq~tmyiXUgxH+5G53Wp`a0Cny|f`y*l$wBzR6TTX1dX2n` z0gue8+m`q2@|kERXBmIWxdgo{DhHG_;LK?v*dG}baqwE#Y8XFjWXQWu^KT1jXsQ+n zIR5DG-$rK#qBWCr_|G4}JB=>k3Wf%>HhqY~JgRkyOatVFdnW*aw8ryYc?%Ll$FF7G zXj_l}mH)f&B4b-k_W{w-bPRvUnA}ZpB%K0)Q9y@2(3-v59$D{;w9NxjkP>uCZJ>gQ zV{uC?CNcO~PerO$B@si{JqRj%my^D5Kji#76#xq?QjJYJe5On9TyDM70e>%oeI^yl zES;IPyft5hNUOI~3*n)JHv7%2&QD0#dA*`+UMG(b9pq8HmmZw126(o|N6kZH#ceMa zEnv;M5FdV2ZiRlw{KsC5102~u3^)0RpuV8}lE)+4F#gyBqb9nBPUv?R6cK56eP14v zHfS3Soi*2#=3RVvcqRWf5te>cLg*Cb=bDW583;)zvQQ49*Tvq3m1BMWFme#4Su{D3 zCc}V1hPABr#$_|fPr=c9x#Ayq{@1dh>%bXV0NnONJ6OiETTy3Hw7qlQKB&DNbXn?M zWZsctpAUJnL>7g=t%&E+PyAFDiNgPC4{~)K6`FKi|0qQu&*Y5@HkKyCLi9t$qHdw6 zOPwBu#pxgYXkC0}(zg3#M6X5Xga-GA2-N|~8RA5K8~1&y1ME}^6883|vdDCPd`QIx z-(L;*Ed$}5RSgZ@2g>+CXa6Y;F6ASNGLnE@&st0bi;W*{uxht@BbXZIs#`FFzF^-I ziblG%#1n#y=X%3B7OJ>LcH>DKl>35Qq@eGmfX^Hd+Fqk--loer?m8?j)AdUHSw!U1 zqUP>lQ3SlOExhP)mG-VP{~_2m1Ev@KI?au!oEUuE+fW|rFj#J|#b^ft6a$~r1GJ7i z%m%GxaRO@AdY`{hE%8inPzNniIqdka!8eXj(-7wviNcxPtfKYjyEQ4Zt}`Mf66UIQ z(eO*HL?6n_Ga`OI~$4dMFP(BO>ZVo3}Qz4 zAy|Gb;)W!;?Z+BX_xVHAowL903|ico8}26;%2CzDqUnx>kiP#K_&t4pR8w36{?(5I z2^k0C=_Bu_M?|^@DfU+otb?~&sKv^;dK&VMTFkRjpW`nCup+kODmZtfow0pe9eA1oiX1*{vK zCB<)?6b#jRrQm*SME-*mK)@z;hOVX&>eWpsXY6hy^mkA;l<_q3-%B9?llg3iuL*lIzsCQj~=dRl9J~_5B6Ksr28$8)z{+7rO4Z zCs#TKZp-}G`F#V-tyMFQ%>@t&T2(d(WB&CUjmjT~!MyZf8V+Y;v0f=+F+}qLQD)NS zi+1ZbL^OChtLC2aRPDymE=u%G(PD(<(TQS9f@6X_h@*LNvz6^f8VMyAdSIoG2rSL7 zL&JpIH(MIj5MR3a$~EtuSw(c7_v~9hh%I>G%dZXP5&w#hqY|B zQl_L9Cf7nfKb?w&=Jpj>OVZav0^0Gg-}glHtaDnJLs55|dERH=dk1Qo1!Vtdl&C;B zx-yMvZ>k82E(=55N3Ps?jC?n%DL@i^_XxZ0V9rccazz4lSyQ-Q)U3D;^Z9!mkFX(+ zan#fO$D@L2AK;pp8gMwyLJjeRoK5%V*FpOU^YiE4QS08@g!=U~A{;m0n*ExC1DxEu zodtE|j7Q;TmXZHPs=yEG7re2FZNH{{jcDxM9x-c>lwT;TvDA|Kj!HKvU?@^blFU{IY#zh zDxYm>+VLZj`zi7j|C(lD@_u#gwgRfcdv5+@{?B9dbnaLvo*#4wcKXYeHaBx_`ISD8 z-OGZHF>Cc;J-;_c=lgF7RI~4(b<()yp#t{$;Kqfz4nAP4qRK_qr>+%B7#;AVFwYKQ z)50&XiV+_GwC4QH`5_Kv$Er{Iu?Du|dBcClVtS?4&Md$B#sIhi0nV4;i{&%_O8<8Z zKPYu2wT(cIt;7*kqM7&NiX)B z6i`Wv42JbpznrU^{8|?hy34=4YxLUjo6kx`bB%o;`g9xFxA46Mb77_Ya{phIs&9Q( zMFHh0@u0W>yq0&F<;t6S)fJ{#2=%A#Jk#eVb9~_B4xFA@!2AdXDexF#4}LM}8Xbmx z8gP0Mks%cnOL)hEZ-pt^6xnksb$;}kb7_^?M7^(%1b-)_vg=~*dgfVMxLfF?%XayT zG|Lf6uX!iWY4;XLE>I);|ER_P*b3P<43uU{%7@e8&D+DPDS^qH>?hpanRkd}Wo%7L z>7OB{uIV+N^0NJs5^GWJewNVW`ATI(?Ovu=3ZBQdz%NvOHrr8LmM~upjbOeiZ|@GzBfIQt2cC z_{$pXL#-!c5EZgUdA<-;8Fw&8(O9-5Y%e`UR+DVG_W1V3KR6LqvW%F#d*}`7sMyes zsyo37dTETVKJ!;|BM6MMppV<}>FyGW?R06r+c6l?We{0UguTwD)rbk0f31yp3BJjn zqL5#Y9P$*sqjkvf?58+YT)M@L7cc{Q^Q_uDfoOecZ~!j5Fg*xbZ9LcRjo8@WFZRLs z9@j;@+*|HZ_MPsj^i3_~xK_utkuJtt_)pwv0dk*1wg4czVb%8pN<#bncYl>&w=!}6 z*HR`R&rv!-%@u^yhKNN|>yZHi$;;in%nUL2@VFTQsT!A}Ox>gH#(%%`g&|U#<<{0~ zht{O)mt0H>xaa@Or$Qq5Q2iWtG~oDUZhDbOT1Xt*eh>U=1^IwE7ju|*dEFNuSLZ7% z8-9IHAoQq7@9sF}iCfGXBI+X90~=?|6YFoe9+a%npsr2)Yg&ki(@siG4ocS6dNEm| z0NIQ0ni|l6t<>2|JS&scDD&dHbusAyS~ViYr{ZGT+N>(!Nn^i2P@WCSf4OJbAbk#~ z&Fci9HIaM}UT!K;z1A_ZO3_&Qm(^XsQq3{9lgBK55v8`PvWW&{!qbUMBA1E=BkCqc zRHovoejS!Sc69Dq)ltXNxx@K}Cv-1$6crt#_yFRwm%aqRP}nmIvgbFm3e&Y#u}1_+ zc8k@&^hsFDD8LA+k-DZfT94lxMzC)9%vY6_xqnjy#SW@9g0%Pr7&AF05ba?BnVEcw4gqBI;B?%Otpjk9#T0p(z5< z%h&wQ5r_^j!lj=#;zgG}#xwXC6@~s_e#mc!&%0?(tvwX!NsvX)F8l{&GetzLt(lYs&%@);3MdidBD_I&C*Qq?9CD2 zOkN7;ti_PPW=_x>K~U$MaA(gqa0Q#g2ZvQJ7YzoiT$W0xmJ)Sw7hQPEDT@)gsNMdh z+`kEKN7j@olNFgZ{B;F!)*{{Hhn@=haKV0$gXns)?k@RIvs?Pu!Jse!hFVp3t*xh< ze?Y*TRUFBPx$Bgdg$((DdW)}lFWBqF1>&fit_2lleXQoUM+2IF=eN9#&tvhn&E6MZr;TPfTCnl{asFmpC zJwVU3WDiCih@b>^Rdcm(bJE~+&DT?Gs&OLD~T(&IC(N^FFBiOFQfXe19XScZr5*c*e7y1 z0vepG&=Yy`Js>*x;aI-qRJnq9kZf_*$=_ZHygKKirK&z3nJU3>q5EqhU`q@lcJDvysP${Y#HUS%;FoVI8ix)J`0DuG zPzlpShN7^%{0Qi5;{&zku$Cpabg1-^gDBuz0Jr9a(lMM4N}?}^L)AOX6YU!S{Z}SU zuYMyI^)v%fKuZsERG+nUkEMRCW#amnokp%Nlw-YFnTk~G6_uBffTrw(O*FGn+%8CF zAj+^PT_oyDcUMo$*CG0qIZiqGFwgj6#Wma27rn)TkU&uc=ofd7cbq!8Mfho;e`1>P ztoR0**Nob+o5Wp?AA9yb!xE82$j2?+7g#D#N3h>FGntS&k*uAI_u_mYrH-a za>&rBw*IMj>j0eV%u=P!#_1OEG}F%`K!kH6PhpcVHW;)ZaKyFPP>^7mv!E&8DR+d@ z;fKl_JId|XhjdpiA?E4^=lB{kJGcj(6}`K940(KwB&i24>&up*69I>2`|4fdX>MNU z?J218fw6RIEBkHk(Pw}1G^UnZD%Qd2$=5hv3@rI$aBcc`&U4>wdTK10#m+##I$Tk_o{ob4mj%`OO0>W=7a|&G_sQbFy2)bkznf|E=n3 zHvz6V02-l0(S$907Qr^X=Ldb*Df}*#9~<8>z3CyFV=9$Ue%?b?cQLH7sE;f0Ei!E( z_JkZpTzc`F6r`61G&6mZ$&~r;9qCe~7*&@62OQ`Uw3Rh; zg3tVdY(-w31s4!$_|xuK*BhtIlGhnH`a|WY(NrH-zbOoJ)32I~ck7-*{G6Bc6GugA ztZ#@%oAM|rl3GLn>OJlFo{(+nHi|bSOWkrGbpD@_z~Vvd_{Y(ugY2(Ng;7PI^j8o7j*bf z4+##2D{cP8;76IlU)e^#5%idhg_)A&(fQ+n$<-a2?Ky8FTdsJPXR$Ag+F*Dbrvnjl zQU2{7FE(1sasysi;#G(>9ix+LlFLAHZc1DpUBI<6?8zo+6XRorbaDdsV7*gi5V7W^ zlkO)kUedrH{vBY|xQLVO#RrjiCc%JgU>|~#{TD5f!6q)^y zF>!CL+FoinBt#CPDFN}gG*j`?HpcK&Zp0@#%7;EmP#dQ_pgXr#Qt&Q}I+=FVhjVODa09?`Hgb*&6E)*#*;rRb5r(_| zWQdR092?wo)%;z z3Z_l23iBTG>otuls^5f{2_&uXyRCkd%krsi^gSXb79c?3SnzsP0&n)c!04?u4nSq2yqt%ca4V&S=I z+p(K+Sp@ZTl`1g|49rNYtS$F=E5od_BS39InE|oT>iiPsO&s7p z(Z9J8-`w^`Qw(vaMXn1Fi!m8>huo7cs32;KT*zGC%~o5X2#Nb`5wq3o2<2EtQB57* z{HPLW&qNs2H3<@Yj2caG6RKK2BXtq^xnC#F$@)(Qe26j!)-qnNdwznvlxiH+73B&P zRd%S3PUK0DanlUO^WW!x)EY36Ruf+bMYi{1Lxu$30U@~WScMqxFHXzPE-E2}c0<)$ z7*)_+uN&YE=B#_r@nP?rSd!CkUfQm262YXHy0=;~;~RZ8_?4@RTivsNc7A5_b*@)k zM3HwgAc`+m>3zVvVE6AaEsImNHC1cOMy!dqu=yu>0rosn_+3vFu$|H7O~7+#VKnz) z>ZI1!bC7AvCjfLFNizkf9~3W^m%JZkyC^_q!OwlXJ7NiLiSVtENjz zcF(f7Lot40GqnX__?3*cp@~&Be9$T>IP&D@0EnA;f_*@YsY5|kH%C|LmG;rM^6yOt z^J=gU$3TW~cv7&%M`#bDzxMd8zQU^}1W$VZWO%F!Iep`905qWYL;l&6I&=qLz0#Dl|2rZprq3$KnGJ zx$h+$zdytg?t{JjZ`PvB3v&;)&XmI4la)(bd7njBYOC6ik`|RXf|F>;?DW=+R`ff9 z7Mud)Etf#~K(IsdoNJhw7N-3j7FXV>%Nv`iEQ?i(5Tod*GM}RhD4fsrN#RM;bvkV8 zxPfZEXV|8FVRYF=q(u)Kw9cGa3C2I=U*YMm|06PDg&Jclx$jJyY+9 zs=us#Kb5~x(uW&DCrAi}f1<1o301_++~K>>8~{X%mC0dvnk#WDNa{;c^!F93TErwdUZ%xF`~xA*ma=i{b~W`uW$~x8bpWv zHIvaKi2+Q%ug|j(YgsYQnV+K^V8MWpoygo-!Bgt-fxUi;#;>aUgPTrOOj$Oh00^~x zLrP6)8Ohm9t$EMG$Zx6rGkvV0dZ)vCcj(fd16N`J<&w7p*}5PEe@yqWMSJdi=*leG z^x|U_XhkYtGzhJ`vCTHjc_3&(7QdRP1mgYToW+VZBb(W#PXUm9?xDexHl9Jozk3$5 zJ}PY$3@NTmx}oP<#_XmJp2YLvn!ylL^A@}5N4vB|CI(nes=Tq=%==~4b5W%&>mQU$KLRYPC_LAgsaotcabgG8Q`9r zHH+ls__Ohznkfdid42Wv1H=o^p7y)Fy1SQ&2e@CjJ3k6>Z*{s2tij1F~=WfdtN6_s24Cz<7?>D>>|gt*QcZTuG?x^Gl?&rLA;;rrk7;n(p0J>aJ+Yuw z3IWFMF`3_2c(lnI9wSK_sp_{%!S)%nDpQ#e?VQvh-MI0i<0z-RTYz{Wo1auKkbAx- zC)^+VgU@ruQHzA(KF}d?HEXeU{`C*TaG3b;^G2G}FU5zk71np_E{U)SZMV7;E~xM# z>bA#XA4{g{=^r2j!%Oyvr`+wnx)beURNN5)1E}VNrv` zpiq%rp5!OuPYkS}-WqYECE-MK=Cig?vO}tUpCy55B-h@)(n@Lze zoB0ZU)6rcPe=!{vcCQrC|81Bv4^7^{tD6_#Y;&Kt^o>B+UNbBy^oj~|;-IeBo$I#t z+(8TCeZm#Xiclm`KM;PsA2gH=Yd}#u>@tnC&dalLo3dxsgxLqEc-!%9B+M9X#W8d! zD@sYVwOK&^y&8c-C{B&NNru6- zO!6OY_`12mP}BvL2nEPTWVY8*kTdbshwPG!H{$%hQCl<0u>uH`pQs58(TJP;No;u} zi<{4Ip+;3GN8R3xNB47+N$vj%>=j@MJvG5cfr+wxZ{f@s%;X8*Q zL>3iv#!9_c?X$nRy9Fc4o4xr~<*7*)0yBV1@x9S&4*R6eUd=({YDT^-Hy3b!9LL6XEM=0N;RKJ0S)&TMX z{2@8jM5ScZ_3+H=Uy3T;CZV-N?t`Wc;3v~RX)if}4qR-3$BowwGVKWFRT_G@zaMp7 zg^67PQDid_&aZ}q?9iv#$&;n+9`-zNb$fD-O#=ih?epF_lnz-nq4eJJbLy0=*iM|V zRV8%W5Dz(5(ScnQY@rvP56NQ-+I~D0ch*@gKP0gV_^HRTBP%+fEPc$L@?ZDc!n@zp z%(K4&5Dn;l9U2>iOZg-^v$M!%x9NHKvFDFGr*`zC>Y+_K3gZre^x zudnvv3;bg{UH*By((SZ0C{u0>5&RpsOdbG$acZ)s>gI*dPw@}ul&U5` z4ao)h5k=*dDrRhOJI0Ew;eV9cyyxoDid79Fn}6Yns@APk8H0tY_2*L-Ckk^wQq5DS=-|P!Xb+1$M@p1p zUHy1DCiyS;R`=1chDBPQ=Go!0uz|)oT-tsA|2`ct@lTjH7G=Flmw5-6%@(_5gfH;m zhSJWkS!=Wp;7dqSqei`}83CFCJ)a?FWNmZjUznXOXNW&u6-q1JivYk}SU)jisFNo+gGUtSH*#wzh&3d2x$3G=1c&esc z6XPB{?;C7OGSe0F80Z?k;m*gF2L7Q?h9x-hU)S#wLw5OG-pY^}J&p|)?rxLERY$bE zT++M%(>R(swW6~ag|}uqIJ6*xQl=&xVRG@zArvh2ecv8Pv-z9c(5nlGSE0-Y#|8he zhtvuHz^%{;4yP8NLPR2O*v7+;H%J#f*xgL)nfR2r(Ym9@7m8Nu5!QJ?7V05!&pSSv z{O)--e5z&Y-lB&eGgkgn^J)?~o=vguyxzmSCMn|8~Dl(6wlH!fkN8e%1-j+)a*k9{%g*c>1@1$ z_I)vl>@={m5Kiv#)I{p0x3)-wrHB8=n=H(sx5+ZoAq(@WSZq|2MKEr#Hlg~G$O>L^ zPt%9+!)YgM8%ZEu_@W<;(DYYDq}`S#5^|phz;3hTM!7CNW4Puw52b_BRCh0T4W$tD5F5XSc%m=8%(jOZI^n8 zK`TU6uXC3rEt1j1+hOAwKV&0K%&H!qF&#lfIeHJHcgNOwrJ#m3B6u4(L%9-TxXf}3-^;W2S;E(3v$hjwG zQdo|JS+MGNMWaVQYFQo?vB-)QQl??n9u0kRUCvwK)TG(mJt9V178>UKJKwa061)LN zo+{{ogBVHSu9$Vp{Z{a}tUxjV&{g?nW)f!*bQ>=LSifJZHom=?YS=JZUGf8is(Itc zXX%yGqi!`4q^H7Zp~F>=t&WNmXKc7}Z|QrzllXPz3Y}HpH1=?q04U_o;gxO(*~LcY zbj*JfTk$(=t2ndA9yWDHU_cl9iWzCUr1;-6HbC`ve+i5+{xp_T9TI^tb*01!r?xm{ zdU70ZvZZ*b^TCfv^_670t_He1fj~%Q177s@ zw>>kd4dnVc?OeHzMx5%NR8TwXUbZ>Aj52x2n=nQU+9`*B!A*puW!ktgn||$%8IfgPYH4XS~;MIGgt9hcw_E2S2s{gybt|$;I_m%VvBl z3+0t07)jlXhHV!dHrQy9ZJ@)yq-=BNRs#qe3`Nf{cUPYSMG31e4b6IiH%4DMCVJG9 z{~Fw#?c$8%h8gdGvbelzv2#NH>WrK}CoZiDpGw4gKMbOYm$8|R|b$FV5-M0dv}ED|R;P^IZna46Ye`}>o^ zU#zs4QHT6-LWGdjP1UP5ac)0F1YV-;&#M^z9R#yMbUrDwZiiLkF5A|LsArWcxyg`l^Dj1g z$f47%M5Dq_9J!R!sRppP!uUS?86r_4UV|avbDj`XNMgedpjqA{~)v-ptkeQGa*9+#lpJg%oK zo<9*48!{@W1+j5=6|SBcmmocYtkNOZP4&1_)TvQVf27U`=IH}8?B6}}yJ~C(tv3aM zx*O6WKC6&#wcxsF{K(R1#@`8nY>SV&OCK;fd^~=x~$OU}1tPGc=#>W4Ty*Gb^@_QeLhbUQ6_FXA0 zq=}kDwrq)4DSNh2l$bP_B&IP^$et3)ULi4t?8%JCQue~gHe}yr8)mzo8{_@`?eiBr zcRyv$a-C~C*SVH+*eP9>EaCp<@pduh zosyd%?Du+IfthTD``g7ns8*9-&vo%Xm{d7u`?M>WG~9`AlhC@b+04_*TM*(Hrh`K; zUU<-^3oKOEZ8VLG6HPHuBms^I0un>xS9%KGMGAfg|8Z2u+g=GRJ=$jZu;>0%_jK}? z*Qd_hyHMgRD^Khsw?{YnstLu@ZD zlw#dbB`(>eY3~)W_mXmO^O*vt%*O&Ao>Km|UBd8sn$7bQ#3TR6_n!G_%TcEN!0N== ztbM31bo2)At%V;~#kSf(C|^$PntV!jA1aj_z;&7btR9rA5-<+KJPf(lSB7bI4FWF# z1i$J;f8jt_Vs8`y(O1EHC8*giud~!{W?;X;;^xT@68f0^;IZ?m0!9-Ujdy- zYvZ=&^PIKNgLLn=xvF%=*PfQ>ymG@slrDJlulD9-M(0D-Af2676oIN;<5E{ocS$91 z&sKhbUlsW9U4yfC4v6o@Znk?K;lP=$fBM|R3zeAr;^wIh_T8PjX}@_6J;XL%3+{K0 zdkfbAGbDnx8XH|mkb&qa4NPm1UOW>}qN2KxZW1ek^erxrc>yuvd|IJ2FaHH&s^pxX zUa2pxmuhG2skI7oT<+&_p8li+m~t_)y03U#F(*CxE2bXCHcBe{0f`n`8Z?M|QglQXRq%(T|LS|$?#oT3Z;{5jk z8?0N8jNM-_KvzygMNUlu}8YDeg z8O%K3+e@Ia|4w$%n~I-Rvl~b_iOQewQlwRD|2KbWXlw1kuZbXMb~25&j;Ay?lcLF?z> zL-&fDxw7tXtxNc4FK=H}(xZ?9fmo^NnPc0N3)^@LR11tWpqdij`+gHcq`uaUJFG%4 z;ZdUYe!*WPw)UDPZ2Dm#HiZ`KPJ9WiO(%S3*PO=>iuE~Y`~D*I&yBl4+kE>5L(rY74H>ZvBwO&+Hi&`_$OwVNJ|Qo-E3IuX2c(uG19Dv z!sf)!!B#99r9+7!BADAV!^h@$zG-j8d%*%%8}EG(Ue%*HLuCsJhaXIp4Q$B%B$T9W zMGPTB&2dJMxa6>asZ)#DR)77*?6XYW0)YYtnKCR{^J@DR2__K#v8R9U@sAzGbzyFg zHm{I`||mS+13l_Dq<6Jz>23TeKOw@txd@6Q!7Fs_i}^-E>~DfH~LA>eaC>; zz}`o`$=L$jGXZj3I|toh?;0#LZZ#ija`10u=bM!+F)9BK&PAQ_*(5CF`Il$8kY=AZ1bKh;yClZ`AtIF4 zv=UsMw&kmZK%PDWFL(J|nQ1qVI=XVfTB0X3p8?+Ay-0F_oY@SDg8_dqd+BsTA|rWJ z?^ARCA4=|%zE;>mppt6wIA}?LM_pycgQ>0ENa&0UXlRb~hwkptlZ6)*o>>hwuv^!w zZu^ee94PPx?;DQO#|Nqap);%eS8U@He2>uz>FRFe+J_Zs)iGN^4@kAZsha}TPR2DE zh{-2#QD*l%U1Ao&1J*6>-I5K+F=g<|3Tt16K+!a)KW1H?XMp8z^o7#0w~+Qgpmg6l zakBg3TLA`0!BKNT zhmC!=^<`ZvEn1WkabQ%LMx`@tYisH~jV6A+SU4^^hcGz%PEbpE?V`Rc3|7^sSlRNE))UYVI4+W;^A zRHh>@3(k_|*}2xY=9&0l!G6$njo$x;YVb>tbDU%9LeXZay|Lgrm`D;9Xtx!(05idU zZCYj%t8>=3ymjEwP;IMxz7_DC1{T7xZC)?<9kUe;1S6SYtB^*wmIbrF+-z!Np@G_w z1EewBTKuWacqC}5#7nSWQ`md@4l$!I)B`*&wYJJ0okimvWX@~_&>#@LG>DkM<2KlJ z@-;9Xlb7K8L7WJbSK(*2gWr(A=N0+v*Nnw#@!zVh_KUJJz+i>o_I(bgb&Mdq9uc2E~|%2wvj|mOkSXDB@dw zP>7EBIkwYl-hmHO9>itDggR*#FM=EjMppB;qaqMU(d64*PVT-y!GZ@G{uGmvq8&NL zpD^t#wWZi0XB0K}4z+>-NIZ_3;RD>PoE+P@za>MElZJv2=P%19F6flkaMy{=L^bjt zX)M1|8@KIeOm6Lwfk-ri)d*mmdtieIs-i$3WLw1qu1*?5)S@X)@S}>^j7T% zm5o{&gsYbAUNu(rJ{)w~gO8M1NiS{{VuEI?I2hJDI`_NUaTg{92iB#N-GKnztzZNM z(wDgxvRMEIq=IiP{U(-lrFpSH33D&&t2%zAiTH4NTfahvq`LM7Zr`@DNzDKM`#&%M z-Um3CXB~g1Wx?MaS4FvyM!~do&S}lfkNhsK>?@L#JbWWi$H@Mm*Dz1Hu4kRA&SP#4 z>w{jq1fKKU*Ex7YOp^CupGV}}!!|8V-?VwH<;7^Uje%C>(x25ln*(pae`*$t0JD$o zw+##p?{t0o37pwdLjMOu|L0GU-uqdwSMx3fVD$Nkv8RlF_(=av{vGC)^Z{Q1h zUJuw4IxWlp|9;kiQZ2J{CxN`=ue9kg>lk9lN^SxN)-go}iZ+9c<+Y(4z=B6L3v>kvAn1tSH@z6M*9dva5IC zZEdTl3hS^0fKLtj+ck(Bu@*N$2q`y>`sZ}ve+{`##lQ`!tj1MMfeVcHV@sxdQ)wC{ zCmL#Yplbgex{_-ZfJ2T?Vau>G{uG1Wy;wo2r5xuD;MHBf&>L44?Meca1}Oo+U6bXy zXFz`S&H&a1P^wZM+<*|gGV^|VRFd>guhEd3R`E~Y(4E(b)haNeUa2ix!X{yY29kvl zL%Rgy=wF)~%x5?^?FZsV$h&r!$o>eXBtV!RUkj8YCC2f)pTyTx#+SQ_>uiq{I9Q3d zIYsnev77~o>b>qm!_BB}c!nuwJzp95&U`fyv$cnTjUNWvrJBNU;IzV2oEd=l+`9DG z{G$W^!M!`k>CwoyCGMpk^A=&mw`tQ(zl7Fj`!o1C`w)xn_jYJea2Z7PP1^WC*==gE z88KunDYe9y0pr{d5;^b>RlN-rSl3b=yq4Qz9g7Tt_l+=1GR8Ju>QQ2FbpiL#xyC@< z$SqdGK+CCT#Bekm2j_!KGs=t^dO<`BE@(W8{QSpq!_H_eUV|9HwJ$i z^C^e_lFqSzh!j98TS+Oipwl@}HEI0~?}*`>!QucrS)45vaX{nJBmPzCrH|0Udqe%s zNB$of45##o<5RgI7I;E<0M8U@yl!0JFIE8;KibE+BeO%o7fS*>raMSAm@oQi;ov>m zrlT1z@(<<3eAYm#@cx&o$Rb&w;?e!|PX{a)`vfq^)jovZs`E{nVe7idv!MyXJgxbL zv8O`a$W0cY<+}Dp#NdBKdH#fnBn6uW?^(NWhGxFlHFvHb(#wN3JJaU}4*tf!bF3pk z`zhtfG8L&o_+ukC=yyBeUv}XJ|69tC?o<^$!x!$8&W&AOQZ(;Qg_}Ggoj07>$X;5p z>y{8p*%7-yo(#%T?lGVxze*|FK45u?yf#q3v_TQmMaFT@#@9cSKdydmXGtj?Jeax~ zs%3v}Hqb)IS;QV$4X4Ow%Z=1?#Fih^aP1R0p_YB+35!Thd&;E(mM68RlDGj?s>e?= z%O$%4*Z~Liw2}!~yW7KE5+lqx$dALfeMNW__w4ahNzNPg>prj9cjPU~vx{okZg+|pys8~vG|A5iyxf>k`Y2zId3$jV zp?u_vofLGU=Sssj7A)5eSmWTD8&)~+Iwk@W;kRxVl2NvDisTX%POI2hlXq?z806g< z{LB<;-4dQtjqTUQ{sKHI=@nX1f?w~7h>+hV`DSfc7(!ZNSCIRK#rd>hXRN0qHnup5INMErov`v z7@n|H!5x(os@U)PnrgX1tHH!jJqXos@AEg#;W1GfD;6>gfi61fV@d(cX7~2Sdr(yL z7jlD2UC&C^2M(+8X2Yqt&b*%>-M!z;me67^`E?V zFy&de5(L)+nGzH8+1ej2(N=%Q=T^OR9QPB+1j$sfPDXus3?RG7sK=}rwi zsco3@?xTf9{5&crPUu_G-*1k&o;UaEEeqwyvRz-L@3g-^X+YeI&g};GS`wA`TA3dZ z7@jugoO>~Wz87h`j8D0Qb>m#`RxxfqXUNh7fh~U(R>@z+mpAM}mJ_(=OaxL&V#-2K z(+|~NyGQG>L1`Vnk}t}lQIAy0qAHYVIgQ0(Ep`POu$@#9uK2nL#P5!T@hr~CJ2?G3 zZ{~cGh4TXSQ^jO71!s&+Y)G5A*fN&{Gq_9~`|qPBHuhZboY_cyX`9;BT<4P|UvG9%#8~2V zw$5@!6-lt1R^O;C1AVVfBp3IoGMn~t?J#8=uaczlsDC7;02SbEsins zr#znhg>kpT+WZhOPUDzA_Agx5@ZXxec5r$x$ej@oriBn0#3EI1KqLJpOOkI>N{7Zy zg$XsPWoOJej453~B~4d${J;Y3=RRBeNhF`r)HoO!geI16tt3|=%LZvkQbqI5gaG4o(CdlBn5`+`^s zX}4Eu0Imn=M|ad1XnbHht^wZFvgGd9YfzeSu5eV$ydJqhKz_UXLh#8q)+Tovd}5cl zHz4;HmP;lgB%w68%RR>Ouq$CD(VRNo+{9DkNlT`vl;In{QV==4yHK?FXyx0pH_~7% zc&x((>4d;E{;9G#xn+aN_#Y_~R&5gkwSOo}Z*2XwioiQhw^lv$lPjDuP@Yvoj)+j} z8j3MXuzEd=Xm;jtE^*2A5%iv@oA|J2n_ktyBOD+bX_pg2mnfkeRxRz6xN=kJ3$D%m zB$Grzb%GW5x6VT2NDcXrcWPaWwG3Org3_mr?0UP|B9;|1R5PhZt7^oKGS`BL`rl{O znjc5V%w{f6B8pxIJu;k8=QWQm6n0Eu?T_HTBg!nhc*Z%^{QAc~Mg-xu5z7!2{ylrbXejRrML$J(T%jEdm^Adc!^ThGJ7m#)RLQj>l zt~86IyV|t2g(EPfod=uNhRk3XnmiGd#3FR_p1sw*7i|Qp{xqyfsgwSjT}_ZJPxNfq z!D2EmM5`F<>3hTa<18ku%6{!Ma3XfMo%3EK?bpe3rYzJ;X5t5t;F|@Hu(`$)dW>ut z==haZwKZEyI$}2AOn&3%pzivQuy`ZFlVfGb`9g$4MCOgT|1CzZoSS*`D)!2u{gU_) z9Xs7?CA>cz-I;cc2IO9^1g;V|wTN6Zc}QhMf~5?4Ao&GpdjZKmd(e9^*0$kI@uvZg z&y_+?s~+cUk=XuZemPlCO&pRnm@S*BX&&t*wSm5ezT!P$rc&R&Cf}M9_dZV*Z`Cj` z?W*k?`8KWA15@Y*WEv+F_~aGjOsBIuYHXR(jUv1FskYKGLaCw)RKcs`r2U zkz%?dkpIUlVAwkVon>*lbmddGRBqOJEh~v*uCtjvMF(<@CLN$DvkdW*|GC^CeyTN* zZo7-axX+T8l1slkz7s#Ew@#0hB$E=}b*2~eD6`bf*)04D-ouDaTKwMabTyUva@4Zja;-GtV_&UCdYl=08CB2HJz9QsNR}inF>yY#5+nH-2I_slTYVuGT0S;i z8^nggp0$g(iBq-8kgWmeH`rXS(ucFS0A|8f+9OVu$QqY_0+gYYcsI8|n@=J-5zg>w zIzr;Knao_(2c1#>QPdhA+-|1m)y{b$on255u-i+_X~>7o56bb!-%CgsOES2*>Jh8Mto?*j#b zg0baZvSrvsDf{FhV#<#*`k@<+EgQ4eSa?JH(Jf*GdWF;gF)G?B2VsU$g?5o8oVIXD zLD!1DGQ*~}xTtTgSclo7^p+zl6DExgI5C?1+xG8?WVX)^r`Iu)c;nDh6w*Z|ymxfh1wg5!b4tsvzX2{bq8SOv@sqE?$-Hy9PzNeR-Kwy?=rg zdL&?JqG zNTn3cWrB*tJfi>j)0fJpf?=(QIYdr_bs=z*tQAXm1Se_D=>sXv9IWmd_4>6|9OD)@ ztFn$^a}Z_YpM794f4uWf46jufO2$$bP<&6X&$JP^sR>QXK6AU&_JRlfED!@0ySGQs zaTw=%tMq6nVf^@<&9cAaC)WTjEt=abJx?bs9Mdue+y_4PrA>@4nR2qGJe&2*-SiLC zcUF@tyCmK>*JqBR0d$I)q?ph4l++L2>inVn$f5&(3zz|E1^Qin&%tzQsBhbZx@L3) zz(%$IJK@|lE!zkGkm^}gPhY4gKKnSYPDYD%F65oAqd}GbG7Jl^rM5Hy4=!hu;yjZE2bZRJ{#O|LqUsd4JKGQZ#dT$9v0BjXZ;PiLGN`e-ZuqhL92sy=h1kh zJSd`&0(Dr#l~l_}5Dg#HNx1FN>qA6VU5fT*0yK+OU3eA4s(N8VM1ygHrP_tsLxBmd zx0%7FEm8|{6aEV&yKP&sd!fv3vn%mhhHjrO<7P57Uj2UgczPHp257Gc0~G;f1HRvJ z{W0qMXNn%`vs^=g>azyPuVv6P&ZJR+8OZEdxgB*j*GBaT{vEuEW?Q`AM;wS)Pa=eA zoTU5m)@v*LmD?MCj*F#Jj%Sw~|C7`(koYRK{pSS@pS-7Nt=s*uWvZQjZ^HhOLT6L) zV-4v}x9peK(^gw_X;)Uz9rq9DC=bir=LbS-+d5OuaGkuxTfL3`0$v!W`wwN3KnAQn z+@T1T@7ySbT$Z03JGh|o#^kN923^h5%>0fR6bvQ}$ zD7Ka;2uo_>Gvmb+#9bHO;l}A@M-55oqrJszjU*{W7*>!zK1`ahDg+D)$WeX1|20m$ zI8&dYoxhvo>&b&XKlktWH_YO!zLF)-A~ZzEFy1TgZQ`Wr0`n5)cQVs;zT_z)fGlMk zx$EX6a83IR$5~}|7ETfk6_HbgLysy(URzv=@n{oA{nRKmYH56ed(=B&PwW5Z9v@5I-z+j81ePZh^|*&?&fcc89hPdHn(nLh4#NC7Ew! zX4Pn8k%})x?kMSz6V|Lqgb-N0A|p9Y2G#1ST@s`9=@?mq4xWlT+BfV}*RFTN-ql5? z^d43D`bh_NcxS}YdmVy(Z>!Q!5gYiFN@6|Qtaj2Rn3lR0qd?c5qJ5miD)qEA=6uJV z?2$oxoRo0-#8$Il-Nm(|i;_O*CpQv!`bwJLL;qk;*rIz8&9_bNauLDw@6>?YK~O@h zjzBvLGi+LKw{Sf<(R}WIXl)fbQ1*aPNCA72t)t(5~_1@ZFp?6b|WEIQw zv7X^ay`4??E-L0c@jj)FM%S`)xxbm{0tX*ZW>C};ztyx_xZuNTY-hj;CnZ_WG+!7 zmA|N03;mB0ekTvO6J7cJ5b>vZXDwfp)rXEFjDX(+Oh15|$Q)!`ny+ZYT(5kVO*D7} z2-Vh1v)J?a2VEf@o+k=#oJ)m@U~kZoCBdtMzc18$s@dbS%%ghZPHS2lnl$=3k zQe)zYTaFL3V@@1kujt^^b3S?H@&`Sy|DnUj$Z{O%71=*0@7$G)b@LB0`lD=4o4x!& zQTh7UPP%0ewpUeiJ}SAE-ckwZx1v&BP7qA+{%!63W(!?j>G0{2LXW3Aa%0-`_@C{_ zy{U3L-VW0!#n1E-yw9v!2*2=}Z3^gpxUymb+%=%z?^iBWXhmF?xPJO|pIbTA2RkUc0BnDG*FTs~7ZY4vFSza*0u(j&UTScZDM(Xi)r-^&2x)TUv8J$XTpM_^U$Sa4}-~wxV=B* zbLkV2@-J1}GEhpI_lHHtyXZTwA0u^y~T9_K5SpF^$qp=qW@Gof^J4Z|EFzc$|8>g|&H1DiY|@an;ab~GuZ-Tn z`0Kls@=c>UL-(-A+Ou1!ZcN6E)3_mD`bvkwuM8`pAX!szYbjq4R10Gs^Zxa4?a>T1 zwBwjk@MFH={-2WktV6w%TNZas{E6MDA8OgXx{r98)w26B3-;z`6X113_X^)J!VteX zTd7WjTtGz^(c*Da{IF22)v-_fij>*DO=4T4~MX#UItdhfl=UO-{@j$drX{0yb)?8&M-^XRF< z?8vmGWMqo-Vt?q`4Bc`RRNx{D=pz)xwTAkSvD&f(SGAj~=$rGaZ%|5zl_El++JEO{ zcEsayW)~!`D(sI4%HglXhjI9CF@e7Pxf{S2|1}Rc&{ZnQrrS|=tRv-}Wq7rM;|iBN zjJt*rsW!1TeddhS9+pL?!>JMpf$5`6hTo!QGxaDY^?PFG~mWBy{H%P4#% zXyuF$U!h(HtS%CjCJLB|%-&a_SVb8M?(^vCn+H2Cu-A!ixUbpnNgr3HujSk-zx_;S z9)XJ%pKv2hjnIGuO*hG8_oyJ_aLIig1dRtKz|#>w0{KQeGVceK$AoTqb>s` zPT>bW+__3PQm-KVSAGE<{lsNWjdiRDd;xmtOXQ@yTYuubz^xD8{<3I#w^k^l@|mc{xRRz(igQB9TtxGQ@nq#Z&$M~;NGkC} zb`55H&U8)^t)(Bef8B}Cw9Eg>W z=O-Nx`9?eRaCWrzvAToDJZdC)^YHF+t4qW`+Qb9myt_=5k7;}zEs+?3ezR>LzslBb z{?{hF@dH&X^g)QF9i8?eE~)cMAAX$wcQAg$yaTXlt)qQi=>srbsiyA)kgPC$f!y~$p9Xk$$lR6m( zsvCbGv|Uz`RMu>r$2=$Ax=RSt-_`=(H3yHZwFrCJIse#*3Ui3vFpLp!k1X^t<`Z~T zy$wmh{#LHVCDS*dqCnwX4l&`D``L^4n58XCE0#lGOgMKHG~!}EL`YLFnO=#>pQvw9 zqdam(^(1-SIxYu3q0&BZ4G#3i^KzixUA_~gYz4W-T4BM#C{P%mN4=IHmR$cX$+Hq$DZ z$zEAvO*nVddxc)2LJWTla>${H0?-m8X6|1QgX#C#v)#}eg~}7`gnuLzVXqw4nSzPF z*^n7~1}L)l#Ogz(7jY&M;%|-|oO@BlMeEd92$}=qu@A`>i@EKpZ9wwahIeBNE=_VN zRUl3v^3{d3WVOyaZig=TM!uw+aZSr0z~nfLe?&dLyrcSW+n){8%93L@````6mnHXw zL<(LaS@a+}2$ZoS)RrRTJMGd1F{QdG`S!omdlyG1){<`V8m$WdnRlH?oMBkA81ddS zrXkZn_CR_~PpDwqf#nJs>8#XU8=}Z8?LT1MmV95ZA(G*s_gGqBq?-#T`X}c4%}+1# z2e7T>c1vH{ZnsV}jJqgFvMh1Q_fUaJy$J-@lEnv|tBfayY6I`1`&EV4-x7iRWbSo8 zen43zb&lwgir$N>mzF@ct|q>;gX`U$XkW5Dr50+YXFRt|P<0HWz;_tnV)3~^t=+3^ zvwl~M-|_r+?_(pj2wOZsu*LQeDh zBYCT*DqwYbF}R;nlXS5f+@_#qjkTw5wG0D~NrZ#PphS2obbL5X@GsK2^A_KM3kf1V zkdV@_JT%7r>jLf>+CjmZx<`bcZmZ2-?3HkR7t z$rfbzlkqJz>#z?1gSl`FWBv`v3BI`4fEL*>k;YCu-Vl8KgB24T0jifM@m5W|qn?K}j(ES2( z5_a;L^Vst;+t_`mIxj^e^BO$X50zh+Sa|2B>m`*2qn3+D$Okj}ZzM^sE$ncao(bVM%9Vl#v^qUX(~MN=ujLn#5`AV2kqxj*v>p?%6ms^nP;*FT-v!dFmqO>(i1Yv5qGE<+p;{n#Qzf1IAC$^VBfBvXMRxl)%w1s|Y*9#p= zI%DWYF8R%C+_oWb*9&uMtjOFrO6}QSG6&!QP@2M-2reuuFGh#cKLbBkid0yF#lfrS z({osd)(nQ~@cg`AzO`S|@PK@?@Vva^x_sia8uB{X>0?^sXE~yy2bh?vWrSQJ~6h@T$}cDIBn#B!@D3bQW-mSs23FjM@7o(&nNED zP;GPgaOJJ;K3Id@1$UR9VnrHjXwDle??HvbiZj@J1vZTwNtJOyyo44ax`7#qb#>oxstVI%1%)=S@0Q7 z>J!8L`G(7aFbh6&%iaQv>0uH{iF0T$|7C-G@N(JOmxOR)9GB|2n6B}0zDlSxI#-Q` zi!{cy-WZ)7E4%X-uE|KEEb+58$b;3w^X4&xu|*;@TQ|U+>rp@Pehtu-ucGv!cY!Ek z2h;5|I#cd~NAuO{^Tfi@TILNeBmYI`Yi1Fw)!Q6vR_yGTPc%fX3cjJpv`9O<#gY|zGrDPR&WY97?viZp)+y`; zHS>7@OF@(?fRR_zQHwV~D7X=7E4miSjxUi;{S=)c9)r?tfL^(WCf^5DW9jOAY9v?V zLShEfq(kAB^YjF0U@%ati@8JC-Y~kTx52g$1x92;`(1-T7LoXgPX)7iu$APjV8$&< zgVNVE6vgdzgM-;UPd+@F$F{}y$4o)6QK*u1)7SO!VXRECXu{+`8_UIFe&TSjgG z@yL1Nvqj^o)z^9i>3HI;yyH`qq6?1^-r*+Et7>1e{AdAw1k*2Td4hCbc1zY?QF)My z0-R72rWMitl<5DlS!Jj>^RB{;lUt>WLaeK@u4fo9smT-%kEfSgl)W2ZP+~a4mGPdk zbeJm1vfa*^9%I7r@L(Vf%=f%Z)p{6yK2>6AQ01(;^^30XqjwJ|fjK$OB>N9cbX*Z8 z@>fNCWRisIeIb4K{)|EsMS6?}=w;=h+|>mt5WdgWpnF)TKYNu9D0=75Vt+J8aoL!S z+QDNtg39X(ZT(8ieu`*aa9q#r0gou}(#AHdSmA?v@mJ|HjOn!aSL>vA*ge2!w3fko z+zRnd>Heu}s7MR_Ag;8~6FsnHHM%Ti&axyxA2?UN#eI;Z4}w6ZEb3O_ zH<4-CkKk%8siNNmaOm!Yt3IcrRb+Z)2@j7?+Nsy~ATp%}-uo{r#lZDwEx%(zv9&DP zv~-Ble=7jk5(Lsj+?=DWpWOqrA#*yvXvx!BstD#((?58_>kTH~`fH#TxIr%#_tn6#n5HF=ArM6uxGst&d*(6cX8zGdCUG|K?`42vhBZ&zYbo`D{#ac<}_;ujeSn()X(5<#t*JG-H z8Q*y}u{n%m|DW;Idxw^?z2j)9n{lX>x!c2pfD2tzsMNN(S0JoKE=hZBJ_#IJ^5@}a z2tC#5!G?3Ss3nM`P8Txmu0BcBi;$w90GSH&y`oU(C4s(C-Yd+DKktXPiPy)GRAo~g1;N@OsE=6A6` z*gH7aYihz|xGBX@Is-B>COzJD>%R!F`lMI7UMFJr>Aj|wylKQ%oJ>njn0W6g+t1pF z?>)gcy8Y0`QPU*l=6-XOo*Iu|v})tK4t-;O9~(PIR7G*M6`c`8z8&}CP94w z%t^(bBR4pY@I1|R&77%G-}VjkNNLzc0fs=|QP z*BE!k3%+f6v5C4$&z;=xCTSg`Xi`rYf6bEf`0nOTOjf?NEZYmUd~jM7O0c{d@I%U0 z<(T|I;frSeEzS-5-#p~&L<9Y>)oDbBmM|Sm~cQ5 zXK{!_&EL2wH;jm5^LC)QvvGjY&hnWR9EHb~vmHDl5|XNyTS6l3f2|b#$Gz@JhQ*%P znH#agx629121M@1g4EtTTL8so!wQuYa5s!xk6`AklcoXGQWoW>)DydDHqpxwD^hG} zl>*=FG$^#U>vUZ;IU(0 z=as6Kartfbc$)1bz4&e#H(Ie9d`SU}lW|Rj4zMhn?!See+UlW7`WH_sjoWoUXfI$) zV3vbvj>3zFi)GjL!OJ&Uu0SM)`tW#!AkWtNAxG`z5m3;T%!|+&#aon!sCdS ze7zmtnkI#;Vc}N~G4SydugpsVzS2FFowisuu;iNZ!^0ahjimFAI9<;Qj6u9wnZ!xy z>(`pNUY^+l&)LkR9|x|I2uj)E425;L1jl~5=Z~Gvr!S7bQP2(j+HcO`av894{$3ut z?qkw2{5D*w7e;&)8btm|{c@J*RSXg-*DbAK9LXmub`(jl^-l6_%#~3NS zOEX+C&h4F*NJ}t{a+9eUvOe#CV2HgkOD!m!1bU|-%|B40e-aw{sJPhpYl{9u*LsNO zj;@#7O~e1f&(#tz#=}~HXa3jYBn_6+)a^Zs(hqR^pcnS-h-;eK$?N#xNg2!bqy#W> z>bN+A;-;|eh%h2Um}FU8b(m81BlG0-MC9>VjM7uR`~-r_eOS$^$vfU7@9~CV9~0oY zOSwZ}a8u$JaG7`|itUoVcJ=w4SuL6|$&YZat#7z!J1$x^*s9N^e;~GvfM%8)ypCWs zYdwq)g7dNaas>o>@9>FD&stpaN=_=h0B?lpsMNn%lU%4@>Wg`tR$vB3i*l|Tff@F9 zNLuNhvO1bOoj&%M^DkUGRoQq>caEW9D?1~g(Wf_&p+>iYm}&2NTZSKGEUd;@%6zdgsfya7fEJ?35=X$8f)5FKA!Z5x6lXK*1-5>E5Y5cUi`) z4X5y9Y4}WPfXUU}fjH&NgM%*(R%$T%Q0*}#xc!wmo!USPc)B}ZIxUkfN}k`l^O4tlWltnR9J|Esb5^5`uB)FFh2#K z>cw^bb1Abg%{S=5T>eH-5)wQ6-(qe#$#x%kx^EBT+^5xD_jVM~Iqbn~qrkSN8@wiq zkEe~<8kz}ewC1S`nb_7xhKN`XM=&WfZS6`h!GFVY1^a6+;{sdYl^qxj^}b`@BNm$0 z8E&|shbBA}s~ip-uSn@xGD=N!=C`~gtQhnaEW19Uu&uFDZ){!@#D?%brr1viuq4KC z9L3i()iSq0v)xDJ22FG81tw(xbm{O{Ql={))%RKY$4;lV5u1t7?kgdEh<2?E_Wk<7 z@Zx=P;Wg&V&X7WkW*aT52DC{mCueAjPnin|b+nJ&{GMtoSyM`XJp$t%GQsdHnA<&C zF$rII9j)AnjcVioU)OcCB11 zEzYzkZ&-sWowh;HUor#yw3e5!MaZ{p_z=kIqU-^%Ba7%g9B<)+;%o$kY;_1W1A%P3 z+*7YRWbpUqKh$?h>dfv({NPbem?pb+#enEXO_N(;%NJ$sKJsS)dP3z|MLy%ipK->t zS@4Eg8=oMkK74Ly3DZPc@ucgTbTF_4rsZ+5tj7I}aL6={$*Gpf{`HuT73as6#E+&* z+IL4ql!`q9D!~_2*RF69xfu(U4W?%^bs-S;ee9bWtUy`)aP_PCVbIjaKb(^e3uQED z{2eA=5*L|n`2Iffp7p!{reRI??Qi8@B=7aECVU1%VFeZnV$2o?h<8!hoOtCn$ALJZ z?)Lb*a;(?QPdTe8@vUd&N$x#u;hTQ+=(=U|=FYbY*j#-FuhZJ>BcQ+4r~1xRBt(G@ z*pryy$yU3?c;3eta7Fs{o72L0FNsTCq8isf{yJ1v0J=WN)4j3}=PocxT`}4q>sGrw z-x?vXTjLRxzO#D<5(0~gc}2X?bv z`v*XM=b9YQdRIdN3skTQ@`-~jZAPldv-BMezKPR z)s6c2mR7-!` zsi8-Y-v|V<5a0wNK%{zXF{zP1VY2UxUj1_^z2F7G8Dw9x*iHhG6G#$?p|A9eP_iy9 zA?waM^Mz5_gxSAGl<40i!}N13z(6mX!mkK(&;xmkjNYiama6E~@N7MfksVod7RtwK zD+xKwYN;g#xdYaX$WmG~7<##iiRFZN4W^aU=`EE7)+Ik>JNPu+mgu)?xu_kBx=Iuf zT;=NYGBy`-6u(ZTgv4zR6L{b>$-H;WYbh zNNCeA#emkG-@RbITYp?>&!fZ&;Li1@eAl-}fw<-OLcqnv*MfI9QrxBeiG7U)YI5j& zPka~X)5YnkZ?~(4Si3?zgJ~yYfLL@uqu9GU2Z))vqm-*AySBNc+S0RdH^jQU?JFJJ z90kG9D|FqQlb&zzAwgduF6;1UxHk|GXK0)3dqjGB$RRe-+Ym1B^w2>#hN*nr^x+Ph zwmS<4_QxyFLC!;o!7E9wWPRc-!zT%qM$+D9obKjlR-Iymh@oQJON6w}wnFwl+K{cd zV?f_GUuYQ3^s1lHW8`0MEK?`F6xuF@4a~hGx*KFdy9}+gTBOoSW76VGy;TX*> ziU22eel^9+B;)6*95)NJ?|1khDW0H50slkqTbcmF^VX+ta<1aF@u-}AhTCld`NSZb zg=X`)32;t8P)&5n?(jsw-p7u_#s{(X3TqQmhy(y$`KG*KE-eVu3|`a^IK zXF!$rUQmzS-i{$8<9N1oQEK?!O&_5icyb`T1fCAu_8Si$v03@nsSY9OG(CT^Se57i zIly9i`@@%a1uoBr-elmL7zJRKSC(z~Cmi=ecMdnA@V4F_!)+@sPJ@ z_nSsMqlqvyf)oP}*|u;PIT7aR1g4_g$m;Sp~l1hKDsKO(eEk+4G=t*$TS715v+4vsMPw z5hdEFBlp?*k zZIn1UbV6Pn79a`CCp7lYD2dbb-tHkqu=Ir|-z;u-^;_Ps2OOgs#?~5*Q%&e?*&gco z!se9!LQ)_KRXfrU?e`=1;OKk;ih*!Iacp0kPz^zdLB_KLJ5Q?k^1Um(GtW%62tL6= zK)S#kNTOo%{-E0@9TpdD5$Y~!JU-hi9Qq|54oRGu8&E_9zv2&W1QXNXXB^nSZC#cE zvF0>@OawQ+LOZr5Pwv82Rm^Yq;*67kWIZpFKs07Q^PMk83rC>17h5#vZw?`UJl?vo z3w)4Fw}}g)KO81;t6w{MwF$!zAj@i@w)^k>%9bAd=*!WIBFoR94)+#%IXtfxv+&pW z1L8R+hxsX{*5@8o<=d^5eeW=@;x_uK<%?VnR9g-Hv%elwj&6*j^5P*;gy zCPXJsiH9V)J&TOf?m&x-3Z>B!Wb7}5XW}271Ez z37NCKg)8XmGmo}ATYpRCg*ev~oK2`QOfw1n8?yapQC*&0I!cEIZO=MBJ>GJ8>LYBe zpQ(Ry`^U52B_WTc0}G|5b98ZMi40qP|M^veBV|tz+d5LTZ4d$JymNIWNOXeh=SJKI zH_xi=g&Fp|XFIqOWO}11V7kz0{cYzXX!+pwpnQqWkmGv?v|Z*C{y(P9JRZvLjr+H) z2%&@!*(yq?#AGX5O0rdCjj69PG@-JLnNiudNeSN)Ns_{tvSu0CM%l|&WNg_P!pxX4 zb3e!T_dKuHGk@}WojLbC=UnGH*Y&yH*YPVwb?FywooLAUg8JV-OO&Q8q26P>Zm%B~ zVlM{0mYDLOS%>1ao&WoHecvuwsM!8Zg(1=TeS=aiv9&TlK*!)hkM1e?ooD~|&6U@R z%LK0#mC&E;;EMY!VRTBv{`Y2}HNz*1uf`Yk$I41Dp{{Aa~(lcynzhXQg^tNjb%;ecp**Pakc(`qJJ?Gvo?3GSwU zC#ikHf5m9`SM;xc+l%q5d4f7Ltj`N2{`jr+P8;?cz}28=TLcL9?!X;1JzM;R;TThD z!e%#gN9C5vgFa1GDN2J2m!R;yWz^YU@IV>*FBaHh?e^!}K;!3@7TMkJLDt)ENRqEP zO%ytIj5}CPBIV7p)FI;u*D4P>1TxKC}5m z$6!Zy_Bn(Q3`*;nl$telE$-u3#RN$~M)f}Z;@8dmy2_&;+}W<~?7k&7=U*^r{o&|% zi1pBra*eI?U0R}VXS_NuRIj||v6c3j{&T6_wvnruP0i%v2G3mV^md!fIdihlR(V2n zZxGgmN_7w$S(^S}DV%hLxv}KN{fEJfG2i01X3E4vb`B#0>Pq8?iMQqFz8JQ&WE-Tb zD{!PnR$Qj_Pu#ynb;(W`EO&c!c4-~=Q;cQ;MKBw1pD$5CGRAWzdpcyM!$9j;){tU? zNNngvqrdXXiHA8W&(Oh(_@!Uel&4CgQDFLRI;g#nA4b$merV+UiMQoR{+>gS_6f0+ zV|=HXVK#jA-EQyYpk+ZGD0UCy#~*gxa9Q?awgT)nX53$~Rt5wZDJEC+PLt>1N)&88 zz!gL65pH4vN5$!@$}QU?s@>!(PcELGVc&I7->2K+zmc_^#Rr{uy&Kkef`5a;+h2_0 z7M4)$m*g&q@eRGfU&_JCtyKq2#LdfLQk#s+7sY3%X*vxjcrm?*TVoAyrO5zXWI&_N zB|_Q2#MD1aK|!rw&j5*mc!=27ssqMi4(3<(tYa`@F|U;%sQBSUezH=&8t25MXZkwk z$cj8BCB}Qxa-4jIt5Z&Z+4~tuqI$Tnzun^EOF?&r(5n8~-f`I|o$!ebbo67Z_v*~oU z5@OGWqe=vKU)Z3TmtxpDhjIEsE4xmI@1a$=#RD_#n89znVl&e2faXJ7LjnULhu^Xkj}_lq&(zS1tF4avWD~+H9@SHt{4%htgDC7Ry(3b z`;*#r{x;QN3@0zcUUISU(_)t0N+yo1&4-Q5iDGOn_SN01j)LZsmRE~!B^MUwa!S$J zm6YjWZN7z=xZYeE%8$@&nxigT8J6NVK=L7Z(d?L;j=`pise_@tZf;jhGN7`xv zx!Vv?vVtcZMvHLr3F6&ye@y0n0V`3oG>DJfE_%&%Z5(cMcYFlTbEa~5F~$VXN!+1Y zv99!1?l@QKT>f^e@j=R{45c+Ze6r_^Lh&|nKvF$i==F+t>r@kdu&knbe!ZfaMnZ4x z3;|P#`rTtmW*6Q~$5ZUyP02`#vwXY<*xJj#2A#QOnMFUhR?@WOAP3BlJs7mx-i}f`~YYEndxnm;>TdF;Z?=I8( zJWn^dPxZ_O9#DN0=>}i@k6N_LCP9|Rh7fAaj7SIs!xR| zP+R(hfdSB;0=*s@x`p!k;@fNL+u|+u&Dg93f2Q}{&La01;T|eM*FssDcu_Wd4zZVS z9bN}&wDN>u`1Z2tuS8tInl9}I!_>InN%eM!{x(jHM7{LJDw8*YM15S{8osn^VgosU zUpH-26k&0avvozT)IRSEF)6+C$G)QrX*#+1C1(WE`V6ewx#C>uzl3Nh?>p5+s`|6m zWz@B7d8cBWx@YN!T76egIj+mU zd42##LUn2LLr2WAcdYFWbeZc)e1Hh$Zm&Wc)_^jIKLh!J912Te+tkb|og?44!Qz-J z^dqd+lRE4jGr2#CIgd~uw#sbNZEo>HS!=g0)ZWfxyR+C&F}M+x((1f1^ZB-syk)5+}?QJy5sn*6){uFi)qi4GwUr;ZN z3co>hRnKq0-r!;zVSOgbJo%j|)8zV*LQcluNvGabk4dCn(F@Au`K{R2!iO2Bu|m}4 z7mv$fr<6aDn{I{s$FiUO*tmHj;_bF@MHT4u@e{+IjsYI?aB@_`;hg#k|8iRmb1GS3 z2e%~KoZc2{^pD`OrR}-I-JJty+kINP+ml`~37_d+jF;=y;XXW2y;@0`EL7H9q=+ug zL0a{C9#fl^8L_k3=TnI9bY`*MrsvJyKC}+oHj||09lrnzB0!#c#md_Vw?YN#|17sZ zjUFz$_@G~~+jofEO_aHF5VA{Jg3ps3>y-1~DQSh*9@=RH>+n6GNe>-J!8&+sxK`0Z z2}#n%=?^BNC?~^5@RiiTDy!?stbIi-)|w(rPf)7}oX%_0eqSJMRoTUL0NVUzIE9=B zP29zjKJ{W&{vNoE8mESCTwXwhEcvjeHCTPf;Q8l5_>^$3Y$Shd=<@l!vdEhkYJ2)u zH}@j;8sUno50GP=L|^U1_K+2y{$N-iqBlOqU~Cl8e{w>^j5bJlJV>_4Ucn}RLR=vH zoS(=>wA2MJDX*T_yq9tOEXF4H5Z7e@_o=!L#xk($TlMY>8N2xE?F2DLF1V{|iZ~M8 z5052XSXiHEr50d=k}>CKQK4MDv8ziKxLKK5 z{G}^+V8%&@7i!Oii+fJU?SW;DQg@ZoWrUxKv7bBFiPGcNg(-yfr=!eW?J9|uR*TZ} z=pZ92L(Tn}6Lyl3cz1i`B3U#4)B(7ZT^F|H$r)HU@i0GeRx{<6Cr(p`{R;-pQ;z<` zwGaBt83t11WXY4itXtg9Q{?0@bBN^HljYcNh&tz7XGgmtYT!oM^2>N`SDSh--fBTn zdC0qq!n*LAc-^y>Zq_EOu!|jqpBJSws_9@2bw!OwKu>4L_%(wm+qSQQ*Q)-SJ!8aZ z&|?Tv&s6`GoX6{@fo3EbNJ?@MByOMx|28;591Qm!P%E<;a;m0h-=jK`4{y!*1~}Kf zQv^_n?z?8~5=ep(Ql&K`j>)cJwMgRcublAiw`*;Fv+(2S*tw31FuE=t4&qRK1xx+_ z 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 C%k(b* literal 0 HcmV?d00001 diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..a1e19d3 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "AppIcon_Middle.png", + "idiom" : "vision", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CLAUDE.md b/CLAUDE.md index c8e9b1f..e79cefd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,14 +390,24 @@ conditional: visionOS has **no draggable split-view divider** — macOS and iPadOS 26 both do — so the workspace column is stuck at its `ideal` for good while the canvas takes the rest of a wide window. That is what set the ideal at 440; the measurement is in `App/Views/CLAUDE.md`. -Two things are known-missing rather than done. **`hoverEffect` crashes** -there — `.automatic` as well as `.highlight`, a `swift_release` segfault inside +One thing is known-missing rather than done: **`hoverEffect` crashes** there — +`.automatic` as well as `.highlight`, a `swift_release` segfault inside SwiftUI's update of `PaletteEntryButton.body`, before a window appears — so -`pointerHover()` stays iOS-only and says so. And **there is no app icon**: -visionOS wants a circular layered icon, Icon Composer only knows squares (plus -watchOS circles), so `AppIcon.icon` produces nothing for it and the system -placeholder is what shows on the Home View. That needs artwork and an -`AppIcon.solidimagestack`, and it is what stands between this and shipping. +`pointerHover()` stays iOS-only and says so. + +**The app icon comes from a second, differently-shaped source.** visionOS wants +a circular layered icon and Icon Composer writes only squares (plus watchOS +circles), so `AppIcon.icon` produces nothing for it — +`Assets.xcassets/AppIcon.solidimagestack` does, three +`.solidimagestacklayer`s (Front / Middle / Back) of 1024×1024 at the `vision` +idiom. The two carry the same name on purpose and do **not** collide: +`ASSETCATALOG_COMPILER_APPICON_NAME` is `AppIcon` for every platform and actool +routes by idiom, so the visionOS `Assets.car` gets a `SolidImageStack` and no +`IconImageStack`, iOS gets the reverse, and macOS still gets `AppIcon.icns`. +Check a change here in the *built* product rather than in Xcode — `assetutil +--info` on each platform's `Assets.car` — because a stack that never made it in +fails the same silent way a missing one does: the system's placeholder, which +looks like a plain app that hasn't been styled yet. **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. From 60a78c73a8763ccf635c2de31194e0195bfa2a8e Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 12 Aug 2026 20:27:45 +0900 Subject: [PATCH 04/33] Polish the three things visionOS got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Localizable.xcstrings | 10 ++++++ App/Views/CLAUDE.md | 44 +++++++++++++++++++++++++ App/Views/CodePane.swift | 64 ++++++++++++++++++++++++++++++------- App/Views/PaletteView.swift | 55 +++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 12 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 2ad62fa..b5ea776 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -704,6 +704,16 @@ } } }, + "Open Another Drawing" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ほかの えを ひらく" + } + } + } + }, "Order" : { "localizations" : { "ja" : { diff --git a/App/Views/CLAUDE.md b/App/Views/CLAUDE.md index 02f3066..308bd2e 100644 --- a/App/Views/CLAUDE.md +++ b/App/Views/CLAUDE.md @@ -66,6 +66,50 @@ with `.toolbar(removing: .title)` (#31). The back chevron beside it is not ours to remove — neither dropping that column's toolbar nor `navigationBarBackButtonHidden` touches it. +**visionOS needs three things the other two get for free** (#11), and all three +are `#if os(visionOS)` rather than shared, because on iPadOS and macOS each +would be a second copy of something that already exists. + +*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, so +the window carries the drawing it was opened with and the only route to another +was to close it and launch again. `documentBrowserToolbar()` puts a folder +button in the sidebar's bar, and `dismiss` — what a `DocumentGroup` document +closes itself with — is what it calls. **Where `dismiss` is read from decides +whether it does anything.** Read inside the toolbar item's own view, which is +the obvious place, 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, which is why this is a +`ViewModifier` and not a view inside the `toolbar` block. Nothing warns you — +the code compiles and the button draws. + +*The name is deliberately in one place.* The sidebar carries the +DocumentGroup's own title with its rename chevron, and `CanvasPane` still drops +its copy with `.toolbar(removing: .title)` (#31). A second, self-drawn label in +the canvas pane was tried — `\.documentConfiguration`'s `fileURL`, which unlike +the system chrome is right from the first frame — and taken back out: on a +window this wide the two read as one name printed twice rather than as a title +and a reminder. + +*No `hoverEffect`.* See `PlatformModifiers` — it crashes there. + +**The code pane is paper, not a semantic surface** (#11). It sat on +`.background.secondary`, which resolves to near-white or near-black on iPad and +Mac but to light translucent glass on visionOS — and the syntax colors 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 +its ground were both light. It is now white, opaque, the same in both +appearances, rounded the same 8 as the canvas — the two swap places inside one +`ZStack`, so pressing 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, +16.9:1) for the reason the block fills are fixed (#41). Two traps came with it. +The copy button stays *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 instead of +its label, leaving a black lozenge with invisible text. And a program narrower +than the pane sat in the middle of it — in a scroll view that scrolls both ways +the content is offered no width to fill, so a `.leading` frame does nothing and +`defaultScrollAnchor(.topLeading)` is what places it. + **A block row is one VoiceOver element, a container header is not** (#1). Swiping a program should say "まえへ、かず 100、じっこうちゅう" once per block, not stop three times, so a simple row is `.accessibilityElement(children: diff --git a/App/Views/CodePane.swift b/App/Views/CodePane.swift index 71c642d..931627c 100644 --- a/App/Views/CodePane.swift +++ b/App/Views/CodePane.swift @@ -24,6 +24,27 @@ struct CodePane: View { .labelStyle(.titleAndIcon) } .padding(8) + // The code is on paper, the same paper the canvas is on (#11). It + // used to sit on `.background.secondary`, which is a *semantic* + // surface: near-white or near-black on iPad and Mac, and on + // visionOS light translucent glass over a room. 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. Opaque, named, and the same in both + // appearances, for the reason the block fills are (#41): this is + // the app's surface, not a response to its surroundings. + // + // White rather than an editor's dark theme because the code pane + // and the canvas swap places inside one `ZStack` — the same sheet, + // rounded the same 8, means pressing the toggle changes only the + // *content*. + // + // The paper wraps the code and nothing else. The copy button stays + // outside it, on the pane's own ground with the window's other + // controls: inside, it needed the ink as a tint to be legible, and + // on visionOS the tint went to the *capsule* instead of the label, + // leaving a black lozenge with invisible text on it. ScrollView([.vertical, .horizontal]) { Text(highlightedCode) .font(.system(.callout, design: .monospaced)) @@ -31,12 +52,40 @@ struct CodePane: View { .padding() .frame(maxWidth: .infinity, alignment: .leading) } + // A program narrower than the pane sat in the *middle* of it, which + // is not where source starts. The `.leading` frame above cannot fix + // that on its own: in a scroll view that scrolls both ways the + // content is offered no width to fill, so it takes its own and the + // scroll view centres what is left over. The anchor is what places + // undersized content, on both axes at once. + .defaultScrollAnchor(.topLeading) + .background(Color.white, in: Self.sheet) + .clipShape(Self.sheet) } - .background(.background.secondary) } - /// Colors each `CodeTokenizer` span with a semantic color, so both - /// light and dark mode stay legible. + /// The canvas's shape, so the two panes are the same sheet — see + /// `CanvasPane.sheet`, which this deliberately matches. + private static let sheet = RoundedRectangle(cornerRadius: 8) + + /// Colors each `CodeTokenizer` span with a fixed color. + /// + /// Fixed, not semantic: the pane is white in both appearances, so a color + /// that inverts with the appearance would be picking its contrast against + /// a background it no longer has. These are measured against white — + /// 8.6:1, 8.4:1, 5.1:1 and 16.9:1 — so every kind clears AA at the callout + /// size, and the three accents stay far enough apart in hue to be told + /// apart at a glance. + private func color(for kind: CodeTokenKind) -> Color { + switch kind { + case .keyword: Color(.sRGB, red: 0.604, green: 0.129, blue: 0.588) // #9A2196 + case .number: Color(.sRGB, red: 0.106, green: 0.220, blue: 0.784) // #1B38C8 + case .methodOrProperty: Color(.sRGB, red: 0.031, green: 0.396, blue: 0.435) // #08656F + case .plain: BlockCategory.ink + } + } + + /// Colors each `CodeTokenizer` span, so the pane reads as source. private var highlightedCode: AttributedString { var result = AttributedString() for token in CodeTokenizer.tokenize(code) { @@ -46,15 +95,6 @@ struct CodePane: View { } return result } - - private func color(for kind: CodeTokenKind) -> Color { - switch kind { - case .keyword: .purple - case .number: .blue - case .methodOrProperty: .teal - case .plain: .primary - } - } } /// Shared with the Run menu's "Copy Code" command (#23), so both paths to diff --git a/App/Views/PaletteView.swift b/App/Views/PaletteView.swift index 1ac7cca..7b48ba2 100644 --- a/App/Views/PaletteView.swift +++ b/App/Views/PaletteView.swift @@ -189,9 +189,64 @@ struct PaletteView: View { } .padding() } + .documentBrowserToolbar() } } +extension View { + /// The way back to the document browser, beside the document's own title + /// (#11). visionOS only, so the `#if` hides in a modifier rather than + /// sitting at the call site. + /// + /// Every other platform already has a way and would end up with two: + /// iPadOS puts a chevron next to the title, macOS has File ▸ Open and one + /// window per document. **visionOS has neither.** Its window carries the + /// document it was opened with, nothing offers another, and the only route + /// to a second drawing was closing the window and launching the app again. + func documentBrowserToolbar() -> some View { + #if os(visionOS) + modifier(DocumentBrowserToolbar()) + #else + self + #endif + } +} + +#if os(visionOS) + + /// Closes this document, which leaves the browser it was opened from. + /// + /// `dismiss` is what a `DocumentGroup`'s document closes itself with, and + /// **where it 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 + /// simply inert: it highlights, and nothing happens. It has to come from + /// the environment of the *content* the toolbar is attached to, which is + /// what makes this a `ViewModifier` rather than a view inside the + /// `toolbar` block. The failure is silent in the worst way — the code + /// compiles, the button draws, and only pressing it tells you. + /// + /// Deliberately not paired with a "new drawing" button: the browser's own ⊕ + /// is right there once you are back, and reaching the browser at all is the + /// part that was missing. + private struct DocumentBrowserToolbar: ViewModifier { + @Environment(\.dismiss) private var dismiss + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItem(placement: .navigation) { + Button("Open Another Drawing", systemImage: "folder") { + dismiss() + } + .labelStyle(.iconOnly) + .touchTarget() + } + } + } + } + +#endif + struct PaletteSectionView: View { let section: PaletteSection let workspace: WorkspaceEditor From 2e99c9ef6c222cfe36f41572f40f79892c667156 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 12 Aug 2026 21:54:49 +0900 Subject: [PATCH 05/33] Let the thumbnail extension sign for a visionOS device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 14 ++++++++++++-- TortoiseBlocks.xcodeproj/project.pbxproj | 3 +-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e79cefd..18eb211 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -357,8 +357,18 @@ greps `project.pbxproj` for the setting, because a build setting cannot enforce its own absence and Xcode writes one there the moment a team is picked in Signing & Capabilities. The everyday loop needs no team because **Debug ad-hoc-signs** -(`CODE_SIGN_IDENTITY = "-"` on macOS for both targets). Release does not, and -must not: those pins used to sit in Release too, which quietly made the +(`CODE_SIGN_IDENTITY = "-"` on macOS for both targets). Note *on macOS* — the +identity is `[sdk=macosx*]`-conditional, and it has to be written that way +round. The extension had it as a bare `CODE_SIGN_IDENTITY = "-"` with an +`[sdk=iphoneos*]` exception naming a real certificate, which reads the same +until a platform arrives that the exception doesn't name: a visionOS *device* +build then fell into the ad-hoc default and stopped with "has entitlements +that require signing with a development certificate" (both targets are +sandboxed, so ad-hoc is never enough on a device). Simulators hid it, because +they ad-hoc sign whatever they are given. Name the platform that wants ad-hoc, +never the ones that don't — the same rule as `#if !os(macOS)` in +`PlatformModifiers`, and the same silent failure when it is inverted. +Release does not ad-hoc sign, and must not: those pins used to sit in Release too, which quietly made the distribution configuration unable to archive at all — an ad-hoc macOS app cannot go to App Store Connect. Release is left to automatic signing, which is what Xcode Cloud's cloud signing then takes over; that is the whole reason diff --git a/TortoiseBlocks.xcodeproj/project.pbxproj b/TortoiseBlocks.xcodeproj/project.pbxproj index 8f4e594..97a938a 100644 --- a/TortoiseBlocks.xcodeproj/project.pbxproj +++ b/TortoiseBlocks.xcodeproj/project.pbxproj @@ -453,8 +453,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = Support/ThumbnailExtension.entitlements; - CODE_SIGN_IDENTITY = "-"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; From e000dee089c8a902f05ed917ba3ec92999985517 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Thu, 13 Aug 2026 08:20:16 +0900 Subject: [PATCH 06/33] Give visionOS its hover effect back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/CLAUDE.md | 12 +++++++++++- App/Views/PaletteView.swift | 9 ++++++++- App/Views/PlatformModifiers.swift | 32 +++++++++++++++++++------------ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/App/Views/CLAUDE.md b/App/Views/CLAUDE.md index 308bd2e..ce0e14f 100644 --- a/App/Views/CLAUDE.md +++ b/App/Views/CLAUDE.md @@ -91,7 +91,17 @@ the system chrome is right from the first frame — and taken back out: on a window this wide the two read as one name printed twice rather than as a title and a reminder. -*No `hoverEffect`.* See `PlatformModifiers` — it crashes there. +*A hover effect, but never on a drag source.* visionOS does not give a button +with a custom `ButtonStyle` the system hover treatment, so without +`pointerHover()` a palette block is the one thing on screen that never lights +up when looked at — and gaze feedback is the whole targeting affordance there. +The catch is that a hover effect and `draggable` **on the same view** segfault +(a `swift_release` inside SwiftUI's update of that view's body, before a window +appears). The palette entry is a drag source, so its hover lives inside +`PaletteBlockButtonStyle` — one level below the `Button` that carries +`draggable` — while every other call site applies it directly. The crash blames +the body, not the modifier, which is why this first read as "`hoverEffect` +crashes on visionOS": it does not, the pairing does. **The code pane is paper, not a semantic surface** (#11). It sat on `.background.secondary`, which resolves to near-white or near-black on iPad and diff --git a/App/Views/PaletteView.swift b/App/Views/PaletteView.swift index 7b48ba2..f0f4128 100644 --- a/App/Views/PaletteView.swift +++ b/App/Views/PaletteView.swift @@ -282,6 +282,11 @@ private struct PaletteBlockButtonStyle: ButtonStyle { .frame(maxWidth: .infinity, alignment: .leading) .background(color, in: RoundedRectangle(cornerRadius: 8)) .opacity(configuration.isPressed ? 0.7 : 1) + // Here rather than on the `Button`, because the button is a drag + // source and the pair crashes visionOS — see `pointerHover`. It + // also reads better: the highlight follows the shape the style + // draws instead of the label's own bounds. + .pointerHover() } } @@ -307,7 +312,9 @@ struct PaletteEntryButton: View { // color — white — which is the one thing a pastel fill can't carry. A // palette entry and the row it becomes should look alike anyway. .buttonStyle(PaletteBlockButtonStyle(color: category.color)) - .pointerHover() + // The hover is inside that style, not here. On visionOS a hover effect + // and `draggable` on the same view segfault — see `pointerHover`. + // // Evaluated per drag, so every drag stamps a fresh Block (new ID). .draggable(Block(kind: entry.kind)) .accessibilityHint("Tap to add to the end of the program. Drag to place anywhere.") diff --git a/App/Views/PlatformModifiers.swift b/App/Views/PlatformModifiers.swift index 724f4a5..f7a553b 100644 --- a/App/Views/PlatformModifiers.swift +++ b/App/Views/PlatformModifiers.swift @@ -22,20 +22,28 @@ extension View { #endif } - /// The iPad (pointer) hover highlight (#24); a no-op on macOS, which has - /// its own cursor affordances. + /// The hover highlight (#24): the iPad's pointer, and on visionOS the gaze, + /// which is the only thing there that says what you are about to press. + /// A no-op on macOS, which has its own cursor affordances. /// - /// Deliberately *not* extended to visionOS, where gaze feedback would seem - /// to be exactly what this is for: `hoverEffect` on a palette entry crashes - /// the app there. Launching straight into the workspace segfaults in - /// `PaletteEntryButton.body` — a `swift_release` inside SwiftUI's own - /// update, not our code — before a window is ever shown, and it does so - /// with `.automatic` as well as `.highlight`, so it is the modifier and not - /// the effect. (visionOS 26.5 / 27.0 simulators, Xcode 26.6.) Buttons get - /// the system's own hover treatment there in any case; this is only the - /// extra highlight iPadOS needs, so the platform loses nothing visible. + /// **Never put this on a view that is also `draggable`.** That combination + /// segfaults on visionOS — a `swift_release` inside SwiftUI's own update of + /// the view's body, before a window is ever shown, with `.automatic` as + /// well as `.highlight`. It cost a while to place, because the crash blames + /// the body rather than the modifier and the effect looked like the + /// culprit; the effect is fine and the *pairing* is not. So a palette + /// entry, which is a drag source, wears its hover inside + /// `PaletteBlockButtonStyle` — on the shaped body the style draws, one + /// level below the `Button` that `draggable` is attached to — and that is + /// why the palette applies this in its style while everything else applies + /// it at the call site. + /// + /// visionOS needs it stated at all because it does *not* give a button with + /// a custom `ButtonStyle` the system hover treatment; without this, a + /// palette block is the one thing on screen that never lights up when + /// looked at. func pointerHover() -> some View { - #if os(iOS) + #if !os(macOS) hoverEffect(.highlight) #else self From cdbe446252595e94ef93c272b0965a1fec2ea083 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 12 Aug 2026 18:31:45 +0900 Subject: [PATCH 07/33] Put visionOS in the store listing and the site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//. #11 --- .claude/skills/release/SKILL.md | 23 +++++++++++- .github/workflows/appstore-metadata.yml | 11 +++--- README.md | 8 ++--- appstore/README.md | 44 +++++++++++++++++------ appstore/metadata/en-US/description.txt | 2 +- appstore/metadata/en-US/release_notes.txt | 4 +++ appstore/metadata/ja/description.txt | 2 +- appstore/metadata/ja/release_notes.txt | 4 +++ fastlane/Fastfile | 39 ++++++++++++++++---- fastlane/metadata_check.rb | 7 ++-- site/index.html | 25 ++++++------- 11 files changed, 127 insertions(+), 42 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 81a6d6e..26dfd31 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -126,8 +126,29 @@ One more vocabulary mismatch to remember: deliver says `osx`, the Connect API says `MAC_OS`, and passing the former to spaceship reports a missing version that plainly exists. +**visionOS is a third listing, spelled three different ways** (#11). It is a +native app on the xrOS SDK, not "Designed for iPad", so App Store Connect gives +it its own platform version — which the app record must carry *before* either +lane will run (`get_edit_app_store_version` returns nil otherwise, and +`metadata_diff` stops with "No editable xros version"). The text is shared with +the other two, as it already was between iOS and macOS; only the screenshots +are per-platform. Then the vocabulary: **`visionos` names the screenshots +directory, `xros` goes to deliver, `VISION_OS` goes to spaceship**, and the +first of those 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 falls back to asking whether the *path* contains `vision` +(downcased) — name the directory after deliver's own platform value and every +screenshot is filed as `APP_APPLE_TV` on an app with no tvOS listing. +The captures need no staging: `xcrun simctl io screenshot` on the +visionOS simulator writes exactly 3840×2160, the simulated room and all, which +is what visionOS screenshots look like anyway. They do carry an alpha channel, +so `-alpha off` applies here like everywhere else. And no new identifier is +needed — spaceship maps `xros` onto the **iOS** `BundleIdPlatform`, so the App +IDs the iPhone/iPad build already registered are the ones visionOS signs +against. + **A release is a `v*` tag** (#4). Xcode Cloud runs one `Release` workflow off -it — two Archive actions, iOS and macOS, each with a TestFlight internal +it — an Archive action per platform, each with a TestFlight internal post-action bound to its own archive artifact. It carries no Build or Test action: GitHub Actions has already run the lint, the Kit tests and both platform builds on the way to main, and Xcode Cloud's 25 free compute diff --git a/.github/workflows/appstore-metadata.yml b/.github/workflows/appstore-metadata.yml index 1ec937c..74167a2 100644 --- a/.github/workflows/appstore-metadata.yml +++ b/.github/workflows/appstore-metadata.yml @@ -13,8 +13,8 @@ on: platform: description: Which listing type: choice - options: [both, ios, macos] - default: both + options: [all, ios, macos, visionos] + default: all apply: description: Upload (unticked, only the diff is shown) type: boolean @@ -48,9 +48,10 @@ jobs: run: | lane='${{ inputs.apply && 'metadata_push' || 'metadata_diff' }}' case '${{ inputs.platform }}' in - ios) platforms="ios" ;; - macos) platforms="mac" ;; - *) platforms="ios mac" ;; + ios) platforms="ios" ;; + macos) platforms="mac" ;; + visionos) platforms="visionos" ;; + *) platforms="ios mac visionos" ;; esac for p in $platforms; do echo "::group::$p $lane" diff --git a/README.md b/README.md index efdafda..1c4f0a0 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ graphics engine written in Swift. ## Requirements - **Xcode** 26+ (Swift 6.2) -- **Platforms** iPadOS 26+ · macOS 26+ · visionOS 26+ (the same three-pane app - in a window; not on the App Store yet) +- **Platforms** iPadOS 26+ · macOS 26+ · visionOS 26+ (the same three-pane app, + in a window on Vision Pro) ## Getting Started @@ -172,8 +172,8 @@ arithmetic saturates the same way, so a value can never run off to infinity. ## Releasing A `v*` tag is the release. Pushing one starts an Xcode Cloud workflow that -archives the iPadOS and macOS apps and sends them to TestFlight, while GitHub -Actions checks that the tag matches `MARKETING_VERSION` in every configuration +archives the app for each platform and sends the builds to TestFlight, while +GitHub Actions checks that the tag matches `MARKETING_VERSION` in every configuration — the two are otherwise unconnected, and a mismatch would ship the wrong version silently. The same tag drafts a GitHub release, with notes split by whether a commit reached the app or only the site, the listing, CI or the docs. diff --git a/appstore/README.md b/appstore/README.md index 3a90d02..9ef063d 100644 --- a/appstore/README.md +++ b/appstore/README.md @@ -5,14 +5,24 @@ App Store Connect (#42). ``` metadata//*.txt the text, one file per field -screenshots/// ios/ and macos/, one directory per locale +screenshots/// ios/, macos/ and visionos/, one directory per locale screenshot-sources/ the documents the captures were shot from ``` `` is an App Store Connect locale code (`en-US`, `ja`), not the app's -`en` / `ja` string-catalog code. `ios` and `macos` name the two listings: there -is no ipadOS — iPad is a display type under iOS, and deliver files a screenshot -by its pixel size. +`en` / `ja` string-catalog code. `ios`, `macos` and `visionos` name the three +listings: there is no ipadOS — iPad is a display type under iOS, and deliver +files a screenshot by its pixel size. + +**`visionos` is the one directory name that is load-bearing.** A Vision Pro +screenshot is 3840×2160 — the same size as an Apple TV one — so deliver cannot +tell the two display types apart by size and breaks the tie on whether the +*path* contains `vision`, downcased, anywhere in it. Naming the directory +`xros` to match deliver's platform value would file every capture under +`APP_APPLE_TV`, on an app with no tvOS listing at all. visionOS is spelled +three different ways across this pipeline and all three are required: +`visionos` here (the ASC `Platform` enum, lowercased, like `ios` and `macos`), +`xros` to deliver, `VISION_OS` to spaceship. ## Running it @@ -24,11 +34,19 @@ export ASC_KEY_ID=… export ASC_PRIVATE_KEY_PATH=~/…/AuthKey_XXXXXXXXXX.p8 bundle install -bundle exec fastlane metadata_check # the files alone, no network, no key -bundle exec fastlane ios metadata_diff # what is live, against what is written -bundle exec fastlane ios metadata_push # upload (mac for the other listing) +bundle exec fastlane metadata_check # the files alone, no network, no key +bundle exec fastlane ios metadata_diff # what is live, against what is written +bundle exec fastlane ios metadata_push # upload +bundle exec fastlane mac metadata_push # …and the same two for the other +bundle exec fastlane visionos metadata_push # two listings ``` +A listing only exists once its platform does: `metadata_diff` and +`metadata_push` both need an **editable version** for that platform in App +Store Connect, and for visionOS that means the app record has to carry the +Apple Vision Pro platform first. Until then the lane stops with "No editable +xros version — create one first", which is the guard working, not a bug. + In CI it is the **App Store Metadata** workflow, run by hand from the Actions tab: pick a platform, and tick *apply* to upload rather than diff. It reads the same three values from secrets, with the .p8 base64-encoded into @@ -86,8 +104,11 @@ hand-run upload cannot skip it. It is plain Ruby with no gems, so CI runs it as ## Screenshots - The sizes are the ones Apple accepts as-is: **iPad 13-inch landscape - 2752×2064** and **Mac 2880×1800**. A reshoot has to keep the window sizes - that produced them + 2752×2064**, **Mac 2880×1800** and **Apple Vision Pro 3840×2160**. A reshoot + has to keep the window sizes that produced them. The Vision Pro size needs no + arranging at all — `xcrun simctl io screenshot` on the visionOS + simulator writes exactly 3840×2160 — but the file it writes **has an alpha + channel**, so it still needs `-alpha off` like every other capture - Order comes from the leading number in the filename. Ten per locale, at most - **Carry no alpha channel** (`magick -alpha off -define png:color-type=2 `). Fully opaque is not enough — the channel alone can get a screenshot @@ -95,7 +116,10 @@ hand-run upload cannot skip it. It is plain Ruby with no gems, so CI runs it as - Capture the whole screen on Mac, not the window: a window-only capture has transparent rounded corners and shadow. Set the *system* language to the locale being shot, too — switching only the app's language leaves the menu - bar in the other language + bar in the other language. On Vision Pro the whole "screen" is the simulated + room, so the app window sits in the middle of a living room — that is what + the platform's screenshots look like, and cropping to the window would give + a size Apple does not accept - **deliver uploads every screenshot twice on a first upload**, reproducibly. It matches local against live by MD5, and Apple has not computed that checksum seven seconds after the PUT, so each image looks missing and the diff --git a/appstore/metadata/en-US/description.txt b/appstore/metadata/en-US/description.txt index 3fe3884..7c1f124 100644 --- a/appstore/metadata/en-US/description.txt +++ b/appstore/metadata/en-US/description.txt @@ -24,4 +24,4 @@ Drawings are ordinary documents in Files and Finder, and each one shows its own FOR PARENTS AND TEACHERS There is no sign-in and no account. There is nothing to type but numbers. There is no advertising, no analytics, no third-party SDK, and no network connection of any kind — the app contains no networking code at all. Drawings stay on the device and in whatever location you choose to save them. -Tortoise Blocks runs on iPad and Mac, speaks English and Japanese, and is free and open source under the MIT license. +Tortoise Blocks runs on iPad, Mac and Apple Vision Pro, speaks English and Japanese, and is free and open source under the MIT license. diff --git a/appstore/metadata/en-US/release_notes.txt b/appstore/metadata/en-US/release_notes.txt index 734b923..6db97b0 100644 --- a/appstore/metadata/en-US/release_notes.txt +++ b/appstore/metadata/en-US/release_notes.txt @@ -6,3 +6,7 @@ Give a block a name, put whatever you like inside it, and call it from anywhere • Every block now carries one ⋯ menu: delete it, move it up or down, or give an "if" its "otherwise". • Tap the trash can to clear the whole program at once. You can undo it. • Block labels stay on one line in narrower windows. + +ON APPLE VISION PRO + +Tortoise Blocks now runs on Apple Vision Pro, with the same three panes in a window: the blocks to pick from, the program you are building, and the tortoise drawing it. Your drawings are the same documents on every device. diff --git a/appstore/metadata/ja/description.txt b/appstore/metadata/ja/description.txt index b523621..cac08a2 100644 --- a/appstore/metadata/ja/description.txt +++ b/appstore/metadata/ja/description.txt @@ -24,4 +24,4 @@ Tortoise Blocks は、子どものためのビジュアルプログラミング ■ 保護者・先生の方へ ログインもアカウントもありません。入力するのは数字だけです。広告も、利用状況の計測も、第三者製の SDK もありません。ネットワーク通信は一切行わず、そもそもアプリの中に通信を行うコードが含まれていません。作った作品は、端末と、選んだ保存先にだけ残ります。 -Tortoise Blocks は iPad と Mac で動き、日本語と英語に対応しています。MIT ライセンスのオープンソースで、無料です。 +Tortoise Blocks は iPad と Mac と Apple Vision Pro で動き、日本語と英語に対応しています。MIT ライセンスのオープンソースで、無料です。 diff --git a/appstore/metadata/ja/release_notes.txt b/appstore/metadata/ja/release_notes.txt index 7eedd13..b71b56c 100644 --- a/appstore/metadata/ja/release_notes.txt +++ b/appstore/metadata/ja/release_notes.txt @@ -6,3 +6,7 @@ ・ブロックの操作を「⋯」メニューにまとめました(けす/うえへ/したへ/そうでなければ)。 ・ゴミ箱をタップすると、プログラムを一度に消せます。元に戻せます。 ・せまい画面でも、ブロックの文字が折り返さなくなりました。 + +■ Apple Vision Pro に対応しました + +Apple Vision Pro でも Tortoise Blocks が動くようになりました。ウィンドウの中は同じ3つのペインです。選ぶブロック、組み立てたプログラム、そして絵を描いていくカメ。作品はどの端末でも同じ書類です。 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9bbd1d5..3325481 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -4,12 +4,18 @@ # Builds reach TestFlight from Xcode Cloud on a v* tag, and these lanes never # upload a binary — skip_binary_upload is on. # -# iOS and macOS are separate versions in App Store Connect, so each takes its -# own run against its own screenshots directory. The text is shared: one -# directory of files per locale, which is deliver's own layout. +# iOS, macOS and visionOS are separate versions in App Store Connect, so each +# takes its own run against its own screenshots directory. The text is shared: +# one directory of files per locale, which is deliver's own layout. require_relative "metadata_check" +# fastlane knows :ios, :mac and :android as Fastfile platforms and warns on +# every run about anything else. deliver understands visionOS perfectly well +# (its platform value is "xros"); it is only the namespace around the lanes +# that has to be declared. +Fastlane::SupportedPlatforms.extra = [:visionos] + APP_IDENTIFIER = "space.hiraku.tortoiseblocks" # Absolute, and deliberately so. deliver resolves a relative path against the @@ -43,6 +49,24 @@ platform :mac do end end +# The screenshots directory is "visionos" and it is not free to rename (#11). +# A Vision Pro screenshot is 3840x2160 — the same size as an Apple TV one — so +# deliver cannot tell the two display types apart by size, and breaks the tie +# on whether the *path* contains "vision" (downcased, anywhere in it). Call the +# directory "xros" to match deliver's platform value and every screenshot is +# filed under APP_APPLE_TV instead, on an app that has no tvOS listing at all. +platform :visionos do + desc "Diff the live visionOS listing against appstore/metadata" + lane :metadata_diff do + diff_metadata(platform: "xros") + end + + desc "Push appstore/ to the visionOS listing" + lane :metadata_push do + push_metadata(platform: "xros", screenshots: "visionos") + end +end + desc "Check appstore/ without touching the network" lane :metadata_check do check_metadata @@ -185,11 +209,14 @@ APP_FIELDS = { }.freeze # deliver and spaceship do not share a vocabulary for the platforms: deliver -# takes "osx", the Connect API calls the same thing MAC_OS. The lanes speak -# deliver's, so the translation happens here. +# takes "osx" and "xros", the Connect API calls the same things MAC_OS and +# VISION_OS. The lanes speak deliver's, so the translation happens here. Note +# visionOS is spelled a third way again in appstore/screenshots — see the +# visionos platform above. CONNECT_PLATFORM = { "ios" => Spaceship::ConnectAPI::Platform::IOS, - "osx" => Spaceship::ConnectAPI::Platform::MAC_OS + "osx" => Spaceship::ConnectAPI::Platform::MAC_OS, + "xros" => Spaceship::ConnectAPI::Platform::VISION_OS }.freeze private_lane :diff_metadata do |options| diff --git a/fastlane/metadata_check.rb b/fastlane/metadata_check.rb index 31dfe59..8978069 100755 --- a/fastlane/metadata_check.rb +++ b/fastlane/metadata_check.rb @@ -28,10 +28,13 @@ module MetadataCheck REQUIRED = (LIMITS.keys + URLS).sort.freeze # Sizes Apple accepts for the display types this app ships. An unexpected - # size is a mistake worth stopping on, not a shape to guess at. + # size is a mistake worth stopping on, not a shape to guess at. A platform + # missing from this table is not checked at all, so a new screenshots + # directory has to be added here to be seen. SIZES = { "ios" => [[2064, 2752], [2752, 2064]], # iPad 13-inch - "macos" => [[1280, 800], [1440, 900], [2560, 1600], [2880, 1800]] + "macos" => [[1280, 800], [1440, 900], [2560, 1600], [2880, 1800]], + "visionos" => [[3840, 2160]] # Apple Vision Pro }.freeze DEFAULT_ROOT = Pathname.new(__dir__).parent / "appstore" diff --git a/site/index.html b/site/index.html index a8b9b2f..2103d83 100644 --- a/site/index.html +++ b/site/index.html @@ -4,14 +4,14 @@ Tortoise Blocks — Snap blocks together, watch the tortoise draw - + - + @@ -245,7 +245,8 @@

Snap blocks together, and watch the tortoise draw.

- A visual programming app for kids on iPad and Mac. Drag blocks into a + A visual programming app for kids on iPad, Mac and Apple Vision Pro. Drag + blocks into a program, press ▶️, and a tortoise draws your picture line by line.

@@ -254,7 +255,7 @@

Snap blocks together, and watch the tortoise draw.

Download on the App Store -

iPadOS 26 or later · macOS 26 or later

+

iPadOS 26 or later · macOS 26 or later · visionOS 26 or later

@@ -390,9 +391,9 @@

From blocks to text

-

iPad and Mac, one app

+

iPad, Mac and Vision Pro, one app

- The same three panes on both, at home with each. It is an ordinary + The same three panes on all three, at home with each. It is an ordinary document app: iCloud Drive and Files, autosave, system undo, and its own folder full of drawings you can tell apart at a glance.

@@ -422,7 +423,7 @@

Quiet about the child using it

Requires
-
iPadOS 26 or later · macOS 26 or later
+
iPadOS 26 or later · macOS 26 or later · visionOS 26 or later
Languages
@@ -449,14 +450,14 @@

Quiet about the child using it

ブロックをならべて、カメに絵をかいてもらおう!

-

iPad と Mac のための、子ども向けビジュアルプログラミングアプリ。ブロックを並べて ▶️ を押すと、カメが1本ずつ線を描いていきます。

+

iPad と Mac と Apple Vision Pro のための、子ども向けビジュアルプログラミングアプリ。ブロックを並べて ▶️ を押すと、カメが1本ずつ線を描いていきます。

App Store でダウンロード -

iPadOS 26 以降 · macOS 26 以降

+

iPadOS 26 以降 · macOS 26 以降 · visionOS 26 以降

@@ -553,8 +554,8 @@

ブロックから、文字のプログラムへ

-

iPad でも Mac でも、同じアプリ

-

3つのペインの構成はどちらでも同じで、それぞれの操作にちゃんとなじみます。中身はふつうの書類アプリです。iCloud Drive と「ファイル」、自動保存、システムの取り消し、そして一目で見分けられる作品の並んだフォルダ。

+

iPad でも Mac でも Apple Vision Pro でも、同じアプリ

+

3つのペインの構成はどれでも同じで、それぞれの操作にちゃんとなじみます。中身はふつうの書類アプリです。iCloud Drive と「ファイル」、自動保存、システムの取り消し、そして一目で見分けられる作品の並んだフォルダ。

使う子どものことを、何も集めません
動作環境
-
iPadOS 26 以降 · macOS 26 以降
+
iPadOS 26 以降 · macOS 26 以降 · visionOS 26 以降
対応言語
From 84952f11d5577b62b3ecb0c687a925bd0f185b55 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Thu, 13 Aug 2026 12:52:00 +0900 Subject: [PATCH 08/33] Put a drawing on the table, to find out what that costs (#53 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/TortoiseBlocksApp.swift | 32 +++++ App/Views/ContentView.swift | 9 +- App/Views/TableSpike.swift | 245 ++++++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 App/Views/TableSpike.swift diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index c76a310..f575c0b 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -2,9 +2,32 @@ import SwiftUI @main struct TortoiseBlocksApp: App { + // Phase 0 spike (#53) — the immersive space is its own Scene and cannot + // see the document's RunnerModel, so the two meet here. Delete with + // `TableSpike.swift`. + #if os(visionOS) + @State private var tableSpike = TableSpikeModel() + #endif + var body: some Scene { + // Phase 0 spike (#53) — first in the body so `-TBSpike YES` can take + // the launch off the DocumentGroup. Suppressed without the flag, so a + // normal launch is unchanged. Delete with `TableSpike.swift`. + #if os(visionOS) + WindowGroup(id: "table-spike-launcher") { + TableSpikeLauncher() + .environment(tableSpike) + } + .defaultLaunchBehavior(TableSpikeModel.autoOpens ? .presented : .suppressed) + // Small, so it does not stand in front of the thing being looked at. + .defaultSize(width: 320, height: 140) + #endif + DocumentGroup(newDocument: BlocksDocument()) { file in ContentView(document: file.$document) + #if os(visionOS) + .environment(tableSpike) + #endif } .commands { TortoiseBlocksCommands() @@ -25,5 +48,14 @@ struct TortoiseBlocksApp: App { #if !os(macOS) LaunchScene() #endif + + // Phase 0 spike (#53) — delete with `TableSpike.swift`. + #if os(visionOS) + ImmersiveSpace(id: TableSpikeModel.spaceID) { + TableSpikeSpace() + .environment(tableSpike) + } + .immersionStyle(selection: .constant(.mixed), in: .mixed) + #endif } } diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 86d8f4e..7e44f1b 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -100,7 +100,9 @@ struct CanvasPane: View { /// the triangle. The asset has to point *up*: `.image` rotates its top edge /// toward the heading. Deliberately not applied to the PNG export's canvas /// — see `RunnerModel.exportFrameSize`. - private static let sprite = TortoiseSprite.image( + // Not private only so the #53 spike can put the same tortoise on the + // table; make it private again when `TableSpike.swift` goes. + static let sprite = TortoiseSprite.image( Image(.tortoiseSprite), size: CGSize(width: 23, height: 32)) /// The paper's outline: the same 8 a standalone block row rounds @@ -153,6 +155,11 @@ struct CanvasPane: View { isStale: runner.isStale(comparedTo: workspace.blocks) ) .padding() + + // Phase 0 spike (#53) — delete with `TableSpike.swift`. + #if os(visionOS) + TableSpikeBar(runner: runner) + #endif } // The document title belongs to the sidebar's bar, once (#31). On // iPadOS the DocumentGroup hands its title chrome to *both* ends of the diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift new file mode 100644 index 0000000..ee19e44 --- /dev/null +++ b/App/Views/TableSpike.swift @@ -0,0 +1,245 @@ +#if os(visionOS) + + import RealityKit + import SwiftUI + import TortoiseBlocksKit + import TortoiseUI + + // Phase 0 spike for #53 — **throwaway**. This exists to answer three + // questions on a real Vision Pro and then be deleted: + // + // 1. Is a drawing laid on a real table legible as line work? + // 2. How big should it be? (`Size.every` is the measuring stick.) + // 3. Does a 10,000-command program still play at a usable frame rate? + // + // It deliberately bolts onto the existing DocumentGroup app rather than + // building the viewer #53 actually describes: the point is to measure the + // rendering, not to prototype the product. Every string here is + // `Text(verbatim:)` so a spike never lands in `Localizable.xcstrings`. + // + // The one real finding is already baked into the shape of this file: + // **no texture pipeline is needed.** `ViewAttachmentComponent` (visionOS + // 26) puts a live SwiftUI view into the RealityKit scene, so the canvas on + // the table is the *same* `TortoiseCanvas` the pane draws, driven by the + // same `CommandPlayer`. Option (a) in #53 costs one entity, and the + // highlight alignment that #53 calls load-bearing is untouched because + // nothing about the command stream changed. + + /// Shared between the document window (which owns the runner) and the + /// immersive space (which is a separate `Scene` and can't see it). + @Observable + @MainActor + final class TableSpikeModel { + static let spaceID = "table-spike" + + /// The runner whose canvas goes on the table. Set by `CanvasPane` when + /// the space opens; nil means nothing to draw. + var runner: RunnerModel? + + /// Length of the canvas's side on the table, in metres. + var side: Size = .sixty + + /// Anchor to a detected horizontal surface, or hang at a fixed spot. + /// The fixed spot is not only a fallback — it is the only mode the + /// simulator can show, since ARKit finds no planes there, which is why + /// the launch-argument route starts with it off. + var anchorsToTable = !TableSpikeModel.autoOpens + + var isOpen = false + + /// `-TBSpike YES` puts the table up at launch on a built-in sample. + /// + /// Without it the spike is unreachable from the simulator: a visionOS + /// `DocumentGroup` ignores `simctl openurl` (the trick that opens a + /// document on iPadOS), simctl cannot send taps, and the custom + /// `DocumentGroupLaunchScene` never appears — visionOS goes straight to + /// the system browser, so there is no view of ours to hang a `.task` + /// on either. Presenting the space itself at launch is what is left. + static var autoOpens: Bool { + UserDefaults.standard.bool(forKey: "TBSpike") + } + + /// The runner to draw, falling back to a document-less one on a + /// built-in sample — which is the only kind the launch-argument route + /// can have. `SampleBlocks` is already public and already what the + /// app's own 「みほん」 uses, so the spike needs no bundled file. + func runnerOrSample() -> RunnerModel { + if let runner { return runner } + let made = RunnerModel() + made.run(SampleBlocks.spiral()) + runner = made + return made + } + + /// Discrete sizes rather than a slider, because changing the size + /// rebuilds the attachment at a new point size (that is the whole + /// point — scaling the entity instead would resample one texture and + /// measure the wrong thing). Rare, deliberate changes suit a rebuild; + /// a slider dragging through them would not. + enum Size: Double, CaseIterable, Identifiable { + case forty = 0.4 + case sixty = 0.6 + case eighty = 0.8 + case hundred = 1.0 + + var id: Double { rawValue } + var label: String { "\(Int(rawValue * 100))cm" } + } + } + + /// The immersive space: one entity, lying flat. + struct TableSpikeSpace: View { + @Environment(TableSpikeModel.self) private var model + + /// visionOS lays SwiftUI out in points and the world in metres. This + /// is the conversion, read from the scene rather than guessed at — it + /// is what makes "60cm" mean 60cm instead of an arbitrary scale + /// factor, and it is the number Phase 1 will need for real. + @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 + + var body: some View { + RealityView { content in + let runner = model.runnerOrSample() + let side = model.side.rawValue * pointsPerMeter + + let canvas = Entity() + canvas.components.set( + ViewAttachmentComponent( + rootView: TableCanvasSheet(runner: runner, side: side))) + // A SwiftUI attachment stands upright facing the viewer; a + // drawing on a table has to lie down. + canvas.orientation = simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) + + if model.anchorsToTable { + content.add( + AnchorEntity( + .plane(.horizontal, classification: .table, minimumBounds: [0.2, 0.2]) + ).addingChild(canvas)) + } + else { + // **An immersive space's origin is on the floor**, under + // where the wearer started — not at eye level. The first + // try put this at y = -0.4 for "desk height below the + // eyes" and buried both entities under the floor, which + // looks exactly like nothing rendering at all. + // Higher than a real table so the simulator's fixed + // horizontal gaze can see it — this mode is for checking + // that the drawing renders, not for judging its height. + canvas.position = [0, 1.0, -1.2] + content.add(canvas) + } + } + // Both of these change what is built, not how it is positioned, so + // they rebuild rather than update. + .id("\(model.side.rawValue)-\(model.anchorsToTable)") + } + } + + /// What actually goes on the table: the app's own canvas, unchanged. + private struct TableCanvasSheet: View { + let runner: RunnerModel + let side: CGFloat + + var body: some View { + TortoiseCanvas(runner.tortoise, player: runner.player) + .tortoiseSprite(CanvasPane.sprite) + // Paper, for the same reason the pane paints it: the default + // pen is black and a table is not white. + .background(.white) + .frame(width: side, height: side) + } + } + + /// The spike's controls, parked under the playback row in `CanvasPane`. + struct TableSpikeBar: View { + let runner: RunnerModel + + @Environment(TableSpikeModel.self) private var model + @Environment(\.openImmersiveSpace) private var openSpace + @Environment(\.dismissImmersiveSpace) private var dismissSpace + + var body: some View { + @Bindable var model = model + HStack { + Button(model.isOpen ? "しまう" : "つくえに おく", systemImage: "table.furniture") { + Task { await toggle() } + } + .buttonStyle(.borderedProminent) + + Picker(selection: $model.side) { + ForEach(TableSpikeModel.Size.allCases) { size in + Text(verbatim: size.label).tag(size) + } + } label: { + Text(verbatim: "おおきさ") + } + .pickerStyle(.segmented) + .fixedSize() + + Toggle(isOn: $model.anchorsToTable) { + Text(verbatim: "平面に置く") + } + .toggleStyle(.switch) + .fixedSize() + + Spacer() + Text(verbatim: "spike #53") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.bottom) + } + + private func toggle() async { + if model.isOpen { + await dismissSpace() + model.isOpen = false + } + else { + model.runner = runner + if case .opened = await openSpace(id: TableSpikeModel.spaceID) { + model.isOpen = true + } + } + } + } + + /// The window `-TBSpike YES` launches into, whose only job is to open the + /// space. `defaultLaunchBehavior(.presented)` on the `ImmersiveSpace` + /// itself does nothing here — the log shows the scene declared with + /// `immersiveStyle = Mixed` and then `requesting immersive or volume NO` — + /// because the `DocumentGroup` takes the launch. A window can take it + /// instead, and `openImmersiveSpace` from inside one does work. + struct TableSpikeLauncher: View { + @Environment(TableSpikeModel.self) private var model + @Environment(\.openImmersiveSpace) private var openSpace + + var body: some View { + VStack(spacing: 12) { + Text(verbatim: "spike #53") + .font(.largeTitle) + Text(verbatim: "つくえの うえに みほんを おいています…") + .foregroundStyle(.secondary) + } + .padding(40) + .task { + guard !model.isOpen else { return } + if case .opened = await openSpace(id: TableSpikeModel.spaceID) { + model.isOpen = true + } + } + } + } + + extension Entity { + /// `content.add(AnchorEntity(…).addingChild(canvas))` reads better than + /// the three statements it replaces, and this file has no other use for + /// a local variable holding the anchor. + fileprivate func addingChild(_ child: Entity) -> Entity { + addChild(child) + return self + } + } + +#endif From c02fb69b1991e1d2e73ea3b5b4895084d82b8600 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Thu, 13 Aug 2026 12:52:47 +0900 Subject: [PATCH 09/33] Declare world sensing, so the device run is not wasted (#53 Phase 0) `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 --- Support/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Support/Info.plist b/Support/Info.plist index cf8baa0..ba875af 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -19,6 +19,8 @@ ITSAppUsesNonExemptEncryption + NSWorldSensingUsageDescription + Used to place your drawing on a real table. UIFileSharingEnabled UTExportedTypeDeclarations From a18818bbe47af68bd29f85905eee8e5d4f3e05ee Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Fri, 14 Aug 2026 06:46:04 +0900 Subject: [PATCH 10/33] Let the wearer size and spin the sheet (#53 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/InfoPlist.xcstrings | 12 +++ App/Localizable.xcstrings | 22 +++-- App/Views/TableSpike.swift | 176 ++++++++++++++++++++++++++++--------- 3 files changed, 161 insertions(+), 49 deletions(-) diff --git a/App/InfoPlist.xcstrings b/App/InfoPlist.xcstrings index ae021cf..cd0eeb7 100644 --- a/App/InfoPlist.xcstrings +++ b/App/InfoPlist.xcstrings @@ -30,6 +30,18 @@ } } }, + "NSWorldSensingUsageDescription" : { + "comment" : "Privacy - World Sensing Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Used to place your drawing on a real table." + } + } + } + }, "Tortoise Blocks Block" : { }, diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index b5ea776..1dd59d6 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -674,42 +674,42 @@ } } }, - "or less" : { + "Open Another Drawing" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "いか" + "value" : "ほかの えを ひらく" } } } }, - "or more" : { + "or less" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "いじょう" + "value" : "いか" } } } }, - "orange" : { + "or more" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "オレンジ" + "value" : "いじょう" } } } }, - "Open Another Drawing" : { + "orange" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ほかの えを ひらく" + "value" : "オレンジ" } } } @@ -1353,6 +1353,12 @@ } } } + }, + "しまう" : { + + }, + "つくえに おく" : { + } }, "version" : "1.0" diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift index ee19e44..35d3dd4 100644 --- a/App/Views/TableSpike.swift +++ b/App/Views/TableSpike.swift @@ -8,9 +8,13 @@ // Phase 0 spike for #53 — **throwaway**. This exists to answer three // questions on a real Vision Pro and then be deleted: // - // 1. Is a drawing laid on a real table legible as line work? - // 2. How big should it be? (`Size.every` is the measuring stick.) + // 1. Is a drawing laid on a real table legible as line work? — **yes**, + // answered on device. + // 2. How big should it be? — **wrong question.** There is no right size; + // the wearer sets it, so the size and the spin are gestures now and + // what is left to learn is where people actually land. // 3. Does a 10,000-command program still play at a usable frame rate? + // — still open. // // It deliberately bolts onto the existing DocumentGroup app rather than // building the viewer #53 actually describes: the point is to measure the @@ -32,13 +36,64 @@ final class TableSpikeModel { static let spaceID = "table-spike" + /// The point size the attachment is built at, expressed in metres. + /// + /// The wearer's pinch scales the *entity*, not this — rebuilding the + /// attachment mid-gesture would re-run the anchor search and make the + /// drawing jump every time it is resized. So one render is made at + /// this size and scaled from there, which never softens below it and + /// may above it: **whether the upper end goes visibly soft is a Phase 0 + /// question to answer on device**, and the reason this is 1m rather + /// than the 60cm the drawing usually sits at. + static let builtSide: Double = 1.0 + + /// Committed size of the sheet's side, in metres. Bounded because an + /// unbounded pinch reaches a 50m canvas in about a second. + var side: Double = 0.6 + static let sideRange: ClosedRange = 0.15...2.0 + + /// Committed rotation about the vertical axis, in radians. A sheet + /// lying flat has its normal straight up, so a plain 2-D twist *is* + /// the vertical-axis spin — no 3-D rotation gesture needed. + var spin: Double = 0 + + /// In-flight gesture values: a multiplier and an offset that apply on + /// top of the committed ones until the gesture ends. Kept apart from + /// the committed values so a cancelled gesture leaves nothing behind. + var liveScale: Double = 1 + var liveSpin: Double = 0 + + /// What the wearer is actually looking at, gesture included. + var visibleSide: Double { + (side * liveScale).clamped(to: Self.sideRange) + } + + var visibleSpin: Double { spin + liveSpin } + + /// The entity scale that turns the one built render into that size. + var entityScale: Float { Float(visibleSide / Self.builtSide) } + + func commitScale(_ magnification: Double) { + side = (side * magnification).clamped(to: Self.sideRange) + liveScale = 1 + } + + func commitSpin(_ radians: Double) { + spin += radians + liveSpin = 0 + } + + func resetPlacement() { + side = 0.6 + spin = 0 + liveScale = 1 + liveSpin = 0 + } + /// The runner whose canvas goes on the table. Set by `CanvasPane` when - /// the space opens; nil means nothing to draw. + /// the space opens from the app. var runner: RunnerModel? - /// Length of the canvas's side on the table, in metres. - var side: Size = .sixty - /// Anchor to a detected horizontal surface, or hang at a fixed spot. /// The fixed spot is not only a fallback — it is the only mode the /// simulator can show, since ARKit finds no planes there, which is why @@ -70,24 +125,15 @@ runner = made return made } + } - /// Discrete sizes rather than a slider, because changing the size - /// rebuilds the attachment at a new point size (that is the whole - /// point — scaling the entity instead would resample one texture and - /// measure the wrong thing). Rare, deliberate changes suit a rebuild; - /// a slider dragging through them would not. - enum Size: Double, CaseIterable, Identifiable { - case forty = 0.4 - case sixty = 0.6 - case eighty = 0.8 - case hundred = 1.0 - - var id: Double { rawValue } - var label: String { "\(Int(rawValue * 100))cm" } + extension Double { + fileprivate func clamped(to range: ClosedRange) -> Double { + min(max(self, range.lowerBound), range.upperBound) } } - /// The immersive space: one entity, lying flat. + /// The immersive space: one entity, lying flat, sized and spun by hand. struct TableSpikeSpace: View { @Environment(TableSpikeModel.self) private var model @@ -97,18 +143,28 @@ /// factor, and it is the number Phase 1 will need for real. @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 + private static let canvasName = "table-spike-canvas" + var body: some View { RealityView { content in let runner = model.runnerOrSample() - let side = model.side.rawValue * pointsPerMeter + let built = TableSpikeModel.builtSide let canvas = Entity() + canvas.name = Self.canvasName canvas.components.set( ViewAttachmentComponent( - rootView: TableCanvasSheet(runner: runner, side: side))) - // A SwiftUI attachment stands upright facing the viewer; a - // drawing on a table has to lie down. - canvas.orientation = simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) + rootView: TableCanvasSheet( + runner: runner, side: built * pointsPerMeter))) + // The gestures are targeted at the *entity*, so it needs a + // shape to be hit and a component saying it accepts input. + // The box is in the view's own plane (normal +Z) and scales + // with the entity, so it keeps matching the sheet. + canvas.components.set(InputTargetComponent()) + canvas.components.set( + CollisionComponent(shapes: [ + .generateBox(size: [Float(built), Float(built), 0.005]) + ])) if model.anchorsToTable { content.add( @@ -119,19 +175,46 @@ else { // **An immersive space's origin is on the floor**, under // where the wearer started — not at eye level. The first - // try put this at y = -0.4 for "desk height below the - // eyes" and buried both entities under the floor, which - // looks exactly like nothing rendering at all. + // try put this at y = -0.4 for "desk height, below the + // eyes" and buried it under the floor, which looks exactly + // like nothing rendering at all. + // // Higher than a real table so the simulator's fixed // horizontal gaze can see it — this mode is for checking // that the drawing renders, not for judging its height. canvas.position = [0, 1.0, -1.2] content.add(canvas) } + } update: { content in + guard let canvas = Self.canvas(in: content) else { return } + canvas.scale = .init(repeating: model.entityScale) + // Lie flat first, then spin about the world's vertical axis. + canvas.orientation = + simd_quatf(angle: Float(model.visibleSpin), axis: [0, 1, 0]) + * simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) } - // Both of these change what is built, not how it is positioned, so - // they rebuild rather than update. - .id("\(model.side.rawValue)-\(model.anchorsToTable)") + // Only the placement mode rebuilds; size and spin are transforms + // on the entity that is already there. + .id(model.anchorsToTable) + .gesture( + MagnifyGesture() + .targetedToAnyEntity() + .onChanged { model.liveScale = $0.gestureValue.magnification } + .onEnded { model.commitScale($0.gestureValue.magnification) } + ) + .simultaneousGesture( + RotateGesture() + .targetedToAnyEntity() + .onChanged { model.liveSpin = $0.gestureValue.rotation.radians } + .onEnded { model.commitSpin($0.gestureValue.rotation.radians) } + ) + } + + private static func canvas(in content: RealityViewContent) -> Entity? { + for root in content.entities { + if let found = root.findEntity(named: canvasName) { return found } + } + return nil } } @@ -147,6 +230,10 @@ // pen is black and a table is not white. .background(.white) .frame(width: side, height: side) + // The pinch and the twist belong to the entity. Left hit- + // testable, this view would swallow them first and the sheet + // could never be resized. + .allowsHitTesting(false) } } @@ -166,16 +253,6 @@ } .buttonStyle(.borderedProminent) - Picker(selection: $model.side) { - ForEach(TableSpikeModel.Size.allCases) { size in - Text(verbatim: size.label).tag(size) - } - } label: { - Text(verbatim: "おおきさ") - } - .pickerStyle(.segmented) - .fixedSize() - Toggle(isOn: $model.anchorsToTable) { Text(verbatim: "平面に置く") } @@ -183,6 +260,20 @@ .fixedSize() Spacer() + + // The readout is the measurement: there is no right size, but + // where wearers actually settle is worth knowing before + // Phase 1 picks a default. + Text(verbatim: "\(Int((model.visibleSide * 100).rounded()))cm") + .monospacedDigit() + Text(verbatim: "\(Int(Angle(radians: model.visibleSpin).degrees.rounded()))°") + .monospacedDigit() + .foregroundStyle(.secondary) + Button("もとに もどす", systemImage: "arrow.counterclockwise") { + model.resetPlacement() + } + .labelStyle(.iconOnly) + Text(verbatim: "spike #53") .font(.caption) .foregroundStyle(.secondary) @@ -221,6 +312,9 @@ .font(.largeTitle) Text(verbatim: "つくえの うえに みほんを おいています…") .foregroundStyle(.secondary) + Text(verbatim: "\(Int((model.visibleSide * 100).rounded()))cm") + .monospacedDigit() + .foregroundStyle(.secondary) } .padding(40) .task { From 9819ba2a4a1a26ef4fe4a7f78a7bc777a2d41718 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Fri, 14 Aug 2026 06:59:58 +0900 Subject: [PATCH 11/33] Turn the sheet with a wrist, and slide it with a drag (#53 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Localizable.xcstrings | 3 + App/Views/TableSpike.swift | 118 ++++++++++++++++++++++++++++--------- 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 1dd59d6..4b57c18 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -1359,6 +1359,9 @@ }, "つくえに おく" : { + }, + "もとに もどす" : { + } }, "version" : "1.0" diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift index 35d3dd4..da315b6 100644 --- a/App/Views/TableSpike.swift +++ b/App/Views/TableSpike.swift @@ -1,6 +1,7 @@ #if os(visionOS) import RealityKit + import Spatial import SwiftUI import TortoiseBlocksKit import TortoiseUI @@ -11,10 +12,10 @@ // 1. Is a drawing laid on a real table legible as line work? — **yes**, // answered on device. // 2. How big should it be? — **wrong question.** There is no right size; - // the wearer sets it, so the size and the spin are gestures now and - // what is left to learn is where people actually land. + // the wearer sets it. Size, spin and position are all gestures now, + // and what is left to learn is where people actually land. // 3. Does a 10,000-command program still play at a usable frame rate? - // — still open. + // — **yes**, answered on device. // // It deliberately bolts onto the existing DocumentGroup app rather than // building the viewer #53 actually describes: the point is to measure the @@ -52,16 +53,29 @@ var side: Double = 0.6 static let sideRange: ClosedRange = 0.15...2.0 - /// Committed rotation about the vertical axis, in radians. A sheet - /// lying flat has its normal straight up, so a plain 2-D twist *is* - /// the vertical-axis spin — no 3-D rotation gesture needed. + /// Committed rotation about the vertical axis, in radians. + /// + /// This started on a 2-D `RotateGesture`, reasoning that a sheet lying + /// flat has its normal straight up so a plain twist *is* the + /// vertical-axis spin. The geometry is right and the gesture is wrong: + /// on visionOS a 2-D rotate wants **two hands**, so a one-handed wrist + /// turn did nothing, and two-handed attempts fought the magnify. + /// `RotateGesture3D(constrainedToAxis: .y)` is the one that means + /// "turn your wrist, and only the vertical axis counts". var spin: Double = 0 - /// In-flight gesture values: a multiplier and an offset that apply on - /// top of the committed ones until the gesture ends. Kept apart from - /// the committed values so a cancelled gesture leaves nothing behind. + /// Committed translation, in the entity's **parent** space — which is + /// the plane anchor when there is one, so the drag stays right without + /// the app ever reading the anchor's transform (which visionOS does + /// not hand out anyway). + var offset: SIMD3 = .zero + + /// In-flight gesture values, applying on top of the committed ones + /// until the gesture ends. Kept apart so a cancelled gesture leaves + /// nothing behind. var liveScale: Double = 1 var liveSpin: Double = 0 + var liveOffset: SIMD3 = .zero /// What the wearer is actually looking at, gesture included. var visibleSide: Double { @@ -70,6 +84,8 @@ var visibleSpin: Double { spin + liveSpin } + var visibleOffset: SIMD3 { offset + liveOffset } + /// The entity scale that turns the one built render into that size. var entityScale: Float { Float(visibleSide / Self.builtSide) } @@ -83,11 +99,25 @@ liveSpin = 0 } + func commitOffset(_ translation: SIMD3) { + offset += translation + liveOffset = .zero + } + func resetPlacement() { side = 0.6 spin = 0 + offset = .zero liveScale = 1 liveSpin = 0 + liveOffset = .zero + } + + /// The signed turn a constrained `RotateGesture3D` describes. Held to + /// the vertical axis, the rotation's own axis comes back as ±Y and its + /// sign is the direction — the angle alone is unsigned. + static func verticalAngle(of rotation: Rotation3D) -> Double { + rotation.angle.radians * (rotation.axis.y < 0 ? -1 : 1) } /// The runner whose canvas goes on the table. Set by `CanvasPane` when @@ -173,41 +203,73 @@ ).addingChild(canvas)) } else { - // **An immersive space's origin is on the floor**, under - // where the wearer started — not at eye level. The first - // try put this at y = -0.4 for "desk height, below the - // eyes" and buried it under the floor, which looks exactly - // like nothing rendering at all. - // - // Higher than a real table so the simulator's fixed - // horizontal gaze can see it — this mode is for checking - // that the drawing renders, not for judging its height. - canvas.position = [0, 1.0, -1.2] content.add(canvas) } } update: { content in guard let canvas = Self.canvas(in: content) else { return } + canvas.position = Self.home(anchored: model.anchorsToTable) + model.visibleOffset canvas.scale = .init(repeating: model.entityScale) - // Lie flat first, then spin about the world's vertical axis. + // Lie flat first, then spin about the parent's vertical axis — + // which is the plane's normal when anchored, and up either way. canvas.orientation = simd_quatf(angle: Float(model.visibleSpin), axis: [0, 1, 0]) * simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) } - // Only the placement mode rebuilds; size and spin are transforms - // on the entity that is already there. + // Only the placement mode rebuilds; size, spin and position are + // transforms on the entity that is already there. .id(model.anchorsToTable) + // All three run together, the way a hand does them: a pinch that + // drifts and turns should move and spin, not pick one. The + // rotation's 5° threshold is what keeps an ordinary drag from + // spinning the sheet on the way. .gesture( - MagnifyGesture() + DragGesture() .targetedToAnyEntity() - .onChanged { model.liveScale = $0.gestureValue.magnification } - .onEnded { model.commitScale($0.gestureValue.magnification) } + .onChanged { model.liveOffset = Self.translation(of: $0) } + .onEnded { model.commitOffset(Self.translation(of: $0)) } ) .simultaneousGesture( - RotateGesture() + RotateGesture3D(constrainedToAxis: .y, minimumAngleDelta: .degrees(5)) .targetedToAnyEntity() - .onChanged { model.liveSpin = $0.gestureValue.rotation.radians } - .onEnded { model.commitSpin($0.gestureValue.rotation.radians) } + .onChanged { + model.liveSpin = TableSpikeModel.verticalAngle(of: $0.gestureValue.rotation) + } + .onEnded { + model.commitSpin( + TableSpikeModel.verticalAngle(of: $0.gestureValue.rotation)) + } ) + .simultaneousGesture( + MagnifyGesture() + .targetedToAnyEntity() + .onChanged { model.liveScale = $0.gestureValue.magnification } + .onEnded { model.commitScale($0.gestureValue.magnification) } + ) + } + + /// Where the sheet sits before the wearer moves it. + /// + /// **An immersive space's origin is on the floor**, under where the + /// wearer started — not at eye level. The first try put the fixed + /// placement at y = -0.4 for "desk height, below the eyes" and buried + /// it under the floor, which looks exactly like nothing rendering at + /// all. The anchored one is the plane's own origin, so it needs no + /// height of its own; the fixed one sits higher than a real table so + /// the simulator's fixed horizontal gaze can see it, since that mode + /// is for checking that the drawing renders rather than for judging + /// where it belongs. + private static func home(anchored: Bool) -> SIMD3 { + anchored ? .zero : [0, 1.0, -1.2] + } + + /// A drag's translation in the entity's parent space, which is where + /// `position` is read. Converting to `.scene` instead would be wrong + /// the moment the sheet hangs off a plane anchor. + private static func translation( + of value: EntityTargetValue + ) -> SIMD3 { + guard let parent = value.entity.parent else { return .zero } + return value.convert(value.gestureValue.translation3D, from: .local, to: parent) } private static func canvas(in content: RealityViewContent) -> Entity? { From ca046441a6168b8d6a2ff6f31af11ab792f29f2a Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Fri, 14 Aug 2026 07:11:12 +0900 Subject: [PATCH 12/33] Keep the sheet on the table, and stop stretching the render (#53 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/TableSpike.swift | 52 +++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift index da315b6..fa9e0ef 100644 --- a/App/Views/TableSpike.swift +++ b/App/Views/TableSpike.swift @@ -37,21 +37,24 @@ final class TableSpikeModel { static let spaceID = "table-spike" - /// The point size the attachment is built at, expressed in metres. + /// Committed size of the sheet's side, in metres, bounded at both ends. /// - /// The wearer's pinch scales the *entity*, not this — rebuilding the - /// attachment mid-gesture would re-run the anchor search and make the - /// drawing jump every time it is resized. So one render is made at - /// this size and scaled from there, which never softens below it and - /// may above it: **whether the upper end goes visibly soft is a Phase 0 - /// question to answer on device**, and the reason this is 1m rather - /// than the 60cm the drawing usually sits at. - static let builtSide: Double = 1.0 - - /// Committed size of the sheet's side, in metres. Bounded because an - /// unbounded pinch reaches a 50m canvas in about a second. + /// The bounds started out only as a guard — an unbounded pinch reaches + /// a 50m canvas in about a second — but on device they turned out to + /// be the answer to a second problem too. The wearer's pinch scales the + /// *entity* rather than rebuilding the attachment (rebuilding mid-drag + /// would re-run the anchor search and make the drawing jump), so one + /// render is stretched, and past its built size that goes soft. Mildly, + /// on device — but bounding the size is what makes it not matter. var side: Double = 0.6 - static let sideRange: ClosedRange = 0.15...2.0 + static let sideRange: ClosedRange = 0.2...1.2 + + /// The size the one render is built at: **the top of the range**, so + /// the sheet is only ever scaled *down*, which never softens. The cost + /// is that the common 60cm view carries a render sized for 1.2m, which + /// is the cheaper half of the trade — the alternative was a rebuild + /// per resize, and a drawing that jumps every time you touch it. + static var builtSide: Double { sideRange.upperBound } /// Committed rotation about the vertical axis, in radians. /// @@ -225,8 +228,14 @@ .gesture( DragGesture() .targetedToAnyEntity() - .onChanged { model.liveOffset = Self.translation(of: $0) } - .onEnded { model.commitOffset(Self.translation(of: $0)) } + .onChanged { + model.liveOffset = Self.translation( + of: $0, keepingOnPlane: model.anchorsToTable) + } + .onEnded { + model.commitOffset( + Self.translation(of: $0, keepingOnPlane: model.anchorsToTable)) + } ) .simultaneousGesture( RotateGesture3D(constrainedToAxis: .y, minimumAngleDelta: .degrees(5)) @@ -265,11 +274,20 @@ /// A drag's translation in the entity's parent space, which is where /// `position` is read. Converting to `.scene` instead would be wrong /// the moment the sheet hangs off a plane anchor. + /// + /// `keepingOnPlane` is what stops a drag lifting the sheet off the + /// table: on a plane anchor the parent's Y **is** the plane's normal, + /// so dropping that one component slides the drawing along the surface + /// instead of into the air. It is off in the fixed placement, where + /// there is no surface to stay on and height is the only way to put + /// the sheet somewhere sensible. private static func translation( - of value: EntityTargetValue + of value: EntityTargetValue, keepingOnPlane: Bool ) -> SIMD3 { guard let parent = value.entity.parent else { return .zero } - return value.convert(value.gestureValue.translation3D, from: .local, to: parent) + var delta = value.convert(value.gestureValue.translation3D, from: .local, to: parent) + if keepingOnPlane { delta.y = 0 } + return delta } private static func canvas(in content: RealityViewContent) -> Entity? { From e03e86408c69f1f066375e2dedf7c71354e33bd0 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Fri, 14 Aug 2026 07:19:22 +0900 Subject: [PATCH 13/33] Give the sheet 2m back, and stop paying for a render that buys nothing (#53 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/TableSpike.swift | 39 +++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift index fa9e0ef..f834412 100644 --- a/App/Views/TableSpike.swift +++ b/App/Views/TableSpike.swift @@ -37,24 +37,29 @@ final class TableSpikeModel { static let spaceID = "table-spike" - /// Committed size of the sheet's side, in metres, bounded at both ends. - /// - /// The bounds started out only as a guard — an unbounded pinch reaches - /// a 50m canvas in about a second — but on device they turned out to - /// be the answer to a second problem too. The wearer's pinch scales the - /// *entity* rather than rebuilding the attachment (rebuilding mid-drag - /// would re-run the anchor search and make the drawing jump), so one - /// render is stretched, and past its built size that goes soft. Mildly, - /// on device — but bounding the size is what makes it not matter. + /// Committed size of the sheet's side, in metres. Bounded because an + /// unbounded pinch reaches a 50m canvas in about a second; 2m at the + /// top because a drawing that fills a whole table is a thing people + /// want, and it stays legible there. var side: Double = 0.6 - static let sideRange: ClosedRange = 0.2...1.2 - - /// The size the one render is built at: **the top of the range**, so - /// the sheet is only ever scaled *down*, which never softens. The cost - /// is that the common 60cm view carries a render sized for 1.2m, which - /// is the cheaper half of the trade — the alternative was a rebuild - /// per resize, and a drawing that jumps every time you touch it. - static var builtSide: Double { sideRange.upperBound } + static let sideRange: ClosedRange = 0.2...2.0 + + /// The size the one render is built at. + /// + /// The wearer's pinch scales the *entity* rather than rebuilding the + /// attachment, because rebuilding mid-drag re-runs the anchor search + /// and makes the drawing jump. Enlarging past this size goes slightly + /// soft — so this was tried at the top of the range, on the theory + /// that a sheet only ever scaled *down* would never soften. + /// + /// **It made no observable difference on device.** Which says the + /// number does not control what it looks like: visionOS decides an + /// attachment's render resolution for itself, and the point size it is + /// handed is a layout size, not a fidelity knob. So this is back to a + /// plain 1m — carrying a render sized for 2m bought nothing and cost + /// four times the pixels. If sharpness ever has to be pushed, this is + /// the wrong lever; a rebuild at the settled size is the right one. + static let builtSide: Double = 1.0 /// Committed rotation about the vertical axis, in radians. /// From a8c976dd930af487919194d2b04cb9fdf46a1474 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Fri, 14 Aug 2026 07:29:18 +0900 Subject: [PATCH 14/33] Say what builtSide actually is, and walk back an overclaim (#53 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/TableSpike.swift | 40 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift index f834412..ff9a788 100644 --- a/App/Views/TableSpike.swift +++ b/App/Views/TableSpike.swift @@ -44,21 +44,26 @@ var side: Double = 0.6 static let sideRange: ClosedRange = 0.2...2.0 - /// The size the one render is built at. + /// The size the one render is built at — **the resolution knob**. /// /// The wearer's pinch scales the *entity* rather than rebuilding the /// attachment, because rebuilding mid-drag re-runs the anchor search - /// and makes the drawing jump. Enlarging past this size goes slightly - /// soft — so this was tried at the top of the range, on the theory - /// that a sheet only ever scaled *down* would never soften. + /// and makes the drawing jump. So the sheet carries one render of a + /// fixed point size and stretches it, and this is that size. /// - /// **It made no observable difference on device.** Which says the - /// number does not control what it looks like: visionOS decides an - /// attachment's render resolution for itself, and the point size it is - /// handed is a layout size, not a fidelity knob. So this is back to a - /// plain 1m — carrying a render sized for 2m bought nothing and cost - /// four times the pixels. If sharpness ever has to be pushed, this is - /// the wrong lever; a rebuild at the settled size is the right one. + /// visionOS lays out at **1m = 1360pt**, so 1.0 here is a 1360pt² + /// render. What that buys depends on how big the sheet is shown: at + /// the 60cm default it is ~2270pt per displayed metre, at the 2m top + /// of the range ~680 — a 3.3× spread across the range, which is where + /// the slight softness at the large end comes from. Raising this is + /// the fix, and it costs the square: 2.0 would be 2720pt², four times + /// the pixels (and heading toward texture-size limits at 2× backing). + /// + /// Do **not** conclude from the one measurement so far that this has + /// no effect. It was tried at 1.2 against 1.0 and looked identical on + /// device — but that is a 20% change in linear resolution, which is + /// about what "identical" should look like. A real test is a 2× swing, + /// judged at *one* displayed size. static let builtSide: Double = 1.0 /// Committed rotation about the vertical axis, in radians. @@ -391,6 +396,11 @@ @Environment(TableSpikeModel.self) private var model @Environment(\.openImmersiveSpace) private var openSpace + /// Only here to make `builtSide` legible as what it is: the render's + /// size in points. Printed rather than reasoned about, because the + /// points-per-metre figure is the scene's to decide. + @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 + var body: some View { VStack(spacing: 12) { Text(verbatim: "spike #53") @@ -400,6 +410,14 @@ Text(verbatim: "\(Int((model.visibleSide * 100).rounded()))cm") .monospacedDigit() .foregroundStyle(.secondary) + Text( + verbatim: + "1m = \(Int(pointsPerMeter))pt / " + + "render \(Int(TableSpikeModel.builtSide * pointsPerMeter))pt²" + ) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) } .padding(40) .task { From 160b3350929103247bb227c9022054d883e5e720 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Fri, 14 Aug 2026 08:37:59 +0900 Subject: [PATCH 15/33] Make visionOS a viewer instead of the editor (#53 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Document/BlocksDocument.swift | 11 +- App/Localizable.xcstrings | 90 ++++++ App/TortoiseBlocksApp.swift | 90 +++--- App/Views/ContentView.swift | 7 +- App/Views/LaunchScene.swift | 12 +- App/Views/PlaybackControls.swift | 7 +- App/Views/TableCanvas.swift | 331 ++++++++++++++++++++++ App/Views/TableSpike.swift | 442 ------------------------------ App/Views/ViewerWindow.swift | 212 ++++++++++++++ 9 files changed, 701 insertions(+), 501 deletions(-) create mode 100644 App/Views/TableCanvas.swift delete mode 100644 App/Views/TableSpike.swift create mode 100644 App/Views/ViewerWindow.swift diff --git a/App/Document/BlocksDocument.swift b/App/Document/BlocksDocument.swift index 0952ade..0a30319 100644 --- a/App/Document/BlocksDocument.swift +++ b/App/Document/BlocksDocument.swift @@ -25,6 +25,15 @@ struct BlocksDocument: FileDocument { guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } + self.project = try Self.project(from: data) + } + + /// Decoding, apart from `FileDocument`, because the visionOS viewer (#53) + /// reads `.tortoise` files through a `fileImporter` and has no + /// `DocumentGroup` to hand it a `ReadConfiguration`. The version gate has + /// to be the same one, or the two ways in would disagree about which files + /// are from the future. + static func project(from data: Data) throws -> BlocksProject { // Probe just the version before the full decode: a newer file's // unknown block shapes would fail the full decode with a generic // "corrupt" error before the version gate could explain it. @@ -32,7 +41,7 @@ struct BlocksDocument: FileDocument { guard probe.schemaVersion <= BlocksProject.currentSchemaVersion else { throw DocumentError.newerSchema } - self.project = try JSONDecoder().decode(BlocksProject.self, from: data) + return try JSONDecoder().decode(BlocksProject.self, from: data) } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 4b57c18..ff762c4 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -184,6 +184,16 @@ } } }, + "Can't Open This" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "これは ひらけません" + } + } + } + }, "Cancel" : { "localizations" : { "ja" : { @@ -634,6 +644,16 @@ } } }, + "No Drawing" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "えが ありません" + } + } + } + }, "Nothing to play" : { "localizations" : { "ja" : { @@ -674,6 +694,16 @@ } } }, + "Open" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ひらく" + } + } + } + }, "Open Another Drawing" : { "localizations" : { "ja" : { @@ -684,6 +714,16 @@ } } }, + "Open a drawing, or start from a sample." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "えを ひらくか、サンプルから はじめてね。" + } + } + } + }, "or less" : { "localizations" : { "ja" : { @@ -824,6 +864,26 @@ } } }, + "Pinch to resize, twist to turn, drag to move." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つまむと おおきさ、ひねると むき、うごかすと ばしょが かわるよ。" + } + } + } + }, + "Place on Table" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つくえに おく" + } + } + } + }, "Position" : { "localizations" : { "ja" : { @@ -834,6 +894,16 @@ } } }, + "Put Away" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "しまう" + } + } + } + }, "purple" : { "localizations" : { "ja" : { @@ -894,6 +964,16 @@ } } }, + "Reset Position" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "もとの ばしょに もどす" + } + } + } + }, "Resume" : { "localizations" : { "ja" : { @@ -1064,6 +1144,16 @@ } } }, + "Sit on a Table" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つくえに のせる" + } + } + } + }, "Tap a palette block, or drag one here" : { "localizations" : { "ja" : { diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index f575c0b..8975ea4 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -2,60 +2,60 @@ import SwiftUI @main struct TortoiseBlocksApp: App { - // Phase 0 spike (#53) — the immersive space is its own Scene and cannot - // see the document's RunnerModel, so the two meet here. Delete with - // `TableSpike.swift`. + // The viewer's whole state, shared by its two scenes (#53). One `Scene` + // cannot see another's, and the window and the table are exactly that + // split: controls here, drawing there. #if os(visionOS) - @State private var tableSpike = TableSpikeModel() + @State private var viewer = ViewerModel() #endif var body: some Scene { - // Phase 0 spike (#53) — first in the body so `-TBSpike YES` can take - // the launch off the DocumentGroup. Suppressed without the flag, so a - // normal launch is unchanged. Delete with `TableSpike.swift`. + // **visionOS is a viewer, not the editor** (#53), so its scene tree is + // a different tree rather than the same one with adjustments. + // + // No `DocumentGroup`: a `.fileImporter` in a plain window opens a + // drawing in one press, which is exactly what #52 could never make a + // DocumentGroup do there — `dismiss()` only reveals the system's own + // 「書類」 button, and `openDocument` / `newDocument` are unavailable on + // the platform. Dropping the apparatus drops the editing UI with it, + // and that is what makes a visionOS build worth having at all instead + // of an iPad app in a window. #if os(visionOS) - WindowGroup(id: "table-spike-launcher") { - TableSpikeLauncher() - .environment(tableSpike) + WindowGroup { + ViewerWindow(model: viewer) } - .defaultLaunchBehavior(TableSpikeModel.autoOpens ? .presented : .suppressed) - // Small, so it does not stand in front of the thing being looked at. - .defaultSize(width: 320, height: 140) - #endif - - DocumentGroup(newDocument: BlocksDocument()) { file in - ContentView(document: file.$document) - #if os(visionOS) - .environment(tableSpike) - #endif - } - .commands { - TortoiseBlocksCommands() - } - // A default worth having anyway — three panes need room, and macOS - // otherwise opens something narrower than the palette, workspace and - // canvas want. It is also exactly the App Store's macOS screenshot - // size: a Retina backing scale of 2 makes 1280×800pt capture as - // 2560×1600px, so a window at its default size needs no cropping or - // resampling. Only a default — macOS restores a window's saved frame - // in preference to it, so a capture wants that state cleared first. - .defaultWindowSize() - - // The custom launch screen (#32). `DocumentGroupLaunchScene` is - // unavailable on macOS and SwiftUI has no empty `Scene` to return in - // its place, so this `#if` can't hide inside a modifier the way the - // ones in `PlatformModifiers` do. - #if !os(macOS) - LaunchScene() - #endif + .defaultSize(width: 560, height: 520) - // Phase 0 spike (#53) — delete with `TableSpike.swift`. - #if os(visionOS) - ImmersiveSpace(id: TableSpikeModel.spaceID) { - TableSpikeSpace() - .environment(tableSpike) + ImmersiveSpace(id: ViewerModel.spaceID) { + TableCanvasSpace(model: viewer) } .immersionStyle(selection: .constant(.mixed), in: .mixed) + #else + DocumentGroup(newDocument: BlocksDocument()) { file in + ContentView(document: file.$document) + } + .commands { + TortoiseBlocksCommands() + } + // A default worth having anyway — three panes need room, and macOS + // otherwise opens something narrower than the palette, workspace + // and canvas want. It is also exactly the App Store's macOS + // screenshot size: a Retina backing scale of 2 makes 1280×800pt + // capture as 2560×1600px, so a window at its default size needs no + // cropping or resampling. Only a default — macOS restores a + // window's saved frame in preference to it, so a capture wants that + // state cleared first. + .defaultWindowSize() + + // The custom launch screen (#32) — iPadOS only. It is + // `DocumentGroupLaunchScene`, so it has nothing to attach to on the + // platform with no DocumentGroup, and nothing to do on macOS, which + // keeps the standard open panel. (It never appeared on visionOS + // even while that platform had a DocumentGroup: the system document + // browser is shown straight away there.) + #if os(iOS) + LaunchScene() + #endif #endif } } diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 7e44f1b..abd55e2 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -151,15 +151,10 @@ struct CanvasPane: View { } Divider() PlaybackControls( - workspace: workspace, runner: runner, + blocks: workspace.blocks, runner: runner, isStale: runner.isStale(comparedTo: workspace.blocks) ) .padding() - - // Phase 0 spike (#53) — delete with `TableSpike.swift`. - #if os(visionOS) - TableSpikeBar(runner: runner) - #endif } // The document title belongs to the sidebar's bar, once (#31). On // iPadOS the DocumentGroup hands its title chrome to *both* ends of the diff --git a/App/Views/LaunchScene.swift b/App/Views/LaunchScene.swift index a7dcc0e..8ab33ad 100644 --- a/App/Views/LaunchScene.swift +++ b/App/Views/LaunchScene.swift @@ -1,12 +1,14 @@ -#if !os(macOS) +#if os(iOS) import SwiftUI - /// The screen in front of the system document browser (#32). + /// The screen in front of the system document browser (#32) — iPadOS only. /// - /// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)` and - /// nothing else, so this file covers iPadOS and visionOS while the Mac - /// keeps the standard open panel. + /// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)`, so this + /// once covered visionOS too. It never actually appeared there — visionOS + /// shows the system document browser straight away — and #53 took the + /// DocumentGroup off that platform entirely, leaving this nothing to sit in + /// front of. The Mac keeps the standard open panel. struct LaunchScene: Scene { var body: some Scene { DocumentGroupLaunchScene( diff --git a/App/Views/PlaybackControls.swift b/App/Views/PlaybackControls.swift index 3f6af80..d06791c 100644 --- a/App/Views/PlaybackControls.swift +++ b/App/Views/PlaybackControls.swift @@ -11,7 +11,10 @@ import TortoiseUI /// "roll the dice again" lives on the canvas itself (`CanvasRollAgainButton`) /// because it re-runs the program rather than moving the playhead. struct PlaybackControls: View { - let workspace: WorkspaceEditor + /// The program the run button runs. Deliberately the blocks and not the + /// `WorkspaceEditor` they came from: the transport never edited anything, + /// and the visionOS viewer (#53) has a program with no editor behind it. + let blocks: [Block] @Bindable var runner: RunnerModel /// Whether the workspace has been edited since the run on screen. /// Hoisted to the pane rather than computed here: deciding it hashes the @@ -85,7 +88,7 @@ struct PlaybackControls: View { private func perform() { switch action { - case .run: runner.run(workspace.blocks) + case .run: runner.run(blocks) case .pause: runner.player.isPaused = true case .play, .resume: runner.player.isPaused = false case .replay: runner.replay() diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift new file mode 100644 index 0000000..2f0b3b7 --- /dev/null +++ b/App/Views/TableCanvas.swift @@ -0,0 +1,331 @@ +#if os(visionOS) + + import RealityKit + import Spatial + import SwiftUI + import TortoiseBlocksKit + import TortoiseUI + + // The visionOS viewer's drawing surface (#53): a sheet of paper lying on a + // real table, with the tortoise walking across it. + // + // **No texture pipeline, and no stroke geometry.** #53 framed the renderer + // as a choice between the two; `ViewAttachmentComponent` (visionOS 26) is a + // third answer that costs neither. It puts a live SwiftUI view into the + // RealityKit scene, so what lies on the table is the *same* + // `TortoiseCanvas` the iPad and Mac panes draw, driven by the same + // `CommandPlayer`. Nothing about the command stream changes, which is what + // keeps the executing-block alignment (`RunnerModel.currentBlockID`) + // available for free when the read-only program window arrives. + + /// Everything the viewer holds: the program on screen, the runner playing + /// it, and where the sheet sits in the room. + /// + /// One object across two scenes — the window owns the controls, the + /// immersive space owns the drawing, and a `Scene` cannot see another + /// scene's state. + @Observable + @MainActor + final class ViewerModel { + static let spaceID = "table" + + // MARK: What is loaded + + /// The name shown in the window. A document's file name, a sample's + /// title, or empty when nothing has been opened yet. + private(set) var title = "" + private(set) var blocks: [Block] = [] + let runner = RunnerModel() + + /// Set when opening a file fails, and shown as an alert. Kept as the + /// message rather than the error so the version gate's wording + /// (`DocumentError.newerSchema`) survives to the alert unchanged. + var openFailure: String? + + var hasProgram: Bool { !blocks.isEmpty } + + func load(_ blocks: [Block], title: String) { + self.blocks = blocks + self.title = title + runner.run(blocks, startPaused: true) + } + + /// Opens a `.tortoise` from the file importer. The URL comes from + /// outside the sandbox, so it has to be asked for before it can be + /// read — and given back whether or not the read worked. + func open(_ url: URL) { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + let project = try BlocksDocument.project(from: Data(contentsOf: url)) + load(project.blocks, title: url.deletingPathExtension().lastPathComponent) + } + catch { + openFailure = error.localizedDescription + } + } + + // MARK: Where the sheet sits + + /// Committed size of the sheet's side, in metres. Bounded because an + /// unbounded pinch reaches a 50m canvas in about a second; 2m at the + /// top because a drawing that fills a whole table is a thing people + /// want, and it stays legible there. + var side: Double = 0.6 + static let sideRange: ClosedRange = 0.2...2.0 + + /// The size the one render is built at — **the resolution knob**. + /// + /// A pinch scales the *entity* rather than rebuilding the attachment, + /// because rebuilding mid-drag re-runs the anchor search and makes the + /// drawing jump. So the sheet carries one render of a fixed point size + /// and stretches it, and this is that size. + /// + /// visionOS lays out at **1m = 1360pt**, so 1.0 here is a 1360pt² + /// render. What that buys depends on how big the sheet is shown: about + /// 2270pt per displayed metre at the 60cm default, 680 at the 2m top of + /// the range — a 3.3× spread, which is where the slight softness at the + /// large end comes from. Raising this is the fix and it costs the + /// square: 2.0 would be 2720pt², four times the pixels, heading toward + /// texture-size limits at 2× backing. + /// + /// Do **not** conclude from the one measurement so far that this has no + /// effect. It was tried at 1.2 against 1.0 and looked identical on + /// device — but that is a 20% change in linear resolution, which is + /// about what "identical" should look like. A real test is a 2× swing, + /// judged at *one* displayed size. + static let builtSide: Double = 1.0 + + /// Committed rotation about the vertical axis, in radians. + /// + /// A 2-D `RotateGesture` is the wrong tool even though the geometry + /// invites it: on visionOS it wants **two hands**, so a one-handed + /// wrist turn does nothing and two-handed attempts fight the magnify. + /// `RotateGesture3D(constrainedToAxis: .y)` is the one that means "turn + /// your wrist, and only the vertical axis counts". + var spin: Double = 0 + + /// Committed translation, in the entity's **parent** space — the plane + /// anchor when there is one, so a drag stays right without the app ever + /// reading that anchor's transform (which visionOS does not hand out). + var offset: SIMD3 = .zero + + /// In-flight gesture values, applying on top of the committed ones + /// until the gesture ends. Kept apart so a cancelled gesture leaves + /// nothing behind. + var liveScale: Double = 1 + var liveSpin: Double = 0 + var liveOffset: SIMD3 = .zero + + /// Sit on a detected horizontal surface, or hang in front of the + /// wearer. The second is the fallback #53 asks for — a room with no + /// table, a refused world-sensing prompt, and the simulator, where + /// ARKit finds no planes at all. + var sitsOnTable = true + + var isPlaced = false + + var visibleSide: Double { (side * liveScale).clamped(to: Self.sideRange) } + var visibleSpin: Double { spin + liveSpin } + var visibleOffset: SIMD3 { offset + liveOffset } + + /// The entity scale that turns the one built render into that size. + var entityScale: Float { Float(visibleSide / Self.builtSide) } + + func commitScale(_ magnification: Double) { + side = (side * magnification).clamped(to: Self.sideRange) + liveScale = 1 + } + + func commitSpin(_ radians: Double) { + spin += radians + liveSpin = 0 + } + + func commitOffset(_ translation: SIMD3) { + offset += translation + liveOffset = .zero + } + + func resetPlacement() { + side = 0.6 + spin = 0 + offset = .zero + liveScale = 1 + liveSpin = 0 + liveOffset = .zero + } + + /// The signed turn a constrained `RotateGesture3D` describes. Held to + /// the vertical axis, the rotation's own axis comes back as ±Y and its + /// sign is the direction — the angle alone is unsigned. + static func verticalAngle(of rotation: Rotation3D) -> Double { + rotation.angle.radians * (rotation.axis.y < 0 ? -1 : 1) + } + } + + extension Double { + fileprivate func clamped(to range: ClosedRange) -> Double { + min(max(self, range.lowerBound), range.upperBound) + } + } + + /// The immersive space: one sheet, lying flat, placed by hand. + struct TableCanvasSpace: View { + let model: ViewerModel + + /// visionOS lays SwiftUI out in points and the world in metres. This is + /// the conversion, read from the scene rather than guessed at — it is + /// what makes "60cm" mean 60cm instead of an arbitrary scale factor. + @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 + + private static let sheetName = "table-canvas" + + var body: some View { + RealityView { content in + let sheet = Entity() + sheet.name = Self.sheetName + sheet.components.set( + ViewAttachmentComponent( + rootView: TableCanvasSheet( + runner: model.runner, + side: ViewerModel.builtSide * pointsPerMeter))) + // The gestures target the *entity*, so it needs a shape to be + // hit and a component saying it accepts input. The box lies in + // the view's own plane and scales with the entity, so it keeps + // matching the sheet. + sheet.components.set(InputTargetComponent()) + sheet.components.set( + CollisionComponent(shapes: [ + .generateBox( + size: [ + Float(ViewerModel.builtSide), Float(ViewerModel.builtSide), 0.005, + ] + ) + ])) + + if model.sitsOnTable { + content.add( + AnchorEntity( + .plane(.horizontal, classification: .table, minimumBounds: [0.2, 0.2]) + ).addingChild(sheet)) + } + else { + content.add(sheet) + } + } update: { content in + guard let sheet = Self.sheet(in: content) else { return } + sheet.position = Self.home(onTable: model.sitsOnTable) + model.visibleOffset + sheet.scale = .init(repeating: model.entityScale) + // Lie flat first, then spin about the parent's vertical axis — + // the plane's normal when anchored, and up either way. + sheet.orientation = + simd_quatf(angle: Float(model.visibleSpin), axis: [0, 1, 0]) + * simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) + } + // Only the placement mode rebuilds; size, spin and position are + // transforms on the entity that is already there. + .id(model.sitsOnTable) + // All three run together, the way a hand does them: a pinch that + // drifts and turns should move and spin rather than pick one. The + // rotation's 5° threshold keeps an ordinary drag from spinning the + // sheet on the way past. + .gesture( + DragGesture() + .targetedToAnyEntity() + .onChanged { + model.liveOffset = Self.translation( + of: $0, keepingOnPlane: model.sitsOnTable) + } + .onEnded { + model.commitOffset( + Self.translation(of: $0, keepingOnPlane: model.sitsOnTable)) + } + ) + .simultaneousGesture( + RotateGesture3D(constrainedToAxis: .y, minimumAngleDelta: .degrees(5)) + .targetedToAnyEntity() + .onChanged { + model.liveSpin = ViewerModel.verticalAngle(of: $0.gestureValue.rotation) + } + .onEnded { + model.commitSpin(ViewerModel.verticalAngle(of: $0.gestureValue.rotation)) + } + ) + .simultaneousGesture( + MagnifyGesture() + .targetedToAnyEntity() + .onChanged { model.liveScale = $0.gestureValue.magnification } + .onEnded { model.commitScale($0.gestureValue.magnification) } + ) + } + + /// Where the sheet sits before the wearer moves it. + /// + /// **An immersive space's origin is on the floor**, under where the + /// wearer started — not at eye level. Placing the fallback at a + /// negative height for "desk height, below the eyes" buries it under + /// the floor, which looks exactly like nothing rendering at all. The + /// anchored placement is the plane's own origin and needs no height of + /// its own. + private static func home(onTable: Bool) -> SIMD3 { + onTable ? .zero : [0, 1.0, -1.2] + } + + /// A drag's translation in the entity's parent space, which is where + /// `position` is read. Converting to `.scene` instead would be wrong + /// the moment the sheet hangs off a plane anchor. + /// + /// `keepingOnPlane` is what stops a drag lifting the sheet off the + /// table: on a plane anchor the parent's Y **is** the plane's normal, + /// so dropping that one component slides the drawing along the surface + /// instead of into the air. It is off in the floating placement, where + /// there is no surface to stay on and height is the only way to put the + /// sheet somewhere sensible. + private static func translation( + of value: EntityTargetValue, keepingOnPlane: Bool + ) -> SIMD3 { + guard let parent = value.entity.parent else { return .zero } + var delta = value.convert(value.gestureValue.translation3D, from: .local, to: parent) + if keepingOnPlane { delta.y = 0 } + return delta + } + + private static func sheet(in content: RealityViewContent) -> Entity? { + for root in content.entities { + if let found = root.findEntity(named: sheetName) { return found } + } + return nil + } + } + + /// What actually lies on the table: the app's own canvas, unchanged. + private struct TableCanvasSheet: View { + let runner: RunnerModel + let side: CGFloat + + var body: some View { + TortoiseCanvas(runner.tortoise, player: runner.player) + .tortoiseSprite(CanvasPane.sprite) + // Paper, for the same reason the pane paints it: the default + // pen is black and a table is not white. + .background(.white) + .frame(width: side, height: side) + // The pinch, twist and drag belong to the entity. Left + // hit-testable, this view swallows them first and the sheet can + // never be moved at all. + .allowsHitTesting(false) + } + } + + extension Entity { + /// `content.add(AnchorEntity(…).addingChild(sheet))` reads better than + /// the three statements it replaces, and this file has no other use for + /// a local variable holding the anchor. + fileprivate func addingChild(_ child: Entity) -> Entity { + addChild(child) + return self + } + } + +#endif diff --git a/App/Views/TableSpike.swift b/App/Views/TableSpike.swift deleted file mode 100644 index ff9a788..0000000 --- a/App/Views/TableSpike.swift +++ /dev/null @@ -1,442 +0,0 @@ -#if os(visionOS) - - import RealityKit - import Spatial - import SwiftUI - import TortoiseBlocksKit - import TortoiseUI - - // Phase 0 spike for #53 — **throwaway**. This exists to answer three - // questions on a real Vision Pro and then be deleted: - // - // 1. Is a drawing laid on a real table legible as line work? — **yes**, - // answered on device. - // 2. How big should it be? — **wrong question.** There is no right size; - // the wearer sets it. Size, spin and position are all gestures now, - // and what is left to learn is where people actually land. - // 3. Does a 10,000-command program still play at a usable frame rate? - // — **yes**, answered on device. - // - // It deliberately bolts onto the existing DocumentGroup app rather than - // building the viewer #53 actually describes: the point is to measure the - // rendering, not to prototype the product. Every string here is - // `Text(verbatim:)` so a spike never lands in `Localizable.xcstrings`. - // - // The one real finding is already baked into the shape of this file: - // **no texture pipeline is needed.** `ViewAttachmentComponent` (visionOS - // 26) puts a live SwiftUI view into the RealityKit scene, so the canvas on - // the table is the *same* `TortoiseCanvas` the pane draws, driven by the - // same `CommandPlayer`. Option (a) in #53 costs one entity, and the - // highlight alignment that #53 calls load-bearing is untouched because - // nothing about the command stream changed. - - /// Shared between the document window (which owns the runner) and the - /// immersive space (which is a separate `Scene` and can't see it). - @Observable - @MainActor - final class TableSpikeModel { - static let spaceID = "table-spike" - - /// Committed size of the sheet's side, in metres. Bounded because an - /// unbounded pinch reaches a 50m canvas in about a second; 2m at the - /// top because a drawing that fills a whole table is a thing people - /// want, and it stays legible there. - var side: Double = 0.6 - static let sideRange: ClosedRange = 0.2...2.0 - - /// The size the one render is built at — **the resolution knob**. - /// - /// The wearer's pinch scales the *entity* rather than rebuilding the - /// attachment, because rebuilding mid-drag re-runs the anchor search - /// and makes the drawing jump. So the sheet carries one render of a - /// fixed point size and stretches it, and this is that size. - /// - /// visionOS lays out at **1m = 1360pt**, so 1.0 here is a 1360pt² - /// render. What that buys depends on how big the sheet is shown: at - /// the 60cm default it is ~2270pt per displayed metre, at the 2m top - /// of the range ~680 — a 3.3× spread across the range, which is where - /// the slight softness at the large end comes from. Raising this is - /// the fix, and it costs the square: 2.0 would be 2720pt², four times - /// the pixels (and heading toward texture-size limits at 2× backing). - /// - /// Do **not** conclude from the one measurement so far that this has - /// no effect. It was tried at 1.2 against 1.0 and looked identical on - /// device — but that is a 20% change in linear resolution, which is - /// about what "identical" should look like. A real test is a 2× swing, - /// judged at *one* displayed size. - static let builtSide: Double = 1.0 - - /// Committed rotation about the vertical axis, in radians. - /// - /// This started on a 2-D `RotateGesture`, reasoning that a sheet lying - /// flat has its normal straight up so a plain twist *is* the - /// vertical-axis spin. The geometry is right and the gesture is wrong: - /// on visionOS a 2-D rotate wants **two hands**, so a one-handed wrist - /// turn did nothing, and two-handed attempts fought the magnify. - /// `RotateGesture3D(constrainedToAxis: .y)` is the one that means - /// "turn your wrist, and only the vertical axis counts". - var spin: Double = 0 - - /// Committed translation, in the entity's **parent** space — which is - /// the plane anchor when there is one, so the drag stays right without - /// the app ever reading the anchor's transform (which visionOS does - /// not hand out anyway). - var offset: SIMD3 = .zero - - /// In-flight gesture values, applying on top of the committed ones - /// until the gesture ends. Kept apart so a cancelled gesture leaves - /// nothing behind. - var liveScale: Double = 1 - var liveSpin: Double = 0 - var liveOffset: SIMD3 = .zero - - /// What the wearer is actually looking at, gesture included. - var visibleSide: Double { - (side * liveScale).clamped(to: Self.sideRange) - } - - var visibleSpin: Double { spin + liveSpin } - - var visibleOffset: SIMD3 { offset + liveOffset } - - /// The entity scale that turns the one built render into that size. - var entityScale: Float { Float(visibleSide / Self.builtSide) } - - func commitScale(_ magnification: Double) { - side = (side * magnification).clamped(to: Self.sideRange) - liveScale = 1 - } - - func commitSpin(_ radians: Double) { - spin += radians - liveSpin = 0 - } - - func commitOffset(_ translation: SIMD3) { - offset += translation - liveOffset = .zero - } - - func resetPlacement() { - side = 0.6 - spin = 0 - offset = .zero - liveScale = 1 - liveSpin = 0 - liveOffset = .zero - } - - /// The signed turn a constrained `RotateGesture3D` describes. Held to - /// the vertical axis, the rotation's own axis comes back as ±Y and its - /// sign is the direction — the angle alone is unsigned. - static func verticalAngle(of rotation: Rotation3D) -> Double { - rotation.angle.radians * (rotation.axis.y < 0 ? -1 : 1) - } - - /// The runner whose canvas goes on the table. Set by `CanvasPane` when - /// the space opens from the app. - var runner: RunnerModel? - - /// Anchor to a detected horizontal surface, or hang at a fixed spot. - /// The fixed spot is not only a fallback — it is the only mode the - /// simulator can show, since ARKit finds no planes there, which is why - /// the launch-argument route starts with it off. - var anchorsToTable = !TableSpikeModel.autoOpens - - var isOpen = false - - /// `-TBSpike YES` puts the table up at launch on a built-in sample. - /// - /// Without it the spike is unreachable from the simulator: a visionOS - /// `DocumentGroup` ignores `simctl openurl` (the trick that opens a - /// document on iPadOS), simctl cannot send taps, and the custom - /// `DocumentGroupLaunchScene` never appears — visionOS goes straight to - /// the system browser, so there is no view of ours to hang a `.task` - /// on either. Presenting the space itself at launch is what is left. - static var autoOpens: Bool { - UserDefaults.standard.bool(forKey: "TBSpike") - } - - /// The runner to draw, falling back to a document-less one on a - /// built-in sample — which is the only kind the launch-argument route - /// can have. `SampleBlocks` is already public and already what the - /// app's own 「みほん」 uses, so the spike needs no bundled file. - func runnerOrSample() -> RunnerModel { - if let runner { return runner } - let made = RunnerModel() - made.run(SampleBlocks.spiral()) - runner = made - return made - } - } - - extension Double { - fileprivate func clamped(to range: ClosedRange) -> Double { - min(max(self, range.lowerBound), range.upperBound) - } - } - - /// The immersive space: one entity, lying flat, sized and spun by hand. - struct TableSpikeSpace: View { - @Environment(TableSpikeModel.self) private var model - - /// visionOS lays SwiftUI out in points and the world in metres. This - /// is the conversion, read from the scene rather than guessed at — it - /// is what makes "60cm" mean 60cm instead of an arbitrary scale - /// factor, and it is the number Phase 1 will need for real. - @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 - - private static let canvasName = "table-spike-canvas" - - var body: some View { - RealityView { content in - let runner = model.runnerOrSample() - let built = TableSpikeModel.builtSide - - let canvas = Entity() - canvas.name = Self.canvasName - canvas.components.set( - ViewAttachmentComponent( - rootView: TableCanvasSheet( - runner: runner, side: built * pointsPerMeter))) - // The gestures are targeted at the *entity*, so it needs a - // shape to be hit and a component saying it accepts input. - // The box is in the view's own plane (normal +Z) and scales - // with the entity, so it keeps matching the sheet. - canvas.components.set(InputTargetComponent()) - canvas.components.set( - CollisionComponent(shapes: [ - .generateBox(size: [Float(built), Float(built), 0.005]) - ])) - - if model.anchorsToTable { - content.add( - AnchorEntity( - .plane(.horizontal, classification: .table, minimumBounds: [0.2, 0.2]) - ).addingChild(canvas)) - } - else { - content.add(canvas) - } - } update: { content in - guard let canvas = Self.canvas(in: content) else { return } - canvas.position = Self.home(anchored: model.anchorsToTable) + model.visibleOffset - canvas.scale = .init(repeating: model.entityScale) - // Lie flat first, then spin about the parent's vertical axis — - // which is the plane's normal when anchored, and up either way. - canvas.orientation = - simd_quatf(angle: Float(model.visibleSpin), axis: [0, 1, 0]) - * simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) - } - // Only the placement mode rebuilds; size, spin and position are - // transforms on the entity that is already there. - .id(model.anchorsToTable) - // All three run together, the way a hand does them: a pinch that - // drifts and turns should move and spin, not pick one. The - // rotation's 5° threshold is what keeps an ordinary drag from - // spinning the sheet on the way. - .gesture( - DragGesture() - .targetedToAnyEntity() - .onChanged { - model.liveOffset = Self.translation( - of: $0, keepingOnPlane: model.anchorsToTable) - } - .onEnded { - model.commitOffset( - Self.translation(of: $0, keepingOnPlane: model.anchorsToTable)) - } - ) - .simultaneousGesture( - RotateGesture3D(constrainedToAxis: .y, minimumAngleDelta: .degrees(5)) - .targetedToAnyEntity() - .onChanged { - model.liveSpin = TableSpikeModel.verticalAngle(of: $0.gestureValue.rotation) - } - .onEnded { - model.commitSpin( - TableSpikeModel.verticalAngle(of: $0.gestureValue.rotation)) - } - ) - .simultaneousGesture( - MagnifyGesture() - .targetedToAnyEntity() - .onChanged { model.liveScale = $0.gestureValue.magnification } - .onEnded { model.commitScale($0.gestureValue.magnification) } - ) - } - - /// Where the sheet sits before the wearer moves it. - /// - /// **An immersive space's origin is on the floor**, under where the - /// wearer started — not at eye level. The first try put the fixed - /// placement at y = -0.4 for "desk height, below the eyes" and buried - /// it under the floor, which looks exactly like nothing rendering at - /// all. The anchored one is the plane's own origin, so it needs no - /// height of its own; the fixed one sits higher than a real table so - /// the simulator's fixed horizontal gaze can see it, since that mode - /// is for checking that the drawing renders rather than for judging - /// where it belongs. - private static func home(anchored: Bool) -> SIMD3 { - anchored ? .zero : [0, 1.0, -1.2] - } - - /// A drag's translation in the entity's parent space, which is where - /// `position` is read. Converting to `.scene` instead would be wrong - /// the moment the sheet hangs off a plane anchor. - /// - /// `keepingOnPlane` is what stops a drag lifting the sheet off the - /// table: on a plane anchor the parent's Y **is** the plane's normal, - /// so dropping that one component slides the drawing along the surface - /// instead of into the air. It is off in the fixed placement, where - /// there is no surface to stay on and height is the only way to put - /// the sheet somewhere sensible. - private static func translation( - of value: EntityTargetValue, keepingOnPlane: Bool - ) -> SIMD3 { - guard let parent = value.entity.parent else { return .zero } - var delta = value.convert(value.gestureValue.translation3D, from: .local, to: parent) - if keepingOnPlane { delta.y = 0 } - return delta - } - - private static func canvas(in content: RealityViewContent) -> Entity? { - for root in content.entities { - if let found = root.findEntity(named: canvasName) { return found } - } - return nil - } - } - - /// What actually goes on the table: the app's own canvas, unchanged. - private struct TableCanvasSheet: View { - let runner: RunnerModel - let side: CGFloat - - var body: some View { - TortoiseCanvas(runner.tortoise, player: runner.player) - .tortoiseSprite(CanvasPane.sprite) - // Paper, for the same reason the pane paints it: the default - // pen is black and a table is not white. - .background(.white) - .frame(width: side, height: side) - // The pinch and the twist belong to the entity. Left hit- - // testable, this view would swallow them first and the sheet - // could never be resized. - .allowsHitTesting(false) - } - } - - /// The spike's controls, parked under the playback row in `CanvasPane`. - struct TableSpikeBar: View { - let runner: RunnerModel - - @Environment(TableSpikeModel.self) private var model - @Environment(\.openImmersiveSpace) private var openSpace - @Environment(\.dismissImmersiveSpace) private var dismissSpace - - var body: some View { - @Bindable var model = model - HStack { - Button(model.isOpen ? "しまう" : "つくえに おく", systemImage: "table.furniture") { - Task { await toggle() } - } - .buttonStyle(.borderedProminent) - - Toggle(isOn: $model.anchorsToTable) { - Text(verbatim: "平面に置く") - } - .toggleStyle(.switch) - .fixedSize() - - Spacer() - - // The readout is the measurement: there is no right size, but - // where wearers actually settle is worth knowing before - // Phase 1 picks a default. - Text(verbatim: "\(Int((model.visibleSide * 100).rounded()))cm") - .monospacedDigit() - Text(verbatim: "\(Int(Angle(radians: model.visibleSpin).degrees.rounded()))°") - .monospacedDigit() - .foregroundStyle(.secondary) - Button("もとに もどす", systemImage: "arrow.counterclockwise") { - model.resetPlacement() - } - .labelStyle(.iconOnly) - - Text(verbatim: "spike #53") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal) - .padding(.bottom) - } - - private func toggle() async { - if model.isOpen { - await dismissSpace() - model.isOpen = false - } - else { - model.runner = runner - if case .opened = await openSpace(id: TableSpikeModel.spaceID) { - model.isOpen = true - } - } - } - } - - /// The window `-TBSpike YES` launches into, whose only job is to open the - /// space. `defaultLaunchBehavior(.presented)` on the `ImmersiveSpace` - /// itself does nothing here — the log shows the scene declared with - /// `immersiveStyle = Mixed` and then `requesting immersive or volume NO` — - /// because the `DocumentGroup` takes the launch. A window can take it - /// instead, and `openImmersiveSpace` from inside one does work. - struct TableSpikeLauncher: View { - @Environment(TableSpikeModel.self) private var model - @Environment(\.openImmersiveSpace) private var openSpace - - /// Only here to make `builtSide` legible as what it is: the render's - /// size in points. Printed rather than reasoned about, because the - /// points-per-metre figure is the scene's to decide. - @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 - - var body: some View { - VStack(spacing: 12) { - Text(verbatim: "spike #53") - .font(.largeTitle) - Text(verbatim: "つくえの うえに みほんを おいています…") - .foregroundStyle(.secondary) - Text(verbatim: "\(Int((model.visibleSide * 100).rounded()))cm") - .monospacedDigit() - .foregroundStyle(.secondary) - Text( - verbatim: - "1m = \(Int(pointsPerMeter))pt / " - + "render \(Int(TableSpikeModel.builtSide * pointsPerMeter))pt²" - ) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) - } - .padding(40) - .task { - guard !model.isOpen else { return } - if case .opened = await openSpace(id: TableSpikeModel.spaceID) { - model.isOpen = true - } - } - } - } - - extension Entity { - /// `content.add(AnchorEntity(…).addingChild(canvas))` reads better than - /// the three statements it replaces, and this file has no other use for - /// a local variable holding the anchor. - fileprivate func addingChild(_ child: Entity) -> Entity { - addChild(child) - return self - } - } - -#endif diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift new file mode 100644 index 0000000..64e4504 --- /dev/null +++ b/App/Views/ViewerWindow.swift @@ -0,0 +1,212 @@ +#if os(visionOS) + + import SwiftUI + import TortoiseBlocksKit + + /// The visionOS viewer's one window (#53) — **controls only**. + /// + /// The drawing is never here. It lives on the table, in the immersive + /// space, and that is the whole reason this platform gets an app of its + /// own: a second flat copy in the window would put the same picture in two + /// places and take the reason away with it. So this window is what a + /// remote control is: pick a drawing, put it down, play it. + /// + /// There is no editing anywhere in it. That is not a feature that was cut + /// — text entry and precise dragging are worse in a headset than on an + /// iPad in every respect, and being read-only is what lets the whole + /// `DocumentGroup` apparatus go, taking with it the two visionOS problems + /// that #52 could not solve (no way back to the browser, and the + /// view-switching residue). + struct ViewerWindow: View { + let model: ViewerModel + + @Environment(\.openImmersiveSpace) private var openSpace + @Environment(\.dismissImmersiveSpace) private var dismissSpace + + @State private var showsImporter = false + + var body: some View { + @Bindable var model = model + VStack(spacing: 24) { + DrawingChooser(model: model, showsImporter: $showsImporter) + + if model.hasProgram { + PlaybackControls( + blocks: model.blocks, runner: model.runner, + isStale: model.runner.isStale(comparedTo: model.blocks) + ) + TablePlacementControls(model: model, place: place) + } + else { + ContentUnavailableView( + "No Drawing", systemImage: "photo.on.rectangle.angled", + description: Text("Open a drawing, or start from a sample.")) + } + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .fileImporter( + isPresented: $showsImporter, allowedContentTypes: [.tortoiseBlocksProject] + ) { result in + if case .success(let url) = result { model.open(url) } + } + .alert( + "Can't Open This", + isPresented: Binding( + get: { model.openFailure != nil }, + set: { if !$0 { model.openFailure = nil } }) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(model.openFailure ?? "") + } + .task { + // Development only: the simulator cannot press any of these + // buttons (simctl sends no input), so `-TBPlace YES` loads a + // sample and puts it down at launch. It is the only way to see + // the immersive space without a headset on. + guard UserDefaults.standard.bool(forKey: "TBPlace"), !model.hasProgram else { + return + } + model.load(SampleBlocks.spiral(), title: String(localized: "Spiral")) + model.sitsOnTable = false + await place() + } + } + + private func place() async { + if model.isPlaced { + await dismissSpace() + model.isPlaced = false + } + else if case .opened = await openSpace(id: ViewerModel.spaceID) { + model.isPlaced = true + } + } + } + + /// Which drawing is on the table: a file, or one of the four samples the + /// iPad and Mac workspace already offers. + /// + /// The samples are not a convenience — they are what stops a viewer with + /// no files from being an empty app. Documents live in the app's own + /// folder on whichever device made them, so a Vision Pro starts with none + /// until something is AirDropped to it. `SampleBlocks` is already public + /// and already what 「みほん」 uses, so this needs no bundled resources and + /// no second set of names. + private struct DrawingChooser: View { + let model: ViewerModel + @Binding var showsImporter: Bool + + var body: some View { + VStack(spacing: 12) { + // Verbatim on both sides: a file's name is the user's own text, + // and the app name mirrors CFBundleDisplayName, which is + // "Tortoise Blocks" in every language — localizing either would + // put something in the string catalog that must never be + // translated (the same reason `LaunchScene` says it verbatim). + Text(verbatim: model.title.isEmpty ? "Tortoise Blocks" : model.title) + .font(.title) + .lineLimit(1) + + HStack(spacing: 12) { + Button("Open", systemImage: "folder") { + showsImporter = true + } + Menu { + // "Sample" is said once, over the list, rather than at + // the head of all four names — the same reasoning as + // the workspace's own menu. + SampleItem("🟦", "Filled Square", SampleBlocks.filledSquare, model) + SampleItem("⭐️", "Star", SampleBlocks.star, model) + SampleItem("🌀", "Spiral", SampleBlocks.spiral, model) + SampleItem("🌳", "Tree", SampleBlocks.fractalTree, model) + } label: { + Label("Samples", systemImage: "sparkles") + } + } + .buttonStyle(.bordered) + } + } + } + + /// One sample entry. Takes the block-builder rather than the blocks so the + /// program is only built when it is chosen — four sample trees built on + /// every redraw of the menu's label would be four for nothing. + private struct SampleItem: View { + let icon: String + /// A `LocalizedStringResource` rather than a `LocalizedStringKey` + /// because this name is needed twice — as the menu entry, and as the + /// title the window then shows — and only a resource can be both drawn + /// and resolved to a `String`. Same reason the palette's titles are + /// resources: a plain `String` there would skip localization outright. + let title: LocalizedStringResource + let build: () -> [Block] + let model: ViewerModel + + init( + _ icon: String, _ title: LocalizedStringResource, _ build: @escaping () -> [Block], + _ model: ViewerModel + ) { + self.icon = icon + self.title = title + self.build = build + self.model = model + } + + var body: some View { + Button { + model.load(build(), title: String(localized: title)) + } label: { + // The emoji is `verbatim` and separate from the title, so the + // string catalog stays free of it. + Label { + Text(title) + } icon: { + Text(verbatim: icon) + } + } + } + } + + /// Putting the sheet down, and the two things about where it lands that + /// are worth a control rather than a gesture. + private struct TablePlacementControls: View { + let model: ViewerModel + let place: () async -> Void + + var body: some View { + @Bindable var model = model + VStack(spacing: 14) { + Button( + model.isPlaced ? "Put Away" : "Place on Table", + systemImage: model.isPlaced ? "xmark.circle" : "table.furniture" + ) { + Task { await place() } + } + .buttonStyle(.borderedProminent) + + // Size, spin and position are gestures on the sheet itself — + // there is nothing here for them. What is left is the choice a + // gesture cannot make (whether to look for a table at all) and + // the way back from having dragged the drawing out of reach. + HStack(spacing: 16) { + Toggle("Sit on a Table", isOn: $model.sitsOnTable) + .toggleStyle(.switch) + .fixedSize() + Button("Reset Position", systemImage: "arrow.counterclockwise") { + model.resetPlacement() + } + .labelStyle(.iconOnly) + .disabled(!model.isPlaced) + } + .font(.callout) + + Text("Pinch to resize, twist to turn, drag to move.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + +#endif From 7fcf4a45330ef6ff644104692cb38053447f4d5d Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sat, 15 Aug 2026 18:45:08 +0900 Subject: [PATCH 16/33] Let the viewer window be the size of its controls (#53 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Localizable.xcstrings | 65 ++++++++++++++++-------------------- App/TortoiseBlocksApp.swift | 6 +++- App/Views/ViewerWindow.swift | 10 +++++- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index ff762c4..cc8f3f8 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -704,22 +704,22 @@ } } }, - "Open Another Drawing" : { + "Open a drawing, or start from a sample." : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ほかの えを ひらく" + "value" : "えを ひらくか、サンプルから はじめてね。" } } } }, - "Open a drawing, or start from a sample." : { + "Open Another Drawing" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "えを ひらくか、サンプルから はじめてね。" + "value" : "ほかの えを ひらく" } } } @@ -834,52 +834,52 @@ } } }, - "Play" : { + "Pinch to resize, twist to turn, drag to move." : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "さいせい" + "value" : "つまむと おおきさ、ひねると むき、うごかすと ばしょが かわるよ。" } } } }, - "Play Again" : { + "Place on Table" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "もういちど さいせい" + "value" : "つくえに おく" } } } }, - "PNG" : { + "Play" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "PNG" + "value" : "さいせい" } } } }, - "Pinch to resize, twist to turn, drag to move." : { + "Play Again" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "つまむと おおきさ、ひねると むき、うごかすと ばしょが かわるよ。" + "value" : "もういちど さいせい" } } } }, - "Place on Table" : { + "PNG" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "つくえに おく" + "value" : "PNG" } } } @@ -894,22 +894,22 @@ } } }, - "Put Away" : { + "purple" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "しまう" + "value" : "むらさき" } } } }, - "purple" : { + "Put Away" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "むらさき" + "value" : "しまう" } } } @@ -1054,6 +1054,16 @@ } } }, + "Sit on a Table" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つくえに のせる" + } + } + } + }, "Speed" : { "localizations" : { "ja" : { @@ -1144,16 +1154,6 @@ } } }, - "Sit on a Table" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "つくえに のせる" - } - } - } - }, "Tap a palette block, or drag one here" : { "localizations" : { "ja" : { @@ -1443,15 +1443,6 @@ } } } - }, - "しまう" : { - - }, - "つくえに おく" : { - - }, - "もとに もどす" : { - } }, "version" : "1.0" diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index 8975ea4..f3e4e8c 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -24,7 +24,11 @@ struct TortoiseBlocksApp: App { WindowGroup { ViewerWindow(model: viewer) } - .defaultSize(width: 560, height: 520) + // The window is its contents, not a canvas they sit in — so no + // `defaultSize`, which left a band of empty glass under the + // controls on device. It shrinks to the "no drawing" state and + // grows when a drawing brings the transport with it. + .windowResizability(.contentSize) ImmersiveSpace(id: ViewerModel.spaceID) { TableCanvasSpace(model: viewer) diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 64e4504..2c3155c 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -44,7 +44,15 @@ } } .padding(28) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + // A fixed width and *no* height at all. The window sizes itself to + // this content (`windowResizability(.contentSize)`), so leaving the + // height to the content is what stops the empty band under the + // controls — a remote control should be exactly as tall as its + // buttons, and it grows by itself when a drawing brings the + // transport with it. The width is held because the scrubber has no + // opinion of its own and would otherwise collapse toward the + // widest label. + .frame(width: 520) .fileImporter( isPresented: $showsImporter, allowedContentTypes: [.tortoiseBlocksProject] ) { result in From b856bf0e822e60d2d78a20e4bc2ce85d0a6c1f41 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sat, 15 Aug 2026 18:55:26 +0900 Subject: [PATCH 17/33] Show the program on its own surface, following the playback (#53 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Localizable.xcstrings | 10 +++++ App/TortoiseBlocksApp.swift | 9 ++++ App/Views/ProgramWindow.swift | 80 +++++++++++++++++++++++++++++++++++ App/Views/TableCanvas.swift | 1 + App/Views/ViewerWindow.swift | 26 +++++++++--- App/Views/WorkspaceView.swift | 39 +++++++++++++++++ 6 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 App/Views/ProgramWindow.swift diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index cc8f3f8..2299c50 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -1154,6 +1154,16 @@ } } }, + "Show Blocks" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブロックを みる" + } + } + } + }, "Tap a palette block, or drag one here" : { "localizations" : { "ja" : { diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index f3e4e8c..8ac4413 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -30,6 +30,15 @@ struct TortoiseBlocksApp: App { // grows when a drawing brings the transport with it. .windowResizability(.contentSize) + // The program, on a surface of its own (#53). A separate window + // rather than a pane, because that is the version of this idea the + // iPad cannot give: the blocks and the drawing at full size at the + // same time, with nothing to switch between. + WindowGroup(id: ViewerModel.programWindowID) { + ProgramWindow(model: viewer) + } + .defaultSize(width: 480, height: 700) + ImmersiveSpace(id: ViewerModel.spaceID) { TableCanvasSpace(model: viewer) } diff --git a/App/Views/ProgramWindow.swift b/App/Views/ProgramWindow.swift new file mode 100644 index 0000000..efa86a6 --- /dev/null +++ b/App/Views/ProgramWindow.swift @@ -0,0 +1,80 @@ +#if os(visionOS) + + import SwiftUI + import TortoiseBlocksKit + + /// The program, read-only, following the playback on the table (#53). + /// + /// This is the window that makes the visionOS build *this app* rather than + /// a way to watch a drawing appear. Seeing which block is running while the + /// tortoise moves is the whole pedagogy, and on a headset it gets the + /// version the iPad cannot give it: the program on one surface and the + /// drawing on your actual table, both full size, with nothing to switch + /// between. + /// + /// **The rows are the editor's own rows.** Not a read-only lookalike — the + /// same `BlockListView`, and so the same category colours, the same C-shaped + /// containers, the same row heights and label alignment. A second renderer + /// would have to be kept in step by hand, and the failure mode is silent: + /// `SimpleBlockLabel` is on the "when adding a block kind" checklist + /// precisely because a kind that is missed there renders as nothing. + struct ProgramWindow: View { + let model: ViewerModel + + var body: some View { + ScrollView { + BlockListView( + blocks: model.blocks, + address: .topLevel, + workspace: Self.readOnly(model.blocks), + // The highlight this window exists for. It costs nothing + // extra: `RunnerModel.currentBlockID` is + // `expandedBlockIDs[player.currentCommandIndex]`, and the + // table draws through the very same `CommandPlayer`, so the + // index alignment the whole app rests on is untouched by + // the drawing having moved into an immersive space. + highlightedID: model.runner.currentBlockID, + usedVariableNames: BlockTree.usedVariableNames(in: model.blocks), + usedFunctionNames: BlockTree.usedFunctionNames(in: model.blocks) + ) + .padding() + } + // Read-only twice over, and the belt matters more than the braces. + // + // The *binding* is what enforces it: `WorkspaceEditor` writes + // through `document`, and a constant binding throws those writes + // away — so even a path that tried to edit could not, and no list + // of hidden affordances has to stay complete for that to hold. + // Hit-testing is off as well, which is what actually removes the + // drag sources, the drop gaps, the row menus, the value chips and + // the "add here" toggles from a window where none of them mean + // anything. + .allowsHitTesting(false) + // And the editing controls are not drawn at all. Inert-but-visible + // was tried first and reads worse than either alternative: the ⋯ + // menu and the mouths' "add here" toggle look pressable, do + // nothing, and are the two things #53 says a read-only program must + // not offer. + .environment(\.showsBlockEditing, false) + .overlay { + if !model.hasProgram { + ContentUnavailableView( + "No Drawing", systemImage: "square.stack.3d.up", + description: Text("Open a drawing, or start from a sample.")) + } + } + } + + /// An editor that cannot edit: no undo manager to register with, fresh + /// UI state nothing reads, and a constant document binding that + /// discards every write. + private static func readOnly(_ blocks: [Block]) -> WorkspaceEditor { + WorkspaceEditor( + document: .constant( + BlocksDocument(project: BlocksProject(title: "", blocks: blocks))), + undoManager: nil, + uiState: WorkspaceUIState()) + } + } + +#endif diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift index 2f0b3b7..d3502dd 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/TableCanvas.swift @@ -28,6 +28,7 @@ @MainActor final class ViewerModel { static let spaceID = "table" + static let programWindowID = "program" // MARK: What is loaded diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 2c3155c..2af9b8f 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -22,6 +22,7 @@ @Environment(\.openImmersiveSpace) private var openSpace @Environment(\.dismissImmersiveSpace) private var dismissSpace + @Environment(\.openWindow) private var openWindow @State private var showsImporter = false @@ -78,6 +79,7 @@ } model.load(SampleBlocks.spiral(), title: String(localized: "Spiral")) model.sitsOnTable = false + openWindow(id: ViewerModel.programWindowID) await place() } } @@ -183,16 +185,28 @@ let model: ViewerModel let place: () async -> Void + @Environment(\.openWindow) private var openWindow + var body: some View { @Bindable var model = model VStack(spacing: 14) { - Button( - model.isPlaced ? "Put Away" : "Place on Table", - systemImage: model.isPlaced ? "xmark.circle" : "table.furniture" - ) { - Task { await place() } + // The two places a drawing can be shown, side by side: on the + // table, and as the program that draws it. Neither replaces the + // other — having both open at once is the point (#53). + HStack(spacing: 12) { + Button( + model.isPlaced ? "Put Away" : "Place on Table", + systemImage: model.isPlaced ? "xmark.circle" : "table.furniture" + ) { + Task { await place() } + } + .buttonStyle(.borderedProminent) + + Button("Show Blocks", systemImage: "square.stack.3d.up") { + openWindow(id: ViewerModel.programWindowID) + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) // Size, spin and position are gestures on the sheet itself — // there is nothing here for them. What is left is the choice a diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index 217625f..4a7f6e8 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -787,9 +787,19 @@ struct InsertionTargetButton: View { let address: BodyAddress let workspace: WorkspaceEditor + @Environment(\.showsBlockEditing) private var showsBlockEditing + private var isTarget: Bool { workspace.insertionTarget == address } var body: some View { + // Gone entirely when the program is only being read (#53). This is the + // palette's aim, and a viewer has no palette to aim. + if showsBlockEditing { + targetToggle + } + } + + private var targetToggle: some View { Toggle( "Add Here", systemImage: isTarget ? "arrow.down.to.line.circle.fill" : "arrow.down.to.line.circle", @@ -830,7 +840,19 @@ struct RowControls: View { /// The if block's "add an otherwise mouth", when this row has one. var addElseAction: (() -> Void)? = nil + @Environment(\.showsBlockEditing) private var showsBlockEditing + var body: some View { + // Absent, not disabled, where the program is only being read (#53): + // the visionOS viewer's constant document binding already makes this + // menu incapable of changing anything, and a control that looks + // pressable and does nothing is worse than no control. + if showsBlockEditing { + editingMenu + } + } + + private var editingMenu: some View { Menu { Button("Move Up", systemImage: "chevron.up") { workspace.move(blockID, by: -1) @@ -1057,3 +1079,20 @@ extension View { isDropTargeted: isDropTargeted)) } } + +extension EnvironmentValues { + /// Whether block rows draw the controls that *change* the program — the + /// row menu (⋯) and the container mouths' "add here" target toggle. + /// + /// True everywhere the workspace is an editor, and false in the visionOS + /// viewer's program window (#53), which shows the same rows for reading + /// only. It is an environment value rather than a parameter because the + /// two views that read it sit several layers below the one that knows — + /// threading a flag through `BlockListView`, `BlockRowView` and + /// `ContainerBlockRow` would put an argument on every one of them for the + /// benefit of two leaves. + /// + /// Hiding is not what enforces read-only — a constant document binding is + /// (see `ProgramWindow`). This is about not *offering* what cannot happen. + @Entry var showsBlockEditing = true +} From 7a2f77d3af31dcad77bf55ee40a82e4b3f31612a Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sat, 15 Aug 2026 22:19:17 +0900 Subject: [PATCH 18/33] Follow the playhead, and stop the row menu tinting its own popup (#53 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/ProgramWindow.swift | 52 +++++++++++++++++++++++++---------- App/Views/WorkspaceView.swift | 9 ++++-- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/App/Views/ProgramWindow.swift b/App/Views/ProgramWindow.swift index efa86a6..1caf7dc 100644 --- a/App/Views/ProgramWindow.swift +++ b/App/Views/ProgramWindow.swift @@ -23,21 +23,25 @@ var body: some View { ScrollView { - BlockListView( - blocks: model.blocks, - address: .topLevel, - workspace: Self.readOnly(model.blocks), - // The highlight this window exists for. It costs nothing - // extra: `RunnerModel.currentBlockID` is - // `expandedBlockIDs[player.currentCommandIndex]`, and the - // table draws through the very same `CommandPlayer`, so the - // index alignment the whole app rests on is untouched by - // the drawing having moved into an immersive space. - highlightedID: model.runner.currentBlockID, - usedVariableNames: BlockTree.usedVariableNames(in: model.blocks), - usedFunctionNames: BlockTree.usedFunctionNames(in: model.blocks) - ) - .padding() + // The list has to follow the playhead, or the feature only + // works for programs short enough to fit: on device the + // highlight was correct and *off screen* for everything past + // the first few rows. + // + // `scrollTo` with **no anchor** is the whole trick. Centring + // the running block would drag the list on nearly every + // command — a loop body cycling three rows would never sit + // still — while the default anchor scrolls the least amount + // that brings the row into view, and does nothing at all while + // it is already there. Unanimated for the same reason: at ten + // commands a second an animation only ever restarts itself. + ScrollViewReader { proxy in + programList + .onChange(of: model.runner.currentBlockID) { _, id in + guard let id else { return } + proxy.scrollTo(id) + } + } } // Read-only twice over, and the belt matters more than the braces. // @@ -65,6 +69,24 @@ } } + private var programList: some View { + BlockListView( + blocks: model.blocks, + address: .topLevel, + workspace: Self.readOnly(model.blocks), + // The highlight this window exists for. It costs nothing + // extra: `RunnerModel.currentBlockID` is + // `expandedBlockIDs[player.currentCommandIndex]`, and the + // table draws through the very same `CommandPlayer`, so the + // index alignment the whole app rests on is untouched by + // the drawing having moved into an immersive space. + highlightedID: model.runner.currentBlockID, + usedVariableNames: BlockTree.usedVariableNames(in: model.blocks), + usedFunctionNames: BlockTree.usedFunctionNames(in: model.blocks) + ) + .padding() + } + /// An editor that cannot edit: no undo manager to register with, fresh /// UI state nothing reads, and a constant document binding that /// discards every write. diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index 4a7f6e8..afaab3a 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -637,12 +637,16 @@ struct ElseDividerRow: View { // Inside the label, for the reason `RowControls` gives. Label("More", systemImage: "ellipsis") .labelStyle(.iconOnly) + // On the glyph, not on the `Menu`. A tint set on the menu + // reaches its *popup* too, and this ink is a fixed near- + // black (#41) — which on macOS's dark menu background is a + // row of black icons on black. + .foregroundStyle(BlockCategory.ink) .touchTarget() } .menuIndicator(.hidden) .buttonStyle(.borderless) .controlSize(.large) - .tint(BlockCategory.ink) } .blockChrome( BlockCategory.control.color, corners: .containerDivider, @@ -880,12 +884,13 @@ struct RowControls: View { // thin band are not, which is exactly how it came out on iPad. Label("More", systemImage: "ellipsis") .labelStyle(.iconOnly) + // On the glyph, not on the `Menu` — see `ElseDividerRow`. + .foregroundStyle(BlockCategory.ink) .touchTarget() } .menuIndicator(.hidden) .buttonStyle(.borderless) .controlSize(.large) - .tint(BlockCategory.ink) } } From c7c2f758a6e5fe821bd612ad2b1b768f90cb82d6 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sat, 15 Aug 2026 22:29:10 +0900 Subject: [PATCH 19/33] =?UTF-8?q?Let=20the=20program=20window=20scroll,=20?= =?UTF-8?q?and=20keep=20the=20=E2=8B=AF=20ink=20off=20its=20own=20popup=20?= =?UTF-8?q?(#53=20Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/ProgramWindow.swift | 34 +++++++++--------- App/Views/WorkspaceView.swift | 66 +++++++++++++++++++++++++---------- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/App/Views/ProgramWindow.swift b/App/Views/ProgramWindow.swift index 1caf7dc..9fc2b64 100644 --- a/App/Views/ProgramWindow.swift +++ b/App/Views/ProgramWindow.swift @@ -43,22 +43,10 @@ } } } - // Read-only twice over, and the belt matters more than the braces. - // - // The *binding* is what enforces it: `WorkspaceEditor` writes - // through `document`, and a constant binding throws those writes - // away — so even a path that tried to edit could not, and no list - // of hidden affordances has to stay complete for that to hold. - // Hit-testing is off as well, which is what actually removes the - // drag sources, the drop gaps, the row menus, the value chips and - // the "add here" toggles from a window where none of them mean - // anything. - .allowsHitTesting(false) - // And the editing controls are not drawn at all. Inert-but-visible - // was tried first and reads worse than either alternative: the ⋯ - // menu and the mouths' "add here" toggle look pressable, do - // nothing, and are the two things #53 says a read-only program must - // not offer. + // The editing controls are not drawn at all. Inert-but-visible was + // tried first and reads worse than either alternative: the ⋯ menu + // and the mouths' "add here" toggle look pressable, do nothing, and + // are the two things #53 says a read-only program must not offer. .environment(\.showsBlockEditing, false) .overlay { if !model.hasProgram { @@ -85,6 +73,20 @@ usedFunctionNames: BlockTree.usedFunctionNames(in: model.blocks) ) .padding() + // Read-only twice over, and the belt matters more than the braces. + // + // The *binding* is what enforces it: `WorkspaceEditor` writes + // through `document`, and a constant binding throws those writes + // away — so even a path that tried to edit could not, and no list + // of hidden affordances has to stay complete for that to hold. + // Hit-testing off then removes the drag sources, the drop gaps and + // the value chips from a window where none of them mean anything. + // + // On the **content**, not on the `ScrollView`. Around the scroll + // view it takes the scrolling with it, and the window becomes one + // that can only ever show the rows it happens to open on — which is + // most of the program for anything but a short one. + .allowsHitTesting(false) } /// An editor that cannot edit: no undo manager to register with, fresh diff --git a/App/Views/WorkspaceView.swift b/App/Views/WorkspaceView.swift index afaab3a..a5ad2cc 100644 --- a/App/Views/WorkspaceView.swift +++ b/App/Views/WorkspaceView.swift @@ -632,21 +632,20 @@ struct ElseDividerRow: View { // either by the ⋯ or by long-press. The mouth is removed from // inside it, so "けす" means the same thing wherever it is found. Menu { + // See `RowControls.editingMenu`: the tint below belongs to the + // glyph, and a menu hands its tint to the popup as well. removeButton + .tint(nil) } label: { // Inside the label, for the reason `RowControls` gives. Label("More", systemImage: "ellipsis") .labelStyle(.iconOnly) - // On the glyph, not on the `Menu`. A tint set on the menu - // reaches its *popup* too, and this ink is a fixed near- - // black (#41) — which on macOS's dark menu background is a - // row of black icons on black. - .foregroundStyle(BlockCategory.ink) .touchTarget() } .menuIndicator(.hidden) .buttonStyle(.borderless) .controlSize(.large) + .blockMenuInk() } .blockChrome( BlockCategory.control.color, corners: .containerDivider, @@ -858,24 +857,35 @@ struct RowControls: View { private var editingMenu: some View { Menu { - Button("Move Up", systemImage: "chevron.up") { - workspace.move(blockID, by: -1) - } - Button("Move Down", systemImage: "chevron.down") { - workspace.move(blockID, by: 1) - } - if let addElseAction { - Button("Add Otherwise", systemImage: "arrow.triangle.branch", action: addElseAction) + // The popup keeps the system's own colours. `blockMenuInk()` below + // is a *tint*, and a tint reaches a menu's items as well as its + // glyph — which put a fixed near-black (#41) on macOS's dark menu + // background. Resetting it here is what lets the glyph keep the + // ink it needs against a pastel block. + Group { + Button("Move Up", systemImage: "chevron.up") { + workspace.move(blockID, by: -1) + } + Button("Move Down", systemImage: "chevron.down") { + workspace.move(blockID, by: 1) + } + if let addElseAction { + Button( + "Add Otherwise", systemImage: "arrow.triangle.branch", + action: addElseAction + ) // The hint the button on the header used to carry: what an // else mouth *is* still needs saying, and a menu entry is // where it is now read. .accessibilityHint( Text("Adds an otherwise mouth that runs when the condition fails")) + } + Divider() + Button("Delete", systemImage: "xmark.circle", role: .destructive) { + workspace.delete(blockID) + } } - Divider() - Button("Delete", systemImage: "xmark.circle", role: .destructive) { - workspace.delete(blockID) - } + .tint(nil) } label: { // The finger target belongs *inside* the label: a `Menu` hit-tests // what it was handed to draw, so a `frame` wrapped around the menu @@ -884,13 +894,12 @@ struct RowControls: View { // thin band are not, which is exactly how it came out on iPad. Label("More", systemImage: "ellipsis") .labelStyle(.iconOnly) - // On the glyph, not on the `Menu` — see `ElseDividerRow`. - .foregroundStyle(BlockCategory.ink) .touchTarget() } .menuIndicator(.hidden) .buttonStyle(.borderless) .controlSize(.large) + .blockMenuInk() } } @@ -1101,3 +1110,22 @@ extension EnvironmentValues { /// (see `ProgramWindow`). This is about not *offering* what cannot happen. @Entry var showsBlockEditing = true } + +extension View { + /// The ⋯ glyph's colour, on the two menus a block row can carry. + /// + /// Every row this appears on is a filled pastel block, so the glyph takes + /// the same fixed near-black the label does (#41) rather than the system + /// accent — white on a pastel is the control nobody can see. + /// + /// It is a `tint` because that is what a `borderless` menu reads for its + /// label; `foregroundStyle` on the label is ignored there, which is how the + /// first attempt at this ended up with a white ⋯ in dark mode. The cost is + /// that the tint reaches the *popup* too, so each menu's content resets it + /// with `.tint(nil)` — a fixed dark ink is right against a pastel block and + /// wrong against macOS's dark menu background, which is the whole of the + /// bug this pair exists to hold apart. + fileprivate func blockMenuInk() -> some View { + tint(BlockCategory.ink) + } +} From 0ed4d91dcc3ba2855259aecde55279627094b84c Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sat, 15 Aug 2026 22:34:29 +0900 Subject: [PATCH 20/33] Drop the way back to a browser that is no longer there (#53 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- App/Localizable.xcstrings | 10 ------- App/Views/PaletteView.swift | 55 ------------------------------------- 2 files changed, 65 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 2299c50..7cbb9f2 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -714,16 +714,6 @@ } } }, - "Open Another Drawing" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ほかの えを ひらく" - } - } - } - }, "or less" : { "localizations" : { "ja" : { diff --git a/App/Views/PaletteView.swift b/App/Views/PaletteView.swift index f0f4128..446b359 100644 --- a/App/Views/PaletteView.swift +++ b/App/Views/PaletteView.swift @@ -189,64 +189,9 @@ struct PaletteView: View { } .padding() } - .documentBrowserToolbar() } } -extension View { - /// The way back to the document browser, beside the document's own title - /// (#11). visionOS only, so the `#if` hides in a modifier rather than - /// sitting at the call site. - /// - /// Every other platform already has a way and would end up with two: - /// iPadOS puts a chevron next to the title, macOS has File ▸ Open and one - /// window per document. **visionOS has neither.** Its window carries the - /// document it was opened with, nothing offers another, and the only route - /// to a second drawing was closing the window and launching the app again. - func documentBrowserToolbar() -> some View { - #if os(visionOS) - modifier(DocumentBrowserToolbar()) - #else - self - #endif - } -} - -#if os(visionOS) - - /// Closes this document, which leaves the browser it was opened from. - /// - /// `dismiss` is what a `DocumentGroup`'s document closes itself with, and - /// **where it 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 - /// simply inert: it highlights, and nothing happens. It has to come from - /// the environment of the *content* the toolbar is attached to, which is - /// what makes this a `ViewModifier` rather than a view inside the - /// `toolbar` block. The failure is silent in the worst way — the code - /// compiles, the button draws, and only pressing it tells you. - /// - /// Deliberately not paired with a "new drawing" button: the browser's own ⊕ - /// is right there once you are back, and reaching the browser at all is the - /// part that was missing. - private struct DocumentBrowserToolbar: ViewModifier { - @Environment(\.dismiss) private var dismiss - - func body(content: Content) -> some View { - content.toolbar { - ToolbarItem(placement: .navigation) { - Button("Open Another Drawing", systemImage: "folder") { - dismiss() - } - .labelStyle(.iconOnly) - .touchTarget() - } - } - } - } - -#endif - struct PaletteSectionView: View { let section: PaletteSection let workspace: WorkspaceEditor From a2cfbe81f987d09792893b47b066ff4335b53594 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sat, 15 Aug 2026 22:53:48 +0900 Subject: [PATCH 21/33] Put the drawing where you are looking, and say so while it looks (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Localizable.xcstrings | 20 +++ App/Views/TableCanvas.swift | 269 +++++++++++++++++++++++++++++------ App/Views/ViewerWindow.swift | 28 +++- 3 files changed, 270 insertions(+), 47 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 7cbb9f2..ce247a0 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -504,6 +504,16 @@ } } }, + "Looking for a table…" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つくえを さがしています…" + } + } + } + }, "item %lld" : { "localizations" : { "ja" : { @@ -654,6 +664,16 @@ } } }, + "No table found, so it is floating in front of you." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つくえが みつからないので、めのまえに だしているよ。" + } + } + } + }, "Nothing to play" : { "localizations" : { "ja" : { diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift index d3502dd..14f63a5 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/TableCanvas.swift @@ -1,5 +1,7 @@ #if os(visionOS) + import ARKit + import QuartzCore import RealityKit import Spatial import SwiftUI @@ -106,9 +108,9 @@ /// your wrist, and only the vertical axis counts". var spin: Double = 0 - /// Committed translation, in the entity's **parent** space — the plane - /// anchor when there is one, so a drag stays right without the app ever - /// reading that anchor's transform (which visionOS does not hand out). + /// Committed translation, in world space — which is also the sheet's + /// parent space, now that it hangs off the scene root rather than a + /// plane anchor. var offset: SIMD3 = .zero /// In-flight gesture values, applying on top of the committed ones @@ -126,6 +128,82 @@ var isPlaced = false + // MARK: Finding somewhere to put it + + /// How the sheet came to be where it is. + enum Placement { + /// Looking for a table. **The sheet is not drawn yet**, and this + /// can take ten seconds or more, so it is a state the window has + /// to say out loud rather than a gap it leaves unexplained. + case searching + /// Standing on a surface ARKit found. + case onTable + /// In mid-air in front of the wearer, because no surface turned up + /// (or none was asked for). + case floating + } + + var placement: Placement = .searching + + /// Where the sheet was put, in world space, before the wearer moved it. + private(set) var home: SIMD3 = [0, 1.0, -1.2] + + /// The turn that puts the sheet's top edge away from the wearer. + private(set) var homeYaw: Float = 0 + + /// How far ahead of the eyes the sheet lands. Desk reach: near enough + /// to be *the thing you are looking at*, far enough not to sit in your + /// lap. Everything past this is the wearer's to drag. + private static let reach: Float = 0.6 + + /// Drop below eye level for the floating fallback — roughly where a + /// desk would be if there were one. + private static let floatingDrop: Float = 0.35 + + func placeOnTable(atHeight y: Float, device: simd_float4x4) { + aim(from: device, height: y) + placement = .onTable + } + + /// The fallback. `device` is nil only when world tracking has not + /// produced a pose yet, and then there is nothing to aim by — the sheet + /// takes a fixed spot ahead of the origin instead. + func floatInFront(device: simd_float4x4?) { + if let device { + aim(from: device, height: device.columns.3.y - Self.floatingDrop) + } + else { + home = [0, 1.0, -1.2] + homeYaw = 0 + } + placement = .floating + } + + /// Puts the sheet in front of the wearer, facing the way they face. + /// + /// Lying down, the sheet's top edge points along **−Z** (that is what + /// the −90° turn about X does to the page's up direction). So the yaw + /// that carries −Z onto the gaze direction is exactly the one that puts + /// the drawing's *north* away from the wearer — read from where you + /// stand, the far edge is the top, which is the way a sheet of paper on + /// a desk is oriented without anyone thinking about it. + private func aim(from device: simd_float4x4, height y: Float) { + let eye = SIMD3(device.columns.3.x, device.columns.3.y, device.columns.3.z) + let ahead = Self.forward(of: device) + home = [eye.x + ahead.x * Self.reach, y, eye.z + ahead.z * Self.reach] + homeYaw = atan2(-ahead.x, -ahead.z) + } + + /// The gaze, flattened onto the horizontal plane. A head transform + /// looks along its own −Z; dropping the Y component is what keeps the + /// sheet level however far up or down the wearer happens to be looking + /// when it lands. + private static func forward(of device: simd_float4x4) -> SIMD3 { + let flat = SIMD3(-device.columns.2.x, 0, -device.columns.2.z) + let length = simd_length(flat) + return length > 1e-4 ? flat / length : SIMD3(0, 0, -1) + } + var visibleSide: Double { (side * liveScale).clamped(to: Self.sideRange) } var visibleSpin: Double { spin + liveSpin } var visibleOffset: SIMD3 { offset + liveOffset } @@ -171,6 +249,15 @@ } } + /// Everything the entity's transform depends on, gathered in `body` so + /// Observation actually sees it change. + private struct SheetTransform { + let isVisible: Bool + let position: SIMD3 + let scale: Float + let yaw: Float + } + /// The immersive space: one sheet, lying flat, placed by hand. struct TableCanvasSpace: View { let model: ViewerModel @@ -183,7 +270,21 @@ private static let sheetName = "table-canvas" var body: some View { - RealityView { content in + // Read here, in `body`, and captured by the closure below. + // + // Observation only registers what a *view update* touches, and a + // `RealityView`'s `update:` closure runs outside that — properties + // read only in there never mark this view as needing an update, so + // the closure is called once and never again. The symptom is total: + // the sheet stays disabled at wherever the first frame put it, and + // ARKit deciding where the table is changes nothing on screen. + let sheet = SheetTransform( + isVisible: model.placement != .searching, + position: model.home + model.visibleOffset, + scale: model.entityScale, + yaw: model.homeYaw + Float(model.visibleSpin)) + + return RealityView { content in let sheet = Entity() sheet.name = Self.sheetName sheet.components.set( @@ -205,25 +306,29 @@ ) ])) - if model.sitsOnTable { - content.add( - AnchorEntity( - .plane(.horizontal, classification: .table, minimumBounds: [0.2, 0.2]) - ).addingChild(sheet)) - } - else { - content.add(sheet) - } + // A plain entity in world space, **not** an `AnchorEntity`. + // Anchoring to a plane is one line and answers none of the + // three questions that matter: the system picks which surface, + // picks where on it, and never says when it succeeded. Running + // the providers directly is what buys "in front of your eyes", + // "turned to face you", and a wait the window can explain. + content.add(sheet) } update: { content in - guard let sheet = Self.sheet(in: content) else { return } - sheet.position = Self.home(onTable: model.sitsOnTable) + model.visibleOffset - sheet.scale = .init(repeating: model.entityScale) - // Lie flat first, then spin about the parent's vertical axis — - // the plane's normal when anchored, and up either way. - sheet.orientation = - simd_quatf(angle: Float(model.visibleSpin), axis: [0, 1, 0]) + guard let entity = Self.sheet(in: content) else { return } + print() + // Nothing to look at until there is somewhere to put it — + // better a considered wait than a sheet parked wherever the + // origin happens to be. + entity.isEnabled = sheet.isVisible + entity.position = sheet.position + entity.scale = .init(repeating: sheet.scale) + // Lie flat, then turn: the yaw the placement chose, plus + // whatever the wearer has twisted since. + entity.orientation = + simd_quatf(angle: sheet.yaw, axis: [0, 1, 0]) * simd_quatf(angle: -.pi / 2, axis: [1, 0, 0]) } + .task { await findSomewhereToPutIt() } // Only the placement mode rebuilds; size, spin and position are // transforms on the entity that is already there. .id(model.sitsOnTable) @@ -236,11 +341,11 @@ .targetedToAnyEntity() .onChanged { model.liveOffset = Self.translation( - of: $0, keepingOnPlane: model.sitsOnTable) + of: $0, keepingOnPlane: model.placement == .onTable) } .onEnded { model.commitOffset( - Self.translation(of: $0, keepingOnPlane: model.sitsOnTable)) + Self.translation(of: $0, keepingOnPlane: model.placement == .onTable)) } ) .simultaneousGesture( @@ -261,28 +366,110 @@ ) } - /// Where the sheet sits before the wearer moves it. + /// Long enough for a table to turn up, short enough that nobody is left + /// staring at nothing wondering whether it is broken. Measured against + /// the ten-plus seconds plane detection actually took on device. + private static let searchTimeout: Duration = .seconds(15) + + /// Finds a table, or gives up and floats — and either way ends with the + /// sheet in front of the wearer, turned to face them. + /// + /// Note the heights are read against the **eyes**, not the floor: a + /// surface between 25cm and 1.4m below eye level is a table or a desk, + /// and that one test excludes the floor and the ceiling without + /// trusting the classifier, which reports `.undetermined` often enough + /// to matter. + private func findSomewhereToPutIt() async { + let session = ARKitSession() + let world = WorldTrackingProvider() + let planes = PlaneDetectionProvider(alignments: [.horizontal]) + model.placement = .searching + + do { + try await session.run(model.sitsOnTable ? [world, planes] : [world]) + } + catch { + // A refused world-sensing prompt lands here, and it is not an + // error worth showing a child: floating is a perfectly good way + // to look at a drawing. + model.floatInFront(device: nil) + return + } + + guard model.sitsOnTable else { + model.floatInFront(device: await Self.pose(from: world)) + return + } + + let giveUp = Task { @MainActor in + try? await Task.sleep(for: Self.searchTimeout) + guard !Task.isCancelled, model.placement == .searching else { return } + model.floatInFront(device: await Self.pose(from: world)) + } + defer { giveUp.cancel() } + + for await update in planes.anchorUpdates { + // Once something has been placed, a later plane must not move + // it — the wearer is already looking at the drawing. + guard model.placement == .searching else { return } + guard update.event != .removed else { continue } + guard let device = await Self.pose(from: world) else { continue } + let height = update.anchor.originFromAnchorTransform.columns.3.y + let belowEyes = device.columns.3.y - height + guard belowEyes > 0.25, belowEyes < 1.4 else { continue } + model.placeOnTable(atHeight: height, device: device) + return + } + } + + /// The head pose — **once world tracking is actually tracking**. /// - /// **An immersive space's origin is on the floor**, under where the - /// wearer started — not at eye level. Placing the fallback at a - /// negative height for "desk height, below the eyes" buries it under - /// the floor, which looks exactly like nothing rendering at all. The - /// anchored placement is the plane's own origin and needs no height of - /// its own. - private static func home(onTable: Bool) -> SIMD3 { - onTable ? .zero : [0, 1.0, -1.2] + /// `queryDeviceAnchor` answers straight away after `run`, and what it + /// answers with at first is an *untracked* anchor whose transform is the + /// identity. Aiming from that puts the sheet 35cm below the origin, + /// which is under the floor, which looks exactly like nothing rendering + /// at all — the same symptom, from a different cause, as the very first + /// placement bug in this file. `isTracked` is what separates a pose from + /// a placeholder. + private static func pose(from world: WorldTrackingProvider) async -> simd_float4x4? { + for _ in 0.. minimumEyeHeight + { + return anchor.originFromAnchorTransform + } + try? await Task.sleep(for: .milliseconds(100)) + } + return nil } + /// The height below which a "pose" is not one. + /// + /// An immersive space's origin is on the floor under the wearer, so a + /// head is about a metre and a half up. **The simulator reports the + /// identity transform and calls it tracked**, which reads as a head on + /// the floor and aims the sheet into the ground — so `isTracked` alone + /// is not enough to trust a pose by. 0.8m is under a seated child's + /// eyes and over anything that is really a placeholder; below it the + /// placement stops guessing and takes its fixed spot instead. + + /// Three seconds' worth of 100ms tries. Long enough for tracking to + /// start, short enough that a headset which never tracks still gets a + /// drawing rather than a blank room. + private static let minimumEyeHeight: Float = 0.8 + + private static let poseAttempts = 30 + /// A drag's translation in the entity's parent space, which is where /// `position` is read. Converting to `.scene` instead would be wrong /// the moment the sheet hangs off a plane anchor. /// /// `keepingOnPlane` is what stops a drag lifting the sheet off the - /// table: on a plane anchor the parent's Y **is** the plane's normal, - /// so dropping that one component slides the drawing along the surface - /// instead of into the air. It is off in the floating placement, where - /// there is no surface to stay on and height is the only way to put the - /// sheet somewhere sensible. + /// table: the sheet is level, so dropping the Y component slides the + /// drawing along the surface instead of into the air. It is off in the + /// floating placement, where there is no surface to stay on and height + /// is the only way to put the sheet somewhere sensible. private static func translation( of value: EntityTargetValue, keepingOnPlane: Bool ) -> SIMD3 { @@ -319,14 +506,4 @@ } } - extension Entity { - /// `content.add(AnchorEntity(…).addingChild(sheet))` reads better than - /// the three statements it replaces, and this file has no other use for - /// a local variable holding the anchor. - fileprivate func addingChild(_ child: Entity) -> Entity { - addChild(child) - return self - } - } - #endif diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 2af9b8f..b5cc492 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -224,11 +224,37 @@ } .font(.callout) - Text("Pinch to resize, twist to turn, drag to move.") + PlacementStatus(model: model) .font(.caption) .foregroundStyle(.secondary) } } } + /// What the sheet is doing, under the placement controls. + /// + /// Finding a table takes **ten seconds or more** on device, and the sheet + /// is deliberately not drawn until there is somewhere to put it — so + /// without this the app spends that time looking broken. Saying what it is + /// waiting for is the whole of the fix: a wait you understand is a wait, + /// and a wait you don't is a bug. + private struct PlacementStatus: View { + let model: ViewerModel + + var body: some View { + switch model.placement { + case .searching: + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Looking for a table…") + } + case .floating: + Text("No table found, so it is floating in front of you.") + case .onTable: + Text("Pinch to resize, twist to turn, drag to move.") + } + } + } + #endif From c238096ab2791b3c26be9de32ce87b7d0c2f4311 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sun, 16 Aug 2026 07:45:17 +0900 Subject: [PATCH 22/33] Stop choosing the floor to draw on (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- App/Views/TableCanvas.swift | 160 +++++++++++++++++++++++++++++------- 1 file changed, 131 insertions(+), 29 deletions(-) diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift index 14f63a5..7227057 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/TableCanvas.swift @@ -154,7 +154,7 @@ /// How far ahead of the eyes the sheet lands. Desk reach: near enough /// to be *the thing you are looking at*, far enough not to sit in your /// lap. Everything past this is the wearer's to drag. - private static let reach: Float = 0.6 + static let reach: Float = 0.6 /// Drop below eye level for the floating fallback — roughly where a /// desk would be if there were one. @@ -188,17 +188,30 @@ /// stand, the far edge is the top, which is the way a sheet of paper on /// a desk is oriented without anyone thinking about it. private func aim(from device: simd_float4x4, height y: Float) { + let target = Self.gazeTarget(from: device) + home = [target.x, y, target.z] + homeYaw = Self.yaw(from: device) + } + + /// The spot the sheet aims for: `reach` metres ahead of the eyes, at + /// eye height. Shared with surface picking, so "the table I am looking + /// at" and "where on it the drawing goes" are the same point. + static func gazeTarget(from device: simd_float4x4) -> SIMD3 { let eye = SIMD3(device.columns.3.x, device.columns.3.y, device.columns.3.z) - let ahead = Self.forward(of: device) - home = [eye.x + ahead.x * Self.reach, y, eye.z + ahead.z * Self.reach] - homeYaw = atan2(-ahead.x, -ahead.z) + let ahead = forward(of: device) + return eye + ahead * reach + } + + static func yaw(from device: simd_float4x4) -> Float { + let ahead = forward(of: device) + return atan2(-ahead.x, -ahead.z) } /// The gaze, flattened onto the horizontal plane. A head transform /// looks along its own −Z; dropping the Y component is what keeps the /// sheet level however far up or down the wearer happens to be looking /// when it lands. - private static func forward(of device: simd_float4x4) -> SIMD3 { + static func forward(of device: simd_float4x4) -> SIMD3 { let flat = SIMD3(-device.columns.2.x, 0, -device.columns.2.z) let length = simd_length(flat) return length > 1e-4 ? flat / length : SIMD3(0, 0, -1) @@ -249,6 +262,27 @@ } } + /// Keeps every horizontal surface ARKit has told us about, so the choice of + /// where to put the drawing is made over the whole set rather than over + /// whichever one happened to arrive first. + @MainActor + final class SurfaceCollector { + private var surfaces: [UUID: PlaneAnchor] = [:] + + var all: [PlaneAnchor] { Array(surfaces.values) } + + func consume(_ provider: PlaneDetectionProvider) async { + for await update in provider.anchorUpdates { + if update.event == .removed { + surfaces[update.anchor.id] = nil + } + else { + surfaces[update.anchor.id] = update.anchor + } + } + } + } + /// Everything the entity's transform depends on, gathered in `body` so /// Observation actually sees it change. private struct SheetTransform { @@ -373,12 +407,6 @@ /// Finds a table, or gives up and floats — and either way ends with the /// sheet in front of the wearer, turned to face them. - /// - /// Note the heights are read against the **eyes**, not the floor: a - /// surface between 25cm and 1.4m below eye level is a table or a desk, - /// and that one test excludes the floor and the ceiling without - /// trusting the classifier, which reports `.undetermined` often enough - /// to matter. private func findSomewhereToPutIt() async { let session = ARKitSession() let world = WorldTrackingProvider() @@ -400,28 +428,102 @@ model.floatInFront(device: await Self.pose(from: world)) return } - - let giveUp = Task { @MainActor in - try? await Task.sleep(for: Self.searchTimeout) - guard !Task.isCancelled, model.placement == .searching else { return } - model.floatInFront(device: await Self.pose(from: world)) - } - defer { giveUp.cancel() } - - for await update in planes.anchorUpdates { - // Once something has been placed, a later plane must not move - // it — the wearer is already looking at the drawing. - guard model.placement == .searching else { return } - guard update.event != .removed else { continue } - guard let device = await Self.pose(from: world) else { continue } - let height = update.anchor.originFromAnchorTransform.columns.3.y - let belowEyes = device.columns.3.y - height - guard belowEyes > 0.25, belowEyes < 1.4 else { continue } - model.placeOnTable(atHeight: height, device: device) + guard let device = await Self.pose(from: world) else { + model.floatInFront(device: nil) return } + + // Collected rather than consumed. Taking the **first** surface that + // passed was the whole of the "it keeps choosing the floor" bug: + // updates arrive in no useful order, and a floor is large, flat and + // found early, so it won nearly every race it was allowed to enter. + let surfaces = SurfaceCollector() + let collecting = Task { await surfaces.consume(planes) } + defer { collecting.cancel() } + + let clock = ContinuousClock() + let started = clock.now + var firstSeen: ContinuousClock.Instant? + + while clock.now - started < Self.searchTimeout { + if let best = Self.bestSurface(among: surfaces.all, device: device) { + firstSeen = firstSeen ?? clock.now + // A table by name is the answer outright. Anything else is + // a guess worth holding a moment, in case the real table is + // still being mapped. + if best.surfaceClassification == .table + || clock.now - firstSeen! >= Self.settleDelay + { + model.placeOnTable( + atHeight: best.originFromAnchorTransform.columns.3.y, device: device) + return + } + } + try? await Task.sleep(for: .milliseconds(250)) + } + model.floatInFront(device: device) } + /// The surface to put the drawing on: the one nearest to where the + /// wearer is looking, out of those that could be a table at all. + /// + /// **Height is tested two ways, and each covers the other's blind + /// spot.** An immersive space's origin sits on the floor, so an + /// absolute height excludes the floor outright — but only while the + /// origin really is down there. Eye-relative height does not depend on + /// that, but on its own it lets the floor through for anyone *sitting*: + /// a seated wearer's eyes are about 1.2m up, which puts the floor 1.2m + /// below them, in exactly the band a standing wearer's desk occupies. + /// That is what made this pick the floor. Together they leave a desk + /// and take a floor whether you sit or stand. + /// + /// Classification is a *preference*, never a filter: it reports + /// `.undetermined` often enough that filtering on it would find nothing + /// in an ordinary room. + private static func bestSurface( + among surfaces: [PlaneAnchor], device: simd_float4x4 + ) -> PlaneAnchor? { + let eye = SIMD3(device.columns.3.x, device.columns.3.y, device.columns.3.z) + let ahead = ViewerModel.gazeTarget(from: device) + return + surfaces + .filter { surface in + let y = surface.originFromAnchorTransform.columns.3.y + let belowEyes = eye.y - y + return y > minimumSurfaceHeight && belowEyes > 0.2 + && belowEyes < maximumDropBelowEyes + } + .min { a, b in + // A named table beats an unnamed surface; after that, the + // one closest to what the wearer is looking at wins, which + // is what makes "the desk in front of me" beat "the counter + // behind me". + let named = ( + a.surfaceClassification == .table, b.surfaceClassification == .table + ) + if named.0 != named.1 { return named.0 } + return distance(from: ahead, to: a) < distance(from: ahead, to: b) + } + } + + private static func distance(from point: SIMD3, to surface: PlaneAnchor) -> Float { + let centre = surface.originFromAnchorTransform.columns.3 + return simd_length(SIMD2(centre.x - point.x, centre.z - point.z)) + } + + /// The floor is at zero, so anything at knee height or below is not a + /// table. Deliberately low: a child's desk is lower than an adult's. + private static let minimumSurfaceHeight: Float = 0.35 + + /// Far enough below the eyes to be a surface you look *down* at, near + /// enough that a seated wearer's floor (about 1.2m down) is excluded. + private static let maximumDropBelowEyes: Float = 1.1 + + /// How long a non-table surface is held before being accepted, in case + /// a real table is still being mapped. Cheap against the ten seconds + /// detection takes anyway. + private static let settleDelay: Duration = .seconds(2) + /// The head pose — **once world tracking is actually tracking**. /// /// `queryDeviceAnchor` answers straight away after `run`, and what it From 2d371b57aa5891bc997a3b31758f8a016b6801a1 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Sun, 16 Aug 2026 22:40:06 +0900 Subject: [PATCH 23/33] Add the 3D tortoise, generated from the three-view drawing (#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. --- App/Resources/Tortoise.usdz | Bin 0 -> 106014 bytes CLAUDE.md | 19 + Tools/tortoise-model/README.md | 99 ++++ Tools/tortoise-model/build_tortoise.py | 663 +++++++++++++++++++++++++ Tools/tortoise-model/qlcheck.swift | 44 ++ Tools/tortoise-model/render_views.py | 142 ++++++ Tools/tortoise-model/sheet.py | 64 +++ 7 files changed, 1031 insertions(+) create mode 100644 App/Resources/Tortoise.usdz create mode 100644 Tools/tortoise-model/README.md create mode 100644 Tools/tortoise-model/build_tortoise.py create mode 100644 Tools/tortoise-model/qlcheck.swift create mode 100644 Tools/tortoise-model/render_views.py create mode 100644 Tools/tortoise-model/sheet.py diff --git a/App/Resources/Tortoise.usdz b/App/Resources/Tortoise.usdz new file mode 100644 index 0000000000000000000000000000000000000000..46a67fc3626eddeb9b57b923b824ac84b58326b6 GIT binary patch literal 106014 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 literal 0 HcmV?d00001 diff --git a/CLAUDE.md b/CLAUDE.md index 18eb211..3399a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -419,6 +419,25 @@ Check a change here in the *built* product rather than in Xcode — `assetutil fails the same silent way a missing one does: the system's placeholder, which looks like a plain app that hasn't been styled yet. +**The 3D tortoise is generated, not modelled** (#53). +`App/Resources/Tortoise.usdz` — the sprite the immersive space will draw with — +comes out of `Tools/tortoise-model/build_tortoise.py`, a Blender script whose +constants *are* the three-view drawing's measurements. It is checked in +alongside the script so no build step needs Blender. Four things about it are a +contract app code will assume, and the reasoning for each is in +`Tools/tortoise-model/README.md`: `upAxis = "Y"` with **forward at `-Z`** (the +model is authored Z-up and the exporter puts `rotateXYZ = (-90, 0, 0)` on the +root — wrong settings here are invisible until the tortoise drives sideways); +**total length exactly 1.0** with `metersPerUnit = 1`, normalised rather than +real-world because the canvas is a 0.2–2m gesture and the size is always +computed anyway; the **origin is the ground point under the shell's centre**, +the point it turns about, *not* the brush tip, so the drawn line trails behind +the animal; and the whole thing rides in `App/` as a synchronized-folder +resource, landing flat at `Contents/Resources/Tortoise.usdz` (verified in the +built bundle, the only way that works — see the nested-CLAUDE.md note above). +Blender rendering it proves nothing about RealityKit; `qlcheck.swift` in the +same directory runs it through Apple's own USD stack instead. + **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. **Localization**: `en` is the source language; Japanese (kid-friendly diff --git a/Tools/tortoise-model/README.md b/Tools/tortoise-model/README.md new file mode 100644 index 0000000..4128e88 --- /dev/null +++ b/Tools/tortoise-model/README.md @@ -0,0 +1,99 @@ +# The 3D tortoise + +`App/Resources/Tortoise.usdz` is generated, not modelled. This directory is +what generates it. + +The design came in as a three-view drawing (top / side / front / rear, #53), +and every shape in it is a primitive — a domed shell, an ellipsoid head, +four flipper blobs, a beret, a tail ending in a brush. That is what makes a +script the right tool rather than the lazy one: the numbers in +`build_tortoise.py` *are* the drawing's measurements, so a proportion can be +argued about and changed in one place instead of being pushed around by hand +and then lost. + +## Running it + +```bash +cd Tools/tortoise-model + +# Build the model. Writes Tortoise.usdz, tortoise.blend and the shell's +# gradient texture into --out. +blender --background --python build_tortoise.py -- --out /tmp/tortoise + +# Render the same views as the drawing, to compare against it. --only takes +# a comma-separated subset (top side front rear hero tail head); a full sheet +# is seven renders and several minutes. +blender --background /tmp/tortoise/tortoise.blend --python render_views.py -- \ + --out /tmp/tortoise --only tail,side +python3 sheet.py /tmp/tortoise # composites them, needs Pillow + +# Then copy the result over the committed asset. +cp /tmp/tortoise/Tortoise.usdz ../../App/Resources/Tortoise.usdz +``` + +Built with Blender 5.2 LTS. `build_tortoise.py` needs nothing but Blender's +own bundled Python — the shell's gradient PNG is written out of `zlib` and +`struct` rather than Pillow for exactly that reason. Only `sheet.py`, which +is a convenience for looking at renders, wants Pillow, and it runs under the +system `python3`. + +## Verifying it + +Blender rendering the model proves nothing about whether *RealityKit* can +read it. Ask Apple's own USD stack instead: + +```bash +xcrun swiftc -O qlcheck.swift -o /tmp/qlcheck +/tmp/qlcheck ../../App/Resources/Tortoise.usdz /tmp/tortoise-ql.png +``` + +That runs the file through QuickLook, which parses and renders it with the +same USD implementation the app will. `qlmanage -t` is the obvious +alternative and tends to hang — the same trap as the thumbnail extension +(see the root `CLAUDE.md`). + +Worth checking the stage metadata too, since it is what decides which way up +the animal arrives: + +```bash +usdcat --flatten ../../App/Resources/Tortoise.usdz -o /tmp/flat.usda +grep -m3 -E 'upAxis|metersPerUnit|defaultPrim' /tmp/flat.usda +``` + +## The contract the app depends on + +These are the things app code will assume, so changing one is a change to +the app and not just to the asset. + +- **`upAxis = "Y"`, forward is `-Z`** — RealityKit's convention, not + Blender's. The model is authored Z-up with the head at `+Y` and the + exporter puts a `rotateXYZ = (-90, 0, 0)` on the root prim to convert. + Confirm with `usdcat` after any change to the export settings; getting it + wrong is invisible until the tortoise drives sideways. +- **Total length is 1.0**, nose tip to brush tip, with `metersPerUnit = 1`. + Deliberately normalised rather than given a real-world size: the canvas is + a gesture between 0.2m and 2m (#53), so the tortoise's size is always + computed anyway, and a unit-length model makes that `scale = the length you + want`. +- **The origin is the point on the ground under the shell's centre** — the + tortoise's position, and the point it turns about. Not the brush tip. The + brush is the pen, so the drawn line trails *behind* the animal; putting the + origin at the brush instead would make the line exact and the turning + strange, and that trade was decided in favour of ordinary turning. +- Bounds, for framing: `x ±0.309`, `y 0 .. 0.386` (height), `z -0.543` (nose) + `.. +0.457` (brush). The brush tip is at `(0, 0.066, 0.457)`. +- Fifteen named meshes under one `Tortoise` xform, five materials, one + 915-byte texture; about 2,200 triangles. + +## Two things that look like mistakes and are not + +**The shell's ramp is squared** (`SHELL_RAMP_BIAS`). It runs blue at the rim +to pink at the apex, by height — but the view that matters is from above, +and a dome seen from above shows only `v²` of its projected area below height +`v`. Mapped straight, three quarters of the top view comes out pink. + +**The brush tip is uneven.** Alternate hairs stop a third of the bundle +short. Three level-tipped versions were built after this one — radial fluting, +a bellied profile, and fourteen separately modelled strands — and the +maintainer chose this shape to come back to. There is a longer note at +`BRISTLE_NOTCH`. diff --git a/Tools/tortoise-model/build_tortoise.py b/Tools/tortoise-model/build_tortoise.py new file mode 100644 index 0000000..9815e4a --- /dev/null +++ b/Tools/tortoise-model/build_tortoise.py @@ -0,0 +1,663 @@ +"""Build the 3D tortoise from the three-view drawing, and write a USDZ. + +Run with: blender --background --python build_tortoise.py -- [--out DIR] + +Everything is parametric: the numbers below were read off the drawing (in +pixels) and normalised so the whole animal is **1.0 unit long**, nose tip to +brush tip. The app scales that to whatever the canvas is. + +Axes while building (Blender's own): +X right, +Y forward (the head), +Z up. +The origin is the point on the ground directly under the shell's centre — the +tortoise's "position", i.e. what turns when it turns. Export converts to +USD/RealityKit convention (+Y up, -Z forward). +""" + +import math +import os +import sys + +import bmesh +import bpy +from mathutils import Matrix, Vector + +# -------------------------------------------------------------------------- +# Proportions, read off the three-view drawing. +# +# Top view was measured over 565px nose-to-brush; the side view over 540px. +# Both are the same animal, so every measurement below is divided by its own +# view's total and expressed as a fraction of the overall length L = 1.0. +# -------------------------------------------------------------------------- + +L = 1.0 # total length, nose tip to brush tip + +# Shell — a low-poly dome. 325px across, 150px tall (side view). +# +# The rim height is the one number that has to be got right against the body, +# and getting it wrong is not subtle: with the rim *below* the body's top the +# golden ellipsoid rises through the dome and the animal reads as a sausage +# with a hat. The rim must sit just inside the body's surface — see BODY_Z. +SHELL_R = 0.288 # radius in the ground plane +SHELL_BASE_Z = 0.115 # the dome's rim height +SHELL_TOP_Z = 0.380 # the animal's full height, from the side view +SHELL_H = SHELL_TOP_Z - SHELL_BASE_Z + +# Body / plastron — the golden rim that shows under the shell in the front +# and rear views. Barely wider than the dome (0.014 of rim, which is what the +# drawing shows) and *flat*: it is a rim, not a torso. +BODY_R_X = 0.302 +BODY_R_Y = 0.312 +BODY_R_Z = 0.082 +BODY_Z = 0.098 +# Where the body's surface crosses the dome's rim radius: +# 0.098 +- 0.082 * sqrt(1 - (0.288/0.302)^2) = 0.073 .. 0.123 +# so a rim at 0.115 is buried 0.008 deep and the seam cannot open. + +# Head — a teardrop: rounded point at the nose, widest about two thirds back. +HEAD_NOSE_Y = 0.543 # the nose, measured forward of the shell's centre +HEAD_LEN = 0.250 +HEAD_R_X = 0.089 +HEAD_R_Z = 0.093 +HEAD_Z = 0.209 +# How much narrower the nose is than the back of the head. Applied through a +# smoothstep, not a power curve: an ellipsoid is already closing toward its +# own end, so a taper that keeps biting there compounds into a spike. The +# drawing's head stays broad most of the way and only rounds off near the tip, +# which is a shallow taper, not a steep one. +HEAD_TAPER = 0.26 + +# Beret — a purple one, worn on the head with a backward tilt. +# +# The drawing reads as a collar round the neck and it is not one; this is a +# hat. Taken literally the drawing puts the purple almost upright, facing +# forward, which is exactly why it reads as a collar: at that angle the crown +# points at the shell, the head hides it, and all that is left to see is the +# rolled edge — a ring. Built that way once and it still looked like a +# collar, so the tilt is set by what makes it legible as a hat instead, and +# the drawing loses this one. It is the element the drawing was wrong about. +# +# What makes it a beret rather than a purple disc: a domed crown wider than +# the skull, so it overhangs; the rolled band round the bottom edge; and the +# little nub on top. +# Every dimension goes through one factor, so resizing the hat stays a single +# number instead of four that can drift apart. 0.70 is against the first pass, +# which sat too big on the head. +BERET_SCALE = 0.70 +BERET_R = 0.115 * BERET_SCALE # the crown, and the ring the rolled edge follows +BERET_DOME = 0.065 * BERET_SCALE # how far the crown rises above the band +BERET_RIM = 0.020 * BERET_SCALE # the rolled edge; outer radius is R + this +BERET_NUB = 0.019 * BERET_SCALE +# Sitting a little lower and straighter than the big one did: a small cap at a +# steep angle perches rather than is worn, because its front edge lifts off the +# skull by more of the head's own height the smaller it gets. +BERET_POS = (0.0, 0.332, 0.274) +BERET_TILT = math.radians(28.0) + +# Legs — four flippers, splayed out at roughly 40 degrees. +# +# Their centres sit almost exactly on the shell's edge circle (measured: 0.280 +# from the centre against a shell radius of 0.288), so what shows outside the +# rim is roughly one half-length of flipper. That is the drawing's silhouette. +LEG_R = (0.082, 0.068, 0.054) # outward, along, up +LEG_Z = 0.048 # centre height; the underside just kisses the ground +FRONT_LEG = (0.220, 0.202) # |x|, y +REAR_LEG = (0.236, -0.194) +LEG_SPLAY = math.radians(40.0) + +# Tail — a tapering shaft ending in a paintbrush. This is the pen. +# +# Both halves are generated from *one* segment, base to tip, and that is the +# point rather than tidiness: built separately, the shaft took a pitch to match +# its two end heights while the brush stayed level, and the join came out with +# a visible kink in it. A brush is one straight object; there is nowhere for a +# second axis to come from. +TAIL_BASE = Vector((0.0, -0.270, 0.092)) +TAIL_TIP = Vector((0.0, -0.457, 0.070)) +FERRULE_T = 0.66 # where the golden shaft stops and the bristles start +TAIL_R_BASE = 0.050 +TAIL_R_TIP = 0.034 +BRUSH_R = 0.056 +BRISTLES = 10 +BRISTLE_NOTCH = 0.34 # how far back the gaps between hairs are cut + +# This tip is **deliberately uneven**: alternate hairs stop a third of the +# bundle short, so the end is a zigzag rather than a cut. +# +# Which is worth a note, because it looks like an oversight and is not. Three +# other tips were built after this one — the notch swapped for radial fluting +# so the end came out flat, then a bellied profile, then fourteen separately +# modelled strands splaying to a level cut. The maintainer looked at all of +# them and chose this one to come back to. So a future pass that "fixes" the +# ragged end is redoing work that was already done and already rejected. + + +# Eyes — big, forward, sitting proud of the snout. +# +# They have to read from *above* as well as from the front: the drawing shows +# both eyes whole in the top view, and the top view is the one the app will +# mostly show, since the tortoise is being watched drawing on a table. So the +# pupils face up-and-forward rather than out to the sides. +EYE_X = 0.052 +EYE_Y = 0.444 +EYE_Z = 0.250 +EYE_R = 0.043 +PUPIL_R = 0.031 +PUPIL_OUT = 0.017 # how far the pupil pokes out of the white +PUPIL_AIM = (0.42, 0.70, 0.58) # outward, forward, up — normalised on use + +# -------------------------------------------------------------------------- +# Colours, sampled from the drawing. +# -------------------------------------------------------------------------- + +GOLD = (0.867, 0.706, 0.310) +PURPLE = (0.557, 0.184, 0.753) +SHELL_LOW = (0.341, 0.776, 0.910) # blue, at the rim +SHELL_HIGH = (0.933, 0.447, 0.835) # pink, at the apex +# The ramp runs up the dome's *height*, but the view that matters most is from +# above — the animal is being watched drawing on a table — and a dome seen from +# above shows only v^2 of its projected area below height v. Mapped straight, +# three quarters of the top view comes out pink. Squaring the coordinate puts +# the halfway colour at v = 0.71, which is where the drawing has it. +SHELL_RAMP_BIAS = 2.0 +WHITE = (0.980, 0.980, 0.980) +BLACK = (0.055, 0.055, 0.060) + +GRADIENT_PNG = "shell_gradient.png" + + +# -------------------------------------------------------------------------- +# Scene plumbing +# -------------------------------------------------------------------------- + + +def clear_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def srgb_to_linear(c): + def one(u): + return u / 12.92 if u <= 0.04045 else ((u + 0.055) / 1.055) ** 2.4 + + return tuple(one(x) for x in c) + + +def plain_material(name, colour, roughness=0.45): + """A Principled BSDF in one flat colour, which is all UsdPreviewSurface + needs for everything except the shell.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + lin = srgb_to_linear(colour) + bsdf.inputs["Base Color"].default_value = (*lin, 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = 0.0 + mat.diffuse_color = (*lin, 1.0) + return mat + + +def gradient_material(name, png_path, roughness=0.35): + """The shell's blue-to-pink ramp, as an image texture. + + A texture rather than vertex colours on purpose: `primvars:displayColor` + is not reliably honoured once a material is bound, while an image feeding + UsdPreviewSurface's diffuse is exactly what RealityKit reads. + """ + mat = bpy.data.materials.new(name) + mat.use_nodes = True + tree = mat.node_tree + bsdf = tree.nodes["Principled BSDF"] + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = 0.0 + + tex = tree.nodes.new("ShaderNodeTexImage") + tex.image = bpy.data.images.load(png_path) + tex.image.colorspace_settings.name = "sRGB" + tex.interpolation = "Closest" + tex.location = (-320, 260) + tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) + return mat + + +def write_gradient_png(path): + """A 8x256 strip: blue at the bottom (v=0, the rim), pink at the top. + + Written by hand out of `zlib` and `struct` because Blender ships its own + Python and it has no Pillow. A PNG is a signature plus three chunks, and + the whole image is 2KB — pulling a dependency into the build for that + would cost more than the twenty lines. + """ + import struct + import zlib + + w, h = 8, 256 + raw = bytearray() + for row in range(h): + # Row 0 is the top of the image, which is v = 1. + t = 1.0 - row / (h - 1) + rgb = bytes( + int(round(255 * (SHELL_LOW[i] + (SHELL_HIGH[i] - SHELL_LOW[i]) * t))) + for i in range(3) + ) + raw.append(0) # filter type 0 (None) for this scanline + raw += rgb * w + + def chunk(kind, payload): + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"") + ) + with open(path, "wb") as f: + f.write(png) + return path + + +def new_object(name, bm, material, smooth=True): + mesh = bpy.data.meshes.new(name) + bm.to_mesh(mesh) + bm.free() + for poly in mesh.polygons: + poly.use_smooth = smooth + mesh.materials.append(material) + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + return obj + + +def icosphere(bm, subdivisions, radius=1.0): + try: + bmesh.ops.create_icosphere(bm, subdivisions=subdivisions, radius=radius) + except TypeError: # older bmesh spelled it "diameter" + bmesh.ops.create_icosphere(bm, subdivisions=subdivisions, diameter=radius) + + +def cone(bm, segments, r1, r2, depth, matrix=None): + kwargs = dict( + cap_ends=True, + cap_tris=False, + segments=segments, + depth=depth, + matrix=matrix or Matrix.Identity(4), + ) + try: + bmesh.ops.create_cone(bm, radius1=r1, radius2=r2, **kwargs) + except TypeError: + bmesh.ops.create_cone(bm, diameter1=r1, diameter2=r2, **kwargs) + + +def align_to(p0, p1): + """The matrix that puts a part built along local +Z onto the segment + p0 -> p1, centred on it.""" + axis = Vector(p1) - Vector(p0) + rot = axis.to_track_quat("Z", "Y").to_matrix().to_4x4() + return Matrix.Translation((Vector(p0) + Vector(p1)) / 2.0) @ rot + + +def apply_matrix(bm, m): + for v in bm.verts: + v.co = m @ v.co + + +def cone_between(bm, p0, p1, r0, r1, segments): + """A frustum from p0 (radius r0) to p1 (radius r1).""" + length = (Vector(p1) - Vector(p0)).length + cone(bm, segments, r0, r1, length, matrix=align_to(p0, p1)) + + +def torus(bm, major, minor, ring_n=24, tube_n=12): + """A torus in the local XY plane, its axis along +Z.""" + rows = [] + for i in range(ring_n): + a = 2 * math.pi * i / ring_n + nx, ny = math.cos(a), math.sin(a) + row = [] + for j in range(tube_n): + b = 2 * math.pi * j / tube_n + r = major + minor * math.cos(b) + row.append(bm.verts.new((nx * r, ny * r, minor * math.sin(b)))) + rows.append(row) + bm.verts.ensure_lookup_table() + for i in range(ring_n): + for j in range(tube_n): + bm.faces.new( + ( + rows[i][j], + rows[i][(j + 1) % tube_n], + rows[(i + 1) % ring_n][(j + 1) % tube_n], + rows[(i + 1) % ring_n][j], + ) + ) + + +def scale_verts(bm, sx, sy, sz, offset=(0.0, 0.0, 0.0)): + for v in bm.verts: + v.co.x = v.co.x * sx + offset[0] + v.co.y = v.co.y * sy + offset[1] + v.co.z = v.co.z * sz + offset[2] + + +# -------------------------------------------------------------------------- +# The parts +# -------------------------------------------------------------------------- + + +def build_shell(material): + """The dome: an icosphere squashed to the shell's proportions, with the + bottom half thrown away. + + Subdivision 2 is what the drawing shows — about 160 triangles over the + dome. A subdivided icosahedron has a closed loop of vertices exactly at + the equator (the cross-band edge midpoints land at z=0 and stay there + through normalisation), so cutting it in half leaves a clean rim. + + Flat-shaded, and the only part of the animal that is: the faceting is the + whole look, and everything golden is smooth in the drawing. + """ + bm = bmesh.new() + icosphere(bm, subdivisions=2, radius=1.0) + + # Keep the top half. A small epsilon so the equatorial ring survives. + doomed = [v for v in bm.verts if v.co.z < -1e-5] + bmesh.ops.delete(bm, geom=doomed, context="VERTS") + + scale_verts(bm, SHELL_R, SHELL_R, SHELL_H, offset=(0, 0, SHELL_BASE_Z)) + + # Cap the open rim. It sits inside the golden body, so it is never seen — + # but an open mesh shows its own inside through the silhouette at grazing + # angles, and a cap costs a dozen triangles. + rim = [e for e in bm.edges if len(e.link_faces) == 1] + bmesh.ops.holes_fill(bm, edges=rim) + + # Per-face UVs: all three corners of a triangle take the v of the face's + # centre, so each facet is one flat colour and the ramp still runs up the + # dome. That is the low-poly look in the drawing — flat steps, not a + # smooth wash. + uv = bm.loops.layers.uv.new("UVMap") + lo, hi = SHELL_BASE_Z, SHELL_TOP_Z + for face in bm.faces: + v = (face.calc_center_median().z - lo) / (hi - lo) + v = min(max(v, 0.0), 1.0) ** SHELL_RAMP_BIAS + for loop in face.loops: + loop[uv].uv = (0.5, v) + + return new_object("Shell", bm, material, smooth=False) + + +def build_body(material): + bm = bmesh.new() + icosphere(bm, subdivisions=3, radius=1.0) + scale_verts(bm, BODY_R_X, BODY_R_Y, BODY_R_Z, offset=(0, 0, BODY_Z)) + return new_object("Body", bm, material) + + +def build_head(material): + """An ellipsoid tapered toward the nose — the teardrop in the top view.""" + bm = bmesh.new() + icosphere(bm, subdivisions=3, radius=1.0) + half = HEAD_LEN / 2.0 + centre_y = HEAD_NOSE_Y - half + scale_verts(bm, HEAD_R_X, half, HEAD_R_Z, offset=(0, centre_y, HEAD_Z)) + + for v in bm.verts: + # f = 0 at the back of the head, 1 at the nose. + f = (v.co.y - (centre_y - half)) / HEAD_LEN + f = min(max(f, 0.0), 1.0) + k = 1.0 - HEAD_TAPER * (f * f * (3.0 - 2.0 * f)) # smoothstep + v.co.x *= k + v.co.z = HEAD_Z + (v.co.z - HEAD_Z) * k + + return new_object("Head", bm, material) + + +def build_beret(material): + """A domed crown, the rolled band round its base, and the nub on top. + + All three are built flat in the local XY plane and tilted together, so the + band stays in the crown's plane and the nub stays on the crown's axis + whatever the tilt is set to. The head pushes up into the open underside, + which is what holds the hat on. + """ + bm = bmesh.new() + + # The crown: the top half of a squashed sphere, capped underneath. + icosphere(bm, subdivisions=3, radius=1.0) + doomed = [v for v in bm.verts if v.co.z < -1e-5] + bmesh.ops.delete(bm, geom=doomed, context="VERTS") + scale_verts(bm, BERET_R, BERET_R, BERET_DOME) + bmesh.ops.holes_fill(bm, edges=[e for e in bm.edges if len(e.link_faces) == 1]) + + torus(bm, BERET_R, BERET_RIM, ring_n=24, tube_n=10) + + nub = bmesh.new() + icosphere(nub, subdivisions=2, radius=BERET_NUB) + for v in nub.verts: + v.co.z += BERET_DOME + mesh = bpy.data.meshes.new("_nub") + nub.to_mesh(mesh) + nub.free() + bm.from_mesh(mesh) + bpy.data.meshes.remove(mesh) + + m = Matrix.Translation(Vector(BERET_POS)) @ Matrix.Rotation(BERET_TILT, 4, "X") + apply_matrix(bm, m) + return new_object("Beret", bm, material) + + +def build_neck(material): + """Joins the head to the body. + + It has real work to do now that the purple is a hat rather than a collar: + the head's centre sits above the body's top, so with the collar gone this + is the only thing bridging them, and a gap here would be visible from + every angle rather than hidden under a ruff. + """ + bm = bmesh.new() + cone_between(bm, (0.0, 0.200, 0.130), (0.0, 0.345, 0.207), 0.080, 0.070, 16) + return new_object("Neck", bm, material) + + +def build_leg(name, x, y, splay, material): + bm = bmesh.new() + icosphere(bm, subdivisions=2, radius=1.0) + scale_verts(bm, LEG_R[0], LEG_R[1], LEG_R[2]) + rot = Matrix.Rotation(splay, 4, "Z") + for v in bm.verts: + v.co = rot @ v.co + Vector((x, y, LEG_Z)) + return new_object(name, bm, material) + + +def build_legs(material): + fx, fy = FRONT_LEG + rx, ry = REAR_LEG + return [ + build_leg("LegFrontLeft", -fx, fy, LEG_SPLAY, material), + build_leg("LegFrontRight", fx, fy, -LEG_SPLAY, material), + build_leg("LegRearLeft", -rx, ry, -LEG_SPLAY, material), + build_leg("LegRearRight", rx, ry, LEG_SPLAY, material), + ] + + +def ferrule_point(): + """Where the shaft ends and the bristles begin, on the one tail segment.""" + return TAIL_BASE.lerp(TAIL_TIP, FERRULE_T) + + +def build_tail(material): + """The golden shaft, sloping gently down and back.""" + bm = bmesh.new() + cone_between(bm, TAIL_BASE, ferrule_point(), TAIL_R_BASE, TAIL_R_TIP, 16) + return new_object("Tail", bm, material) + + +def build_brush(material): + """The bristles: a flared cone with a notched end. + + Built along +Z and swung onto the tail's own segment afterwards, so the + ferrule cannot develop a kink: there is only one axis in the file. + + The notch takes alternate rim vertices *back* along the axis as well as + inward, so half the hairs finish a third of the bundle short of the other + half. The end is therefore a zigzag, not a cut — see the note by + BRISTLE_NOTCH, which is the whole reason it is like this. + """ + bm = bmesh.new() + ferrule = ferrule_point() + length = (TAIL_TIP - ferrule).length + cone(bm, BRISTLES, TAIL_R_TIP * 1.04, BRUSH_R, length) + + tip_z = length / 2.0 + rim = sorted( + (v for v in bm.verts if abs(v.co.z - tip_z) < 1e-4), + key=lambda v: math.atan2(v.co.y, v.co.x), + ) + for i, v in enumerate(rim): + if i % 2: + v.co.z -= length * BRISTLE_NOTCH + v.co.x *= 0.86 + v.co.y *= 0.86 + + apply_matrix(bm, align_to(ferrule, TAIL_TIP)) + return new_object("Brush", bm, material) + + +def build_eyes(white, black): + objs = [] + for side, sx in (("Left", -1.0), ("Right", 1.0)): + bm = bmesh.new() + icosphere(bm, subdivisions=2, radius=EYE_R) + for v in bm.verts: + v.co += Vector((sx * EYE_X, EYE_Y, EYE_Z)) + objs.append(new_object(f"Eye{side}", bm, white)) + + # The pupil sits on the outward-forward-upper face of the white, poking + # through it — the same trick the drawing uses to read from any angle. + n = Vector((sx * PUPIL_AIM[0], PUPIL_AIM[1], PUPIL_AIM[2])).normalized() + bm = bmesh.new() + icosphere(bm, subdivisions=2, radius=PUPIL_R) + for v in bm.verts: + v.co += Vector((sx * EYE_X, EYE_Y, EYE_Z)) + n * PUPIL_OUT + objs.append(new_object(f"Pupil{side}", bm, black)) + return objs + + +# -------------------------------------------------------------------------- +# Assembly and export +# -------------------------------------------------------------------------- + + +def build(out_dir): + clear_scene() + png = write_gradient_png(os.path.join(out_dir, GRADIENT_PNG)) + + gold = plain_material("Gold", GOLD, roughness=0.42) + purple = plain_material("Purple", PURPLE, roughness=0.38) + white = plain_material("EyeWhite", WHITE, roughness=0.22) + black = plain_material("EyeBlack", BLACK, roughness=0.14) + # Matte rather than glossy: a tight highlight on a faceted dome blows one + # or two facets to white and breaks the ramp exactly where it is meant to + # be read. + shell_mat = gradient_material("Shell", png, roughness=0.52) + + parts = [ + build_shell(shell_mat), + build_body(gold), + build_head(gold), + build_neck(gold), + build_beret(purple), + build_tail(gold), + build_brush(purple), + ] + parts += build_legs(gold) + parts += build_eyes(white, black) + + root = bpy.data.objects.new("Tortoise", None) + bpy.context.collection.objects.link(root) + for p in parts: + p.parent = root + return root, parts + + +def report(parts): + tris = 0 + for p in parts: + for poly in p.data.polygons: + tris += max(0, len(poly.vertices) - 2) + lo = Vector((1e9, 1e9, 1e9)) + hi = Vector((-1e9, -1e9, -1e9)) + for p in parts: + for v in p.data.vertices: + lo = Vector((min(lo[i], v.co[i]) for i in range(3))) + hi = Vector((max(hi[i], v.co[i]) for i in range(3))) + print(f"[tortoise] parts={len(parts)} triangles={tris}") + print(f"[tortoise] bounds x {lo.x:+.3f}..{hi.x:+.3f} (width {hi.x - lo.x:.3f})") + print(f"[tortoise] bounds y {lo.y:+.3f}..{hi.y:+.3f} (length {hi.y - lo.y:.3f})") + print(f"[tortoise] bounds z {lo.z:+.3f}..{hi.z:+.3f} (height {hi.z - lo.z:.3f})") + + +def export_usdz(path): + for obj in bpy.context.scene.objects: + obj.select_set(True) + kwargs = dict( + filepath=path, + selected_objects_only=False, + export_animation=False, + export_materials=True, + export_uvmaps=True, + export_normals=True, + export_lights=False, + export_cameras=False, + generate_preview_surface=True, + export_textures_mode="NEW", + # RealityKit triangulates on load anyway; doing it here means the + # collar's quads are triangulated by Blender, where the result can be + # looked at, rather than at runtime where it cannot. + triangulate_meshes=True, + # Blender is Z-up, USD/RealityKit is Y-up with -Z forward. The + # selections here are the operator's own defaults, written out so the + # convention this asset is authored in is stated rather than assumed. + convert_orientation=True, + export_global_forward_selection="NEGATIVE_Z", + export_global_up_selection="Y", + root_prim_path="/Tortoise", + ) + while True: + try: + bpy.ops.wm.usd_export(**kwargs) + return + except TypeError as exc: + # Drop whichever keyword this Blender does not know and retry, so + # the script survives an API rename rather than failing outright. + message = str(exc).replace('"', "'") + bad = message.split("'") + dropped = next((k for k in list(kwargs) if k in bad), None) + if dropped is None or dropped == "filepath": + raise + print(f"[tortoise] usd_export: dropping unsupported '{dropped}'") + kwargs.pop(dropped) + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + out_dir = os.path.abspath(argv[argv.index("--out") + 1] if "--out" in argv else ".") + os.makedirs(out_dir, exist_ok=True) + + _, parts = build(out_dir) + report(parts) + + blend = os.path.join(out_dir, "tortoise.blend") + bpy.ops.wm.save_as_mainfile(filepath=blend) + usdz = os.path.join(out_dir, "Tortoise.usdz") + export_usdz(usdz) + print(f"[tortoise] wrote {blend}") + print(f"[tortoise] wrote {usdz} ({os.path.getsize(usdz)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/Tools/tortoise-model/qlcheck.swift b/Tools/tortoise-model/qlcheck.swift new file mode 100644 index 0000000..da2aac0 --- /dev/null +++ b/Tools/tortoise-model/qlcheck.swift @@ -0,0 +1,44 @@ +// Ask the system to thumbnail the USDZ, which is the cheapest way to make +// Apple's own USD stack parse and render it end to end. `qlmanage -t` is the +// obvious alternative and tends to hang; QLThumbnailGenerator does not. +import Foundation +import ImageIO +import QuickLookThumbnailing +import UniformTypeIdentifiers + +let args = CommandLine.arguments +guard args.count > 2 else { + FileHandle.standardError.write(Data("usage: qlcheck \n".utf8)) + exit(2) +} +let input = URL(fileURLWithPath: args[1]) +let output = URL(fileURLWithPath: args[2]) + +let request = QLThumbnailGenerator.Request( + fileAt: input, size: CGSize(width: 512, height: 512), scale: 1, + representationTypes: .all) + +let done = DispatchSemaphore(value: 0) +var failure: Error? + +QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, error in + defer { done.signal() } + if let error { failure = error; return } + guard let cg = rep?.cgImage else { return } + guard + let dest = CGImageDestinationCreateWithURL( + output as CFURL, UTType.png.identifier as CFString, 1, nil as CFDictionary?) + else { return } + CGImageDestinationAddImage(dest, cg, nil as CFDictionary?) + CGImageDestinationFinalize(dest) + print("ok \(cg.width)x\(cg.height) -> \(output.path)") +} + +if done.wait(timeout: .now() + 60) == .timedOut { + print("TIMED OUT") + exit(1) +} +if let failure { + print("FAILED: \(failure)") + exit(1) +} diff --git a/Tools/tortoise-model/render_views.py b/Tools/tortoise-model/render_views.py new file mode 100644 index 0000000..6622dcd --- /dev/null +++ b/Tools/tortoise-model/render_views.py @@ -0,0 +1,142 @@ +"""Render the same four orthographic views as the drawing, plus a hero shot. + +Run with: blender --background tortoise.blend --python render_views.py -- --out DIR + +Kept apart from the builder on purpose: judging the shape means rendering it +many times, and there is no reason to re-export a USDZ for each look. +""" + +import math +import os +import sys + +import bpy +from mathutils import Vector + +SIZE = 640 +CENTRE = Vector((0.0, 0.0, 0.19)) # mid-height of the animal +ORTHO = 1.16 # the animal is 1.0 long; a little air around it + +# The head is at +Y, so the camera that *sees* the face stands at +Y looking +# back along -Y. Naming these the other way round is an easy mistake and a +# confusing one, because the renders still look fine — they are just labelled +# with each other's name. +VIEWS = [ + # name, camera location, euler XYZ in degrees, orthographic + ("top", (0.0, 0.0, 4.0), (0.0, 0.0, 0.0), True), + ("side", (-4.0, 0.0, CENTRE.z), (90.0, 0.0, -90.0), True), + ("front", (0.0, 4.0, CENTRE.z), (90.0, 0.0, 180.0), True), + ("rear", (0.0, -4.0, CENTRE.z), (90.0, 0.0, 0.0), True), + ("hero", (-1.05, 1.25, 0.78), None, False), +] + +# Close-ups. The brush is a tenth of the animal long, so at the scale of the +# view sheet it is a dozen pixels and any judgement about it is guesswork. +DETAILS = [ + # name, camera location, target, orthographic scale + ("tail", (-0.55, -0.34, 0.22), (0.0, -0.37, 0.085), 0.26), + ("head", (-0.45, 0.62, 0.42), (0.0, 0.40, 0.245), 0.40), +] + + +def aim(obj, target): + direction = Vector(target) - obj.location + obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def add_light(name, location, energy, size): + data = bpy.data.lights.new(name, type="AREA") + data.energy = energy + data.size = size + obj = bpy.data.objects.new(name, data) + obj.location = location + bpy.context.collection.objects.link(obj) + aim(obj, CENTRE) + return obj + + +def setup_world(): + world = bpy.data.worlds.new("World") + world.use_nodes = True + bg = world.node_tree.nodes["Background"] + bg.inputs["Color"].default_value = (0.55, 0.60, 0.66, 1.0) + bg.inputs["Strength"].default_value = 0.28 + bpy.context.scene.world = world + + +def setup_render(out_dir): + scene = bpy.context.scene + for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"): + try: + scene.render.engine = engine + break + except TypeError: + continue + print(f"[render] engine={scene.render.engine}") + scene.render.resolution_x = SIZE + scene.render.resolution_y = SIZE + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.film_transparent = True + scene.view_settings.view_transform = "Standard" + if hasattr(scene, "eevee"): + for attr, value in (("taa_render_samples", 64), ("use_gtao", True)): + if hasattr(scene.eevee, attr): + setattr(scene.eevee, attr, value) + + +def render_view(name, location, euler, ortho, out_dir, scale=ORTHO, target=None): + data = bpy.data.cameras.new(f"cam_{name}") + data.type = "ORTHO" if ortho else "PERSP" + if ortho: + data.ortho_scale = scale + else: + data.lens = 55 + cam = bpy.data.objects.new(f"cam_{name}", data) + cam.location = location + bpy.context.collection.objects.link(cam) + if euler is None: + aim(cam, target or CENTRE) + else: + cam.rotation_euler = [math.radians(a) for a in euler] + + bpy.context.scene.camera = cam + path = os.path.join(out_dir, f"view_{name}.png") + bpy.context.scene.render.filepath = path + bpy.ops.render.render(write_still=True) + bpy.data.objects.remove(cam, do_unlink=True) + print(f"[render] {path}") + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + out_dir = os.path.abspath(argv[argv.index("--out") + 1] if "--out" in argv else ".") + os.makedirs(out_dir, exist_ok=True) + + setup_world() + setup_render(out_dir) + # Key from the upper front-left, a soft fill opposite, and a rim behind — + # enough separation to read the facets without hiding the gradient. + # + # An area light's power spreads as 1/(4*pi*d^2), so at d ~ 3.5 the visible + # radiance is roughly P/490 for these albedos: past ~500W everything clips + # to white and the shell's ramp disappears along with it. + add_light("Key", (-2.0, 2.4, 2.8), 300.0, 3.0) + add_light("Fill", (2.6, 1.4, 1.0), 90.0, 4.0) + add_light("Rim", (0.8, -3.0, 2.2), 100.0, 2.5) + + # --only tail,head renders a subset. A full sheet is seven renders and + # about seven minutes; iterating on one part should not cost that. + wanted = set(argv[argv.index("--only") + 1].split(",")) if "--only" in argv else None + + for name, loc, euler, ortho in VIEWS: + if wanted is None or name in wanted: + render_view(name, loc, euler, ortho, out_dir) + for name, loc, target, scale in DETAILS: + if wanted is None or name in wanted: + render_view(name, loc, None, True, out_dir, scale=scale, target=target) + + +if __name__ == "__main__": + main() diff --git a/Tools/tortoise-model/sheet.py b/Tools/tortoise-model/sheet.py new file mode 100644 index 0000000..ce23f42 --- /dev/null +++ b/Tools/tortoise-model/sheet.py @@ -0,0 +1,64 @@ +"""Composite the rendered views into one sheet laid out like the drawing.""" + +import os +import sys + +from PIL import Image, ImageDraw, ImageFont + +OUT = sys.argv[1] if len(sys.argv) > 1 else "." +CELL = 460 +PAD = 18 +LABEL = 26 +BG = (246, 246, 248) + + +def load(name): + img = Image.open(os.path.join(OUT, f"view_{name}.png")).convert("RGBA") + return img.resize((CELL, CELL), Image.LANCZOS) + + +def font(size): + for path in ( + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/SFNS.ttf", + ): + if os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except OSError: + pass + return ImageFont.load_default() + + +names = ["top", "side", "front", "rear", "hero", "tail", "head"] +titles = { + "top": "TOP", + "side": "SIDE (Left)", + "front": "FRONT", + "rear": "REAR", + "hero": "3/4", + "tail": "TAIL (close)", + "head": "HEAD (close)", +} + +cols = 4 +rows = (len(names) + cols - 1) // cols +W = PAD + cols * (CELL + PAD) +H = PAD + rows * (CELL + LABEL + PAD) +sheet = Image.new("RGB", (W, H), BG) +draw = ImageDraw.Draw(sheet) +f = font(20) + +for i, name in enumerate(names): + cx = PAD + (i % cols) * (CELL + PAD) + cy = PAD + (i // cols) * (CELL + LABEL + PAD) + cell = Image.new("RGB", (CELL, CELL), (236, 238, 242)) + img = load(name) + cell.paste(img, (0, 0), img) + sheet.paste(cell, (cx, cy)) + draw.rectangle([cx, cy, cx + CELL - 1, cy + CELL - 1], outline=(200, 202, 208)) + draw.text((cx + 4, cy + CELL + 4), titles[name], fill=(40, 40, 46), font=f) + +path = os.path.join(OUT, "sheet.png") +sheet.save(path) +print(path) From c5b438897744a516258de4f51e8d3740c4885d45 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Mon, 17 Aug 2026 22:37:30 +0900 Subject: [PATCH 24/33] Stand the tortoise on the table (#53 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Models/RunnerModel.swift | 28 ++- App/Views/ContentView.swift | 4 +- App/Views/TableCanvas.swift | 187 +++++++++++++++++- CLAUDE.md | 38 ++++ TortoiseBlocks.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- TortoiseBlocksKit/Package.resolved | 6 +- TortoiseBlocksKit/Package.swift | 2 +- 8 files changed, 250 insertions(+), 21 deletions(-) diff --git a/App/Models/RunnerModel.swift b/App/Models/RunnerModel.swift index 5807da0..5051360 100644 --- a/App/Models/RunnerModel.swift +++ b/App/Models/RunnerModel.swift @@ -22,6 +22,16 @@ final class RunnerModel { /// exports render exactly what is on screen. private(set) var lastRunCommands: [TortoiseCommand] = [] + /// Bounding box of the last run's visible output, or nil when it drew + /// nothing. `nil` on an empty stream, exactly as `DrawingBounds` reports it. + /// + /// Computed once per run rather than per use, because both users want it + /// repeatedly and neither can afford the replay: the export sizes its + /// frame from it (once per export, previously replaying the stream again), + /// and the visionOS table maps the 3-D tortoise's position through the + /// same `autoFit` transform the canvas uses — *every display frame*. + private(set) var drawingBounds: DrawingBounds? + /// Set when expansion fails; drives a kid-friendly alert. var showsExpansionError = false @@ -114,6 +124,8 @@ final class RunnerModel { let expanded = try BlockExpander.expand(blocks) expandedBlockIDs = expanded.map(\.blockID) lastRunCommands = expanded.map(\.command) + drawingBounds = DrawingBounds.compute( + from: CommandPlayer.play(commands: lastRunCommands)) lastRunTreeHash = blocks.hashValue player.isPaused = startPaused tortoise.reset() @@ -182,7 +194,8 @@ final class RunnerModel { func pngData(scale: CGFloat = 2) -> Data? { if let cached = pngDataCache[scale] { return cached } guard canExport else { return nil } - let data = Self.renderPNG(lastRunCommands, longSide: 512, scale: scale) + let data = Self.renderPNG( + lastRunCommands, bounds: drawingBounds, longSide: 512, scale: scale) pngDataCache[scale] = data return data } @@ -209,7 +222,8 @@ final class RunnerModel { /// putting the picture on the file in the first place. func thumbnailData() -> Data? { guard canExport else { return nil } - return Self.renderPNG(lastRunCommands, longSide: 256, scale: 1, onWhite: true) + return Self.renderPNG( + lastRunCommands, bounds: drawingBounds, longSide: 256, scale: 1, onWhite: true) } /// Renders a command stream to PNG, cropped tight to the drawing and @@ -228,14 +242,15 @@ final class RunnerModel { /// exports transparent, because an export is a picture you place somewhere /// yourself, and the thumbnail white, because Finder places that one. private static func renderPNG( - _ commands: [TortoiseCommand], longSide: Double, scale: CGFloat, onWhite: Bool = false + _ commands: [TortoiseCommand], bounds: DrawingBounds?, longSide: Double, scale: CGFloat, + onWhite: Bool = false ) -> Data? { let export = Tortoise() export.speed = 0 export.backgroundColor = onWhite ? .white : .clear export.apply(commands) export.hideTortoise() - let size = exportFrameSize(for: commands, longSide: longSide) + let size = exportFrameSize(for: bounds, longSide: longSide) let renderer = ImageRenderer( content: TortoiseCanvas(export) .frame(width: size.width, height: size.height) @@ -255,11 +270,10 @@ final class RunnerModel { /// an unusable sliver; an empty drawing (no bounds) falls back to a square, /// mirroring SVG's own "no visible output" fallback. private static func exportFrameSize( - for commands: [TortoiseCommand], longSide long: Double + for bounds: DrawingBounds?, longSide long: Double ) -> CGSize { let square = CGSize(width: long, height: long) - guard let bounds = DrawingBounds.compute(from: CommandPlayer.play(commands: commands)) - else { return square } + guard let bounds else { return square } // Mirrors TortoiseUI's autoFit inset — the tortoise sprite's // half-extent × tortoiseScaleMax — so the drawing fills the frame with // a uniform margin instead of a lopsided one. 20 is the *triangle's* diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index abd55e2..f9645ce 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -100,9 +100,7 @@ struct CanvasPane: View { /// the triangle. The asset has to point *up*: `.image` rotates its top edge /// toward the heading. Deliberately not applied to the PNG export's canvas /// — see `RunnerModel.exportFrameSize`. - // Not private only so the #53 spike can put the same tortoise on the - // table; make it private again when `TableSpike.swift` goes. - static let sprite = TortoiseSprite.image( + private static let sprite = TortoiseSprite.image( Image(.tortoiseSprite), size: CGSize(width: 23, height: 32)) /// The paper's outline: the same 8 a standalone block row rounds diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift index 7227057..13be132 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/TableCanvas.swift @@ -31,6 +31,7 @@ final class ViewerModel { static let spaceID = "table" static let programWindowID = "program" + static let codeWindowID = "code" // MARK: What is loaded @@ -40,6 +41,16 @@ private(set) var blocks: [Block] = [] let runner = RunnerModel() + /// The generated Swift, for the code window (#53 Phase 3). + /// + /// Built here, once per load, rather than in the window's `body` the + /// way `CanvasPane` does it. The iPad's code pane is only in the + /// hierarchy while the toggle says so, so generating there costs + /// nothing when it is hidden; a window is its own scene and redraws on + /// its own schedule, and the program behind it cannot change while it + /// is open — nothing here can edit. + private(set) var code = "" + /// Set when opening a file fails, and shown as an alert. Kept as the /// message rather than the error so the version gate's wording /// (`DocumentError.newerSchema`) survives to the alert unchanged. @@ -50,6 +61,7 @@ func load(_ blocks: [Block], title: String) { self.blocks = blocks self.title = title + code = SwiftCodeGenerator.code(for: blocks) runner.run(blocks, startPaused: true) } @@ -301,8 +313,23 @@ /// what makes "60cm" mean 60cm instead of an arbitrary scale factor. @PhysicalMetric(from: .meters) private var pointsPerMeter: CGFloat = 1 + /// Holds the per-frame subscription alive. A `RealityView`'s `make` + /// closure runs once, and an `EventSubscription` that nothing retains + /// is cancelled the moment it goes out of scope — which reads exactly + /// like the handler never being called. + @State private var frameTicker = FrameTicker() + private static let sheetName = "table-canvas" + /// White paper around the drawing, in points at ``ViewerModel/builtSide``. + /// + /// It replaces the inset `autoFit` used to add for the sprite, which a + /// `.hidden` sprite no longer earns. Sized for the *tortoise* rather + /// than for looks: at `tortoiseLength` it is about half the animal, so + /// a tortoise standing at the far corner of its drawing still has all + /// four feet on the paper. + static let sheetMargin: CGFloat = 64 + var body: some View { // Read here, in `body`, and captured by the closure below. // @@ -347,9 +374,31 @@ // the providers directly is what buys "in front of your eyes", // "turned to face you", and a wait the window can explain. content.add(sheet) + + // The tortoise, as a child of the sheet — so it inherits the + // pinch, the twist and the drag for free, and its own + // transform only ever has to say where on the *paper* it is. + // + // A failure here costs the tortoise and nothing else: the + // drawing still appears and still plays. An immersive space + // that shows no picture because a model would not load would + // be a much worse trade than a picture that draws itself. + if let tortoise = await Self.loadTortoise() { + sheet.addChild(tortoise) + // Per *display frame*, not per view update. The two differ + // by two orders of magnitude here and the difference is the + // feature: `currentCommandIndex` changes about ten times a + // second, and a tortoise moved on that schedule jumps from + // command to command while the line it is drawing grows + // smoothly underneath it. + frameTicker.subscription = content.subscribe( + to: SceneEvents.Update.self, on: nil + ) { _ in + Self.walk(tortoise, with: model.runner, pointsPerMeter: pointsPerMeter) + } + } } update: { content in guard let entity = Self.sheet(in: content) else { return } - print() // Nothing to look at until there is somewhere to put it — // better a considered wait than a sheet parked wherever the // origin happens to be. @@ -587,20 +636,150 @@ } return nil } + + // MARK: - The tortoise on the paper + + /// How long the tortoise is, as a fraction of the sheet's side — so it + /// keeps its size relative to the drawing however big the sheet is + /// pinched, and needs no rescaling of its own. + /// + /// A twelfth is deliberately larger than the 2-D sprite ever was + /// (23×32pt of a 1360pt sheet is nearer a thirtieth). On a screen the + /// tortoise is a *cursor* — the thing you track to see where the line + /// is going. On the table it is the animal, and the animal is the whole + /// argument for this platform existing (#53): a tortoise that walks + /// across your desk drawing a line is what tortoise graphics was before + /// there were screens. At 60cm — the default sheet — this is 5cm. + private static let tortoiseLength: Float = 1.0 / 12.0 + + /// Clear of the paper by a hair, so nothing the model owns is ever + /// exactly coplanar with the attachment's white ground — which + /// z-fights along the whole underside of a tortoise that lands on + /// zero. A tenth of a millimetre at the default sheet size, which is + /// far below anything the eye resolves and far above the depth + /// buffer's tie. + private static let hover: Float = 0.0002 + + /// Loads the generated tortoise (`Tools/tortoise-model/`), scaled to + /// `tortoiseLength` and stood upright on the paper. + /// + /// The model is authored to a contract this depends on: Y-up with + /// **forward at −Z**, total length exactly 1.0 so a scale is a length, + /// and its origin at the ground point under the shell's centre — the + /// point it turns about. Wrapped in a plain container entity so the + /// standing-up rotation lives here, once, and `walk` only ever has to + /// say where on the paper and which way round. + private static func loadTortoise() async -> Entity? { + guard let model = try? await Entity(named: "Tortoise", in: .main) else { return nil } + // +90° about X: the model's own up (+Y) becomes the sheet's +Z, + // which is up off the table, and its forward (−Z) becomes the + // sheet's +Y — the drawing's north, which is heading 0. + model.orientation = simd_quatf(angle: .pi / 2, axis: [1, 0, 0]) + model.scale = .init(repeating: tortoiseLength) + let carrier = Entity() + carrier.addChild(model) + // Stand it *on* the paper rather than at its own origin. The origin + // is the ground point under the shell's centre — the point the + // animal turns about — and the feet reach a little below it (6‰ of + // the body length, measured off the loaded model rather than + // assumed, so a re-generated tortoise cannot quietly start sinking + // into the page). + model.position.z = -model.visualBounds(relativeTo: carrier).min.z + hover + return carrier + } + + /// Puts the tortoise where the canvas would have drawn its sprite. + /// + /// Called once per display frame, which is what + /// `currentTortoiseState` is for: it interpolates *between* commands, + /// so the tortoise walks the line as it is drawn. Driving this from + /// `currentCommandIndex` instead — the value everything else in the app + /// watches — would teleport it once per command while the line grew + /// smoothly underneath. + @MainActor + private static func walk( + _ tortoise: Entity, with runner: RunnerModel, pointsPerMeter: CGFloat + ) { + guard let state = runner.player.currentTortoiseState, state.isVisible else { + tortoise.isEnabled = false + return + } + tortoise.isEnabled = true + + // The same transform the canvas laid the drawing out with, asked + // for rather than reimplemented — `autoFit` centres on the drawing + // and scales to fill, and getting that subtly wrong would put the + // tortoise beside its own line rather than on it. + let side = ViewerModel.builtSide * pointsPerMeter + let inner = side - 2 * sheetMargin + let transform = ViewportMode.autoFit.transform( + canvasSize: runner.tortoise.canvasSize, + viewSize: CGSize(width: inner, height: inner), + drawingBounds: runner.drawingBounds, + spriteHalfExtent: TortoiseSprite.hidden.halfExtent) + let point = CGPoint(x: state.position.x, y: state.position.y).applying(transform) + + // View points (top-left origin, Y down) to the sheet entity's own + // space (centre origin, Y up, 1 unit = 1 metre). The canvas is + // centred in the attachment, so the margin cancels and only the + // inner size matters. + // Z is the loader's business, not this function's: the model was + // lifted onto the paper once, and every frame after that is a slide + // across it. + tortoise.position = [ + Float((point.x - inner / 2) / pointsPerMeter), + Float((inner / 2 - point.y) / pointsPerMeter), + 0, + ] + // Heading is clockwise from north; a turn about the sheet's +Z, + // which points up out of the paper, is counter-clockwise seen from + // above. Hence the sign. + tortoise.orientation = simd_quatf( + angle: -Float(state.heading * .pi / 180), axis: [0, 0, 1]) + } } - /// What actually lies on the table: the app's own canvas, unchanged. + /// Owns the scene-update subscription for as long as the view is on + /// screen. A `final class` in `@State` rather than the subscription in + /// `@State` directly, because `make` runs before the first update and + /// assigning state from there would be a write during view construction. + @MainActor + private final class FrameTicker { + var subscription: EventSubscription? + } + + /// What actually lies on the table: the app's own canvas, drawing + /// everything **except** the tortoise. + /// + /// `.hidden` (TortoiseGraphics2 2.1.0) is the whole difference from the + /// iPad's pane. The tortoise here is not a picture on the paper, it is a + /// model standing on it, so the canvas has to stop drawing its own — and + /// this is the way to say that, rather than `hideTortoise()`, which + /// records a *command* and would travel into the SVG export, the PNG, the + /// thumbnail and the saved stream. private struct TableCanvasSheet: View { let runner: RunnerModel let side: CGFloat var body: some View { TortoiseCanvas(runner.tortoise, player: runner.player) - .tortoiseSprite(CanvasPane.sprite) + .tortoiseSprite(.hidden) + // The margin the sprite used to buy. `autoFit` insets by the + // sprite's half-diagonal so it can't clip at the edge, and a + // hidden sprite has no extent — so without this the drawing + // runs edge to edge, and the tortoise standing at its far + // corner hangs off the paper. Inner frame first, then the + // paper around it: that keeps the drawing centred on the + // sheet, which is what lets the mapping below be a single + // scale about the middle. + .frame( + width: side - 2 * TableCanvasSpace.sheetMargin, + height: side - 2 * TableCanvasSpace.sheetMargin + ) // Paper, for the same reason the pane paints it: the default // pen is black and a table is not white. - .background(.white) .frame(width: side, height: side) + .background(.white) // The pinch, twist and drag belong to the entity. Left // hit-testable, this view swallows them first and the sheet can // never be moved at all. diff --git a/CLAUDE.md b/CLAUDE.md index 3399a11..105d621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -438,6 +438,44 @@ built bundle, the only way that works — see the nested-CLAUDE.md note above). Blender rendering it proves nothing about RealityKit; `qlcheck.swift` in the same directory runs it through Apple's own USD stack instead. +**The tortoise on the table is drawn by us, and that took a library release** +(#53 Phase 3, TortoiseGraphics2 2.1.0). The sheet is still the app's own +`TortoiseCanvas` in a `ViewAttachmentComponent`; what changed is that it now +draws everything *except* the tortoise (`.tortoiseSprite(.hidden)`), and the +USDZ stands on the paper as a child of the sheet entity — so the pinch, twist +and drag it inherits for free, and its own transform only ever says where on +the page it is. Three upstream additions were needed and none of them 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. +`TortoisePlayer.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; a reimplementation agrees on the day it +is written and drifts silently after. It is read once per *display frame*, from +a `SceneEvents.Update` subscription — not from `body`, which would re-evaluate +the view at the refresh rate — and the subscription has to be retained +(`FrameTicker`), because one that nothing holds is cancelled at the end of +`make` and looks exactly like a handler that is never called. +Two numbers are judged on device and are the first things to change if it looks +wrong: the tortoise is `1/12` of the sheet's side (deliberately larger than the +2-D sprite's ~1/30 — on a screen it is a cursor, on a table it is the animal), +and the paper keeps a 64pt margin, since a hidden sprite earns no `autoFit` +inset and the drawing would otherwise run to the paper's edge with the tortoise +hanging off it. The lift onto the paper is *measured* from the loaded model, +not assumed: the feet reach ~6‰ of the body length below the origin, which is +the ground point under the shell's centre. +**The visionOS simulator cannot check any of this.** It does not host +`ViewAttachmentComponent` views at all — the sheet's own `.task` never runs, so +`TortoisePlayer` never attaches to a canvas and `currentTortoiseState` stays +nil, which reads exactly like a broken tortoise. What the simulator *is* good +for is the two things that would otherwise be guesses: that the USDZ loads in +the real visionOS runtime with the bounds the contract promises, and that the +per-frame subscription fires. Everything else is the headset. + **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. **Localization**: `en` is the source language; Japanese (kid-friendly diff --git a/TortoiseBlocks.xcodeproj/project.pbxproj b/TortoiseBlocks.xcodeproj/project.pbxproj index 97a938a..c8e4339 100644 --- a/TortoiseBlocks.xcodeproj/project.pbxproj +++ b/TortoiseBlocks.xcodeproj/project.pbxproj @@ -559,7 +559,7 @@ repositoryURL = "https://github.com/temoki/TortoiseGraphics2"; requirement = { kind = exactVersion; - version = 2.0.0; + version = 2.1.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TortoiseBlocks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TortoiseBlocks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a8e5129..ef7ad8d 100644 --- a/TortoiseBlocks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TortoiseBlocks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/temoki/TortoiseGraphics2", "state" : { - "revision" : "8304101e5cdc8d717963ec1ac8ba143b2b6553d9", - "version" : "2.0.0" + "revision" : "83941fe71e20d39941f72d7f38d16d82308e3635", + "version" : "2.1.0" } } ], diff --git a/TortoiseBlocksKit/Package.resolved b/TortoiseBlocksKit/Package.resolved index 0897ec6..6e89044 100644 --- a/TortoiseBlocksKit/Package.resolved +++ b/TortoiseBlocksKit/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "c2d79e7964c910d5a59486e3f4d8fe4f42f923aabb5cfdeb8dffdf326080a937", + "originHash" : "6f793ae5ce9d59bfe288dda5c0a74b4c288b2bae63418b026cf2f644effa56e9", "pins" : [ { "identity" : "tortoisegraphics2", "kind" : "remoteSourceControl", "location" : "https://github.com/temoki/TortoiseGraphics2", "state" : { - "revision" : "8304101e5cdc8d717963ec1ac8ba143b2b6553d9", - "version" : "2.0.0" + "revision" : "83941fe71e20d39941f72d7f38d16d82308e3635", + "version" : "2.1.0" } } ], diff --git a/TortoiseBlocksKit/Package.swift b/TortoiseBlocksKit/Package.swift index 275c648..a5d4ca9 100644 --- a/TortoiseBlocksKit/Package.swift +++ b/TortoiseBlocksKit/Package.swift @@ -12,7 +12,7 @@ let package = Package( .library(name: "TortoiseBlocksKit", targets: ["TortoiseBlocksKit"]) ], dependencies: [ - .package(url: "https://github.com/temoki/TortoiseGraphics2", exact: "2.0.0") + .package(url: "https://github.com/temoki/TortoiseGraphics2", exact: "2.1.0") ], targets: [ .target( From 4ef38123f5cb57a90f78250d38b2919f60852ec8 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Mon, 17 Aug 2026 22:37:43 +0900 Subject: [PATCH 25/33] Give the viewer a code window, and a way out (#53 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Localizable.xcstrings | 10 +++++++ App/TortoiseBlocksApp.swift | 11 ++++++++ App/Views/CodeWindow.swift | 41 +++++++++++++++++++++++++++++ App/Views/ViewerWindow.swift | 51 ++++++++++++++++++++++++++++++++---- CLAUDE.md | 15 +++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 App/Views/CodeWindow.swift diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index ce247a0..cf7b9b9 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -1174,6 +1174,16 @@ } } }, + "Show Code" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コードを みる" + } + } + } + }, "Tap a palette block, or drag one here" : { "localizations" : { "ja" : { diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index 8ac4413..8872ab9 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -39,6 +39,17 @@ struct TortoiseBlocksApp: App { } .defaultSize(width: 480, height: 700) + // And the code on a third (#53 Phase 3). Same reasoning 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. + WindowGroup(id: ViewerModel.codeWindowID) { + CodeWindow(model: viewer) + } + // Wider than the program's 480: source lines are longer than block + // rows, and the pane scrolls horizontally rather than wrapping. + .defaultSize(width: 620, height: 700) + ImmersiveSpace(id: ViewerModel.spaceID) { TableCanvasSpace(model: viewer) } diff --git a/App/Views/CodeWindow.swift b/App/Views/CodeWindow.swift new file mode 100644 index 0000000..b21e669 --- /dev/null +++ b/App/Views/CodeWindow.swift @@ -0,0 +1,41 @@ +#if os(visionOS) + + import SwiftUI + + /// The generated Swift, on a surface of its own (#53 Phase 3). + /// + /// The third surface, and the one that makes the platform's whole argument + /// concrete: the drawing on the table, the blocks that make it, and the + /// code they stand for, **all three at once**. On iPad and Mac the canvas + /// and the code are the two states of one toggle, because a window has + /// room for one of them; a headset has as many surfaces as you like, so the + /// choice never has to be made. That is the same reason the program got a + /// window rather than a pane. + /// + /// It is `CodePane`, unchanged — the app's own paper, the same + /// `CodeTokenizer` colouring, the same copy button. The pane was already + /// made to work here: #11 took it off `.background.secondary`, which + /// resolves to translucent glass on this platform and left the syntax + /// colours with nothing to stand on. + struct CodeWindow: View { + let model: ViewerModel + + var body: some View { + // An `if`, where `ProgramWindow` puts the empty state in an + // `.overlay`. Deliberate, and the difference is what is behind it: + // an empty block list is nothing, while an empty code pane is a + // sheet of white paper carrying a "Copy Code" button that would + // copy an empty string. + if model.hasProgram { + CodePane(code: model.code) + .padding() + } + else { + ContentUnavailableView( + "No Drawing", systemImage: "curlybraces", + description: Text("Open a drawing, or start from a sample.")) + } + } + } + +#endif diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index b5cc492..844c74b 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -2,6 +2,7 @@ import SwiftUI import TortoiseBlocksKit + import UniformTypeIdentifiers /// The visionOS viewer's one window (#53) — **controls only**. /// @@ -25,11 +26,17 @@ @Environment(\.openWindow) private var openWindow @State private var showsImporter = false + // One presentation state for both formats, for the reason `CanvasPane` + // has one: two `fileExporter`s on the same view and the later silently + // swallows the earlier. The `fileImporter` below is a different kind of + // modifier and does not collide with it. + @State private var exportFile: ExportFile? + @State private var exportType: UTType = .png var body: some View { @Bindable var model = model VStack(spacing: 24) { - DrawingChooser(model: model, showsImporter: $showsImporter) + DrawingChooser(model: model, showsImporter: $showsImporter, onExport: export) if model.hasProgram { PlaybackControls( @@ -69,6 +76,14 @@ } message: { Text(model.openFailure ?? "") } + .fileExporter( + isPresented: Binding( + get: { exportFile != nil }, set: { if !$0 { exportFile = nil } }), + document: exportFile, contentType: exportType, + defaultFilename: String(localized: "Drawing") + ) { _ in + exportFile = nil + } .task { // Development only: the simulator cannot press any of these // buttons (simctl sends no input), so `-TBPlace YES` loads a @@ -93,6 +108,12 @@ model.isPlaced = true } } + + private func export(_ data: Data?, as type: UTType) { + guard let data else { return } + exportType = type + exportFile = ExportFile(data: data) + } } /// Which drawing is on the table: a file, or one of the four samples the @@ -104,9 +125,16 @@ /// until something is AirDropped to it. `SampleBlocks` is already public /// and already what 「みほん」 uses, so this needs no bundled resources and /// no second set of names. + /// + /// It carries the way *out* as well (#53 Phase 3). Export sits beside Open + /// and Samples rather than with the placement controls because those two + /// rows answer different questions: this one is what picture is loaded and + /// what you can take away with you, the other is where it lies on the + /// table. private struct DrawingChooser: View { let model: ViewerModel @Binding var showsImporter: Bool + let onExport: (Data?, UTType) -> Void var body: some View { VStack(spacing: 12) { @@ -134,6 +162,14 @@ } label: { Label("Samples", systemImage: "sparkles") } + // The same menu the iPad and Mac canvas toolbar carries — + // SVG, PNG at three scales, and a ShareLink for each. It + // renders `RunnerModel.lastRunCommands`, which is the + // stream the table is drawing from, so what comes out is + // the picture on the table down to the rolled dice (#25); + // moving the drawing into an immersive space changed + // nothing about that, which is why this costs one view. + CanvasExportMenu(runner: model.runner, onExport: onExport) } .buttonStyle(.bordered) } @@ -190,9 +226,11 @@ var body: some View { @Bindable var model = model VStack(spacing: 14) { - // The two places a drawing can be shown, side by side: on the - // table, and as the program that draws it. Neither replaces the - // other — having both open at once is the point (#53). + // The three places one drawing can be shown, side by side: on + // the table, as the program that draws it, and as the code that + // program stands for. None of them replaces another — having + // all three open at once is the point (#53), and is the one + // thing the iPad build cannot do. HStack(spacing: 12) { Button( model.isPlaced ? "Put Away" : "Place on Table", @@ -205,8 +243,11 @@ Button("Show Blocks", systemImage: "square.stack.3d.up") { openWindow(id: ViewerModel.programWindowID) } - .buttonStyle(.bordered) + Button("Show Code", systemImage: "curlybraces") { + openWindow(id: ViewerModel.codeWindowID) + } } + .buttonStyle(.bordered) // Size, spin and position are gestures on the sheet itself — // there is nothing here for them. What is left is the choice a diff --git a/CLAUDE.md b/CLAUDE.md index 105d621..57e901b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -476,6 +476,21 @@ for is the two things that would otherwise be guesses: that the USDZ loads in the real visionOS runtime with the bounds the contract promises, and that the per-frame subscription fires. Everything else is the headset. +**The viewer has three surfaces, and the third is the code** (#53 Phase 3). +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 code 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. 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. + **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. **Localization**: `en` is the source language; Japanese (kid-friendly From 4400369fc4c03bd20d82d7891a5cf0c05b3f8dad Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Mon, 17 Aug 2026 22:37:57 +0900 Subject: [PATCH 26/33] Stop the room draining the tortoise's colours (#53 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Resources/Tortoise.usdz | Bin 106014 -> 107080 bytes CLAUDE.md | 8 +++ Tools/tortoise-model/README.md | 36 ++++++++++- Tools/tortoise-model/build_tortoise.py | 86 ++++++++++++++++++++++--- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/App/Resources/Tortoise.usdz b/App/Resources/Tortoise.usdz index 46a67fc3626eddeb9b57b923b824ac84b58326b6..e0f739b1fa663aebce38ffb00d89393d162ffc1e 100644 GIT binary patch delta 4181 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 Cpb%;R diff --git a/CLAUDE.md b/CLAUDE.md index 57e901b..48edc84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -437,6 +437,14 @@ resource, landing flat at `Contents/Resources/Tortoise.usdz` (verified in the built bundle, the only way that works — see the nested-CLAUDE.md note above). Blender rendering it proves nothing about RealityKit; `qlcheck.swift` in the same directory runs it through Apple's own USD stack instead. +A fifth thing is a contract with the *room* rather than with app code: **every +material emits a third of its own colour**, because a `.mixed` immersive space +lights the model with the real room and a lamp-lit evening one drained the +pastels to mud (measured: luminance 39 of 255, gold reading brown — 111 with +emission, and the facets still step). Do not "fix" it as a PBR error, and do +not lighten the colours instead: those are sampled from the drawing, which is +the specification. The reasoning and the shell's second texture are in the +tool README. **The tortoise on the table is drawn by us, and that took a library release** (#53 Phase 3, TortoiseGraphics2 2.1.0). The sheet is still the app's own diff --git a/Tools/tortoise-model/README.md b/Tools/tortoise-model/README.md index 4128e88..d29be08 100644 --- a/Tools/tortoise-model/README.md +++ b/Tools/tortoise-model/README.md @@ -82,16 +82,46 @@ the app and not just to the asset. strange, and that trade was decided in favour of ordinary turning. - Bounds, for framing: `x ±0.309`, `y 0 .. 0.386` (height), `z -0.543` (nose) `.. +0.457` (brush). The brush tip is at `(0, 0.066, 0.457)`. -- Fifteen named meshes under one `Tortoise` xform, five materials, one - 915-byte texture; about 2,200 triangles. +- Fifteen named meshes under one `Tortoise` xform, five materials, two small + textures (the shell's ramp, and the same ramp dimmed for emission); about + 2,200 triangles. +- **Every material emits a third of its own colour** (`EMISSION`). Not + decoration — see below. -## Two things that look like mistakes and are not +## Three things that look like mistakes and are not **The shell's ramp is squared** (`SHELL_RAMP_BIAS`). It runs blue at the rim to pink at the apex, by height — but the view that matters is from above, and a dome seen from above shows only `v²` of its projected area below height `v`. Mapped straight, three quarters of the top view comes out pink. +**Every material has an `emissiveColor`, and the shell has a second texture +for it.** A tortoise is not a lamp, so this looks like a mistake in the PBR +setup, and removing it is a one-line change that will look correct and undo +the reason it is here. + +In a `.mixed` immersive space RealityKit lights the model with the **real +room**. Measured against the shipped no-emission build under a lamp-lit +evening room, the pastels came out at 39 of 255 in luminance — the gold read +brown, the blue-to-pink dome read muddy teal — which is the state the +maintainer reported from the headset. At `EMISSION = 0.35` the same render is +111, a factor of 2.8, and the facets still step (all of it, and the tortoise +flattens into a sticker). Under QuickLook's bright studio lighting the same +change is only +12%, which is the point: emission is a floor that a dark room +cannot take away, not a brightness knob. + +Lightening the colours instead was the alternative and is wrong: they are +sampled from the three-view drawing, which is the specification. Emission +leaves the design alone and lets it read at its own value in any room. + +The shell needs a *second* PNG because `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, and the ratio is checked at +three points along the ramp (0.347–0.354 against the flat materials' 0.35). + **The brush tip is uneven.** Alternate hairs stop a third of the bundle short. Three level-tipped versions were built after this one — radial fluting, a bellied profile, and fourteen separately modelled strands — and the diff --git a/Tools/tortoise-model/build_tortoise.py b/Tools/tortoise-model/build_tortoise.py index 9815e4a..3bcf859 100644 --- a/Tools/tortoise-model/build_tortoise.py +++ b/Tools/tortoise-model/build_tortoise.py @@ -161,7 +161,26 @@ WHITE = (0.980, 0.980, 0.980) BLACK = (0.055, 0.055, 0.060) +# How much of its own colour each material gives off, on top of what the room +# lends it. +# +# **This is not decoration, it is what makes the colours survive the room.** In +# a `.mixed` immersive space RealityKit lights the model with the *real* room, +# so a lamp-lit living room in the evening dims every one of these and tints +# what is left — and the colours above are sampled from the three-view drawing, +# which is the whole specification. Lightening them instead would move the +# design to suit one room; emission leaves the design alone and lets it read at +# its own value in any room. +# +# 0.35 rather than more: the shading is what makes a low-poly dome look faceted, +# and emission is flat by definition, so all of it and the tortoise turns into a +# sticker. At this level the facets still step and the pastels stop going grey. +# Judged on the headset, in a room — a render cannot tell you this, because the +# renderer's lights are not the room's. +EMISSION = 0.35 + GRADIENT_PNG = "shell_gradient.png" +EMISSION_PNG = "shell_gradient_emission.png" # -------------------------------------------------------------------------- @@ -173,11 +192,17 @@ def clear_scene(): bpy.ops.wm.read_factory_settings(use_empty=True) -def srgb_to_linear(c): - def one(u): - return u / 12.92 if u <= 0.04045 else ((u + 0.055) / 1.055) ** 2.4 +def srgb_to_linear_one(u): + return u / 12.92 if u <= 0.04045 else ((u + 0.055) / 1.055) ** 2.4 + + +def linear_to_srgb(u): + u = min(max(u, 0.0), 1.0) + return 12.92 * u if u <= 0.0031308 else 1.055 * u ** (1 / 2.4) - 0.055 - return tuple(one(x) for x in c) + +def srgb_to_linear(c): + return tuple(srgb_to_linear_one(x) for x in c) def plain_material(name, colour, roughness=0.45): @@ -190,11 +215,15 @@ def plain_material(name, colour, roughness=0.45): bsdf.inputs["Base Color"].default_value = (*lin, 1.0) bsdf.inputs["Roughness"].default_value = roughness bsdf.inputs["Metallic"].default_value = 0.0 + # Its own colour, not white: emission at white would wash every part toward + # grey and take the pastels with it, which is the problem, not the fix. + bsdf.inputs["Emission Color"].default_value = (*lin, 1.0) + bsdf.inputs["Emission Strength"].default_value = EMISSION mat.diffuse_color = (*lin, 1.0) return mat -def gradient_material(name, png_path, roughness=0.35): +def gradient_material(name, png_path, emission_png_path, roughness=0.35): """The shell's blue-to-pink ramp, as an image texture. A texture rather than vertex colours on purpose: `primvars:displayColor` @@ -214,16 +243,45 @@ def gradient_material(name, png_path, roughness=0.35): tex.interpolation = "Closest" tex.location = (-320, 260) tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) + + # The same ramp again, pre-dimmed — see `write_gradient_png`. A *ramp* and + # not one flat emissive tint, because a single colour here would drag the + # blue rim and the pink apex toward each other and flatten the gradient the + # dome exists to show. + emis = tree.nodes.new("ShaderNodeTexImage") + emis.image = bpy.data.images.load(emission_png_path) + emis.image.colorspace_settings.name = "sRGB" + emis.interpolation = "Closest" + emis.location = (-320, -80) + tree.links.new(emis.outputs["Color"], bsdf.inputs["Emission Color"]) + # 1.0: the dimming is in the image, so a strength here would apply it twice. + bsdf.inputs["Emission Strength"].default_value = 1.0 return mat -def write_gradient_png(path): +def write_gradient_png(path, scale=1.0): """A 8x256 strip: blue at the bottom (v=0, the rim), pink at the top. Written by hand out of `zlib` and `struct` because Blender ships its own Python and it has no Pillow. A PNG is a signature plus three chunks, and the whole image is 2KB — pulling a dependency into the build for that would cost more than the twenty lines. + + `scale` dims the whole ramp, and exists because the shell needs a *second* + copy of it for emission. Everything else can say "my own colour times + EMISSION" in one shader input, but UsdPreviewSurface has no emissive + strength — only `emissiveColor` — so feeding the diffuse texture straight + into it makes the shell emit at full value while every other part emits at + a third, and the dome washes out. A Blender multiply node in between does + not survive the export either: the preview-surface writer follows a fixed + set of node patterns and drops the rest, silently. So the scaling is baked + into a second image. + + It is done in **linear** light, not on the stored bytes. These ramp + endpoints are sRGB, the texture is tagged sRGB, and the shader decodes it + before shading — so scaling the bytes would darken the shell well past the + third the other materials give off (0.35 of an sRGB byte is nearer 0.1 of + the light it stands for). """ import struct import zlib @@ -234,7 +292,17 @@ def write_gradient_png(path): # Row 0 is the top of the image, which is v = 1. t = 1.0 - row / (h - 1) rgb = bytes( - int(round(255 * (SHELL_LOW[i] + (SHELL_HIGH[i] - SHELL_LOW[i]) * t))) + int( + round( + 255 + * linear_to_srgb( + srgb_to_linear_one( + SHELL_LOW[i] + (SHELL_HIGH[i] - SHELL_LOW[i]) * t + ) + * scale + ) + ) + ) for i in range(3) ) raw.append(0) # filter type 0 (None) for this scanline @@ -555,6 +623,8 @@ def build_eyes(white, black): def build(out_dir): clear_scene() png = write_gradient_png(os.path.join(out_dir, GRADIENT_PNG)) + emission_png = write_gradient_png( + os.path.join(out_dir, EMISSION_PNG), scale=EMISSION) gold = plain_material("Gold", GOLD, roughness=0.42) purple = plain_material("Purple", PURPLE, roughness=0.38) @@ -563,7 +633,7 @@ def build(out_dir): # Matte rather than glossy: a tight highlight on a faceted dome blows one # or two facets to white and breaks the ramp exactly where it is meant to # be read. - shell_mat = gradient_material("Shell", png, roughness=0.52) + shell_mat = gradient_material("Shell", png, emission_png, roughness=0.52) parts = [ build_shell(shell_mat), From 8bfac994ad6391f70f567bd5dd22fc2a34ca26c0 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 18 Aug 2026 08:05:51 +0900 Subject: [PATCH 27/33] Say what the headset actually senses (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- site/privacy.html | 56 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/site/privacy.html b/site/privacy.html index 72a6dc8..c87675f 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -160,7 +160,7 @@

Privacy Policy

device and in whatever location you choose to save them.

-

Last updated: 7 August 2026

+

Last updated: 18 August 2026

1. Information we collect

None. The app has none of the following:

@@ -173,6 +173,11 @@

1. Information we collect

(anything that would require App Tracking Transparency)
  • Access to location, contacts, photos, camera, or microphone
  • +

    + On Apple Vision Pro the app asks permission to detect your surroundings, so + it can put your drawing on a real table. That is not camera access, and + nothing it detects is collected — see section 6. +

    2. Network connections

    The app makes none. Its source code contains no networking code at all.

    @@ -203,10 +208,39 @@

    4. Children

    5. Sharing with third parties

    There is nothing to share, because nothing is collected.

    -

    6. Changes to this policy

    +

    6. Detecting your surroundings on Apple Vision Pro

    +

    + On Apple Vision Pro — and only there — the app asks for permission to detect + your surroundings. It uses this to place your drawing on a real table in + front of you, instead of leaving it floating wherever the app happened to + start. +

    +
      +
    • + What the app receives is the position, size and height of flat + horizontal surfaces around you, and the position of the + device itself — enough to know that there is a table in front of + you and how far away it is. +
    • +
    • + No camera image is ever given to the app. The app cannot + see your room, the people in it, or anything written on your desk. +
    • +
    • + It is used only to decide where to put the drawing. It is never saved and + never sent anywhere — it exists in memory while the drawing is on the + table, and is gone once you put it away or close the app. +
    • +
    • + You do not have to allow it. If you decline, or if no table is found, the + drawing simply floats in front of you instead. +
    • +
    + +

    7. Changes to this policy

    If this policy changes, this page will be updated and the date above revised.

    -

    7. Contact

    +

    8. Contact

    Questions about this policy or the app are welcome at GitHub Issues. @@ -224,7 +258,7 @@

    プライバシーポ 利用者自身が選んだ保存先にのみ保存されます。

    -

    最終更新日: 2026年8月7日

    +

    最終更新日: 2026年8月18日

    1. 収集する情報

    ありません。本アプリには次のいずれもありません。

    @@ -236,6 +270,7 @@

    1. 収集する情報

  • 端末を横断して利用者を追跡する仕組み(App Tracking Transparency の対象となる行為)
  • 位置情報、連絡先、写真、カメラ、マイクへのアクセス
  • +

    Apple Vision Pro では、作品を現実の机の上に置くために、周囲の検出の許可を求めます。これはカメラへのアクセスではなく、検出した内容を収集することもありません。詳しくは 6 をご覧ください。

    2. ネットワーク通信

    @@ -269,10 +304,19 @@

    4. 子どもの利用について

    5. 第三者への提供

    収集している情報がないため、第三者に提供する情報もありません。

    -

    6. 本ポリシーの変更

    +

    6. Apple Vision Pro での周囲の検出

    +

    Apple Vision Pro 版でのみ、アプリは周囲の検出の許可を求めます。作品を目の前の現実の机の上に置くために使います。許可がないと、作品はアプリが起動した場所に浮いたままになります。

    +
      +
    • アプリが受け取るのは、周囲にある水平な面の位置・大きさ・高さと、端末自身の位置だけです。目の前に机があること、それがどのくらい離れているかが分かる、という程度の情報です。
    • +
    • カメラの映像がアプリに渡ることはありません。アプリが部屋の様子や、そこにいる人、机の上に置かれた書類などを見ることはできません。
    • +
    • これらは作品を置く場所を決めるためだけに使われます。保存も送信も一切行いません。作品が机の上にある間だけメモリ上に存在し、片付けるかアプリを終了した時点で失われます。
    • +
    • 許可しなくても構いません。許可しなかった場合や、机が見つからなかった場合は、作品は目の前の空中に表示されます。
    • +
    + +

    7. 本ポリシーの変更

    内容を変更した場合は、このページを更新し、上記の最終更新日を改めます。

    -

    7. お問い合わせ

    +

    8. お問い合わせ

    本ポリシーや本アプリについてのご質問は、 GitHub の Issues From 8241363d7d2dc762f9cc6e5ece763a8fa555c9d7 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 18 Aug 2026 08:05:51 +0900 Subject: [PATCH 28/33] Read the debug flag without touching UserDefaults (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-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. --- .claude/skills/release/SKILL.md | 14 ++++++++++++-- App/Views/ViewerWindow.swift | 14 +++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 26dfd31..419223e 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -31,8 +31,18 @@ The policy page states what was *measured* — no accounts, no analytics, no advertising or third-party SDK, no tracking, and no networking code anywhere in the app or in TortoiseGraphics2 — which is also why no `PrivacyInfo.xcprivacy` is needed: nothing here touches a required-reason -API, not even `UserDefaults`. Re-check that if a dependency or an -`@AppStorage` ever arrives. **Both pages read in one language**, chosen by +API, not even `UserDefaults`. That last one is **held on purpose and has +already had to be defended once**: #53's viewer read a debug launch flag with +`UserDefaults.standard.bool(forKey:)`, which is where a `-flag value` pair +normally arrives, and one debug read of it obliges the whole app to ship a +manifest declaring `CA92.1`. It now reads `ProcessInfo.processInfo.arguments` +instead — same launch command, no manifest. So the thing to re-check is not +only "a dependency or an `@AppStorage`": *any* `UserDefaults` call counts, +including a read, including one that only fires in development, because it is +compiled into the shipping binary. `NSWorldSensingUsageDescription` (#53) is +**not** on that list — a usage description is a permission, not a +required-reason API, and it changes the policy page rather than the manifest. +**Both pages read in one language**, chosen by `?lang=` first, then `navigator.languages` in the reader's own order, falling back to English; adding a language is a code in `LANGS`, an `

    `. Two rules hold it together: the site stores nothing (a diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 844c74b..2457be4 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -89,7 +89,19 @@ // buttons (simctl sends no input), so `-TBPlace YES` loads a // sample and puts it down at launch. It is the only way to see // the immersive space without a headset on. - guard UserDefaults.standard.bool(forKey: "TBPlace"), !model.hasProgram else { + // + // Read off the launch arguments rather than through + // `UserDefaults`, which is where a `-flag value` pair normally + // arrives. **`UserDefaults` is one of Apple's required-reason + // APIs**, so touching it — even for a debug flag, even for a + // read — obliges the app to ship a `PrivacyInfo.xcprivacy` + // declaring `CA92.1`. This app has no privacy manifest and + // needs none, which is a property worth keeping for one line: + // nothing else here touches a required-reason API, and the + // launch command is unchanged either way. + guard ProcessInfo.processInfo.arguments.contains("-TBPlace"), + !model.hasProgram + else { return } model.load(SampleBlocks.spiral(), title: String(localized: "Spiral")) From 3e917f64d759b07f2550b666cb805adc8aeee6fb Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 18 Aug 2026 08:17:54 +0900 Subject: [PATCH 29/33] Take SVG/PNG export back out of the viewer (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Views/ViewerWindow.swift | 43 ++++++------------------------------ CLAUDE.md | 13 +++++++---- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 2457be4..18623b7 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -2,7 +2,6 @@ import SwiftUI import TortoiseBlocksKit - import UniformTypeIdentifiers /// The visionOS viewer's one window (#53) — **controls only**. /// @@ -26,17 +25,11 @@ @Environment(\.openWindow) private var openWindow @State private var showsImporter = false - // One presentation state for both formats, for the reason `CanvasPane` - // has one: two `fileExporter`s on the same view and the later silently - // swallows the earlier. The `fileImporter` below is a different kind of - // modifier and does not collide with it. - @State private var exportFile: ExportFile? - @State private var exportType: UTType = .png var body: some View { @Bindable var model = model VStack(spacing: 24) { - DrawingChooser(model: model, showsImporter: $showsImporter, onExport: export) + DrawingChooser(model: model, showsImporter: $showsImporter) if model.hasProgram { PlaybackControls( @@ -76,14 +69,6 @@ } message: { Text(model.openFailure ?? "") } - .fileExporter( - isPresented: Binding( - get: { exportFile != nil }, set: { if !$0 { exportFile = nil } }), - document: exportFile, contentType: exportType, - defaultFilename: String(localized: "Drawing") - ) { _ in - exportFile = nil - } .task { // Development only: the simulator cannot press any of these // buttons (simctl sends no input), so `-TBPlace YES` loads a @@ -120,12 +105,6 @@ model.isPlaced = true } } - - private func export(_ data: Data?, as type: UTType) { - guard let data else { return } - exportType = type - exportFile = ExportFile(data: data) - } } /// Which drawing is on the table: a file, or one of the four samples the @@ -138,15 +117,15 @@ /// and already what 「みほん」 uses, so this needs no bundled resources and /// no second set of names. /// - /// It carries the way *out* as well (#53 Phase 3). Export sits beside Open - /// and Samples rather than with the placement controls because those two - /// rows answer different questions: this one is what picture is loaded and - /// what you can take away with you, the other is where it lies on the - /// table. + /// **There is no export here, and that is the same decision as everything + /// else this window leaves out** (#53). The viewer cannot change a drawing, + /// so the file it was given is already the artifact; a second one written + /// from it belongs where the drawing is *made*, which is the iPad and the + /// Mac. It also kept a remote control down to the controls that place and + /// play — the row had reached four buttons before this came out. private struct DrawingChooser: View { let model: ViewerModel @Binding var showsImporter: Bool - let onExport: (Data?, UTType) -> Void var body: some View { VStack(spacing: 12) { @@ -174,14 +153,6 @@ } label: { Label("Samples", systemImage: "sparkles") } - // The same menu the iPad and Mac canvas toolbar carries — - // SVG, PNG at three scales, and a ShareLink for each. It - // renders `RunnerModel.lastRunCommands`, which is the - // stream the table is drawing from, so what comes out is - // the picture on the table down to the rolled dice (#25); - // moving the drawing into an immersive space changed - // nothing about that, which is why this costs one view. - CanvasExportMenu(runner: model.runner, onExport: onExport) } .buttonStyle(.bordered) } diff --git a/CLAUDE.md b/CLAUDE.md index 48edc84..bada142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,10 +494,15 @@ unchanged, which #11 had already made work here by taking it off 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. 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. +schedule and nothing here can edit the program behind it. +**SVG/PNG export was built here and then taken back out**, and the reason is +worth keeping so it is not re-added as an oversight: it worked, and cost one +view — `CanvasExportMenu` unchanged, rendering `lastRunCommands`, so moving the +drawing into an immersive space changed nothing about what came out. It came +out because a viewer cannot change a drawing, so the file it was handed is +already the artifact, and writing a second one from it belongs where drawings +are *made*. The window is a remote control, and its row had reached four +buttons. **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. From abe7653ce09824920daffded94e275c317dbf202 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 18 Aug 2026 08:58:06 +0900 Subject: [PATCH 30/33] Stop -TBPlace promising a picture it cannot show (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Views/ViewerWindow.swift | 18 ++++++++++++++++-- CLAUDE.md | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 18623b7..7dcec32 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -72,8 +72,22 @@ .task { // Development only: the simulator cannot press any of these // buttons (simctl sends no input), so `-TBPlace YES` loads a - // sample and puts it down at launch. It is the only way to see - // the immersive space without a headset on. + // sample, opens the program window and puts the drawing down at + // launch. Without it a simulator run is a window of buttons + // nobody can reach, and the immersive space never opens at all. + // + // **It does not let you see the drawing.** The simulator hosts + // no `ViewAttachmentComponent` view, so the sheet's own SwiftUI + // body never runs, `TortoisePlayer` never attaches to a canvas, + // and `currentTortoiseState` stays nil — which looks exactly + // like a broken tortoise and has cost an afternoon once + // already (the root CLAUDE.md has the longer note). What the + // flag is actually good for is everything that is not the + // picture: 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, and + // that the program and code windows draw with content in them. + // The picture itself is the headset's to judge. // // Read off the launch arguments rather than through // `UserDefaults`, which is where a `-flag value` pair normally diff --git a/CLAUDE.md b/CLAUDE.md index bada142..e1605e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,13 @@ xcodebuild -project TortoiseBlocks.xcodeproj -scheme TortoiseBlocks \ # Manual verification loop (macOS): pkill -x TortoiseBlocks; open ~/Library/Developer/Xcode/DerivedData/TortoiseBlocks-*/Build/Products/Debug/TortoiseBlocks.app +# visionOS. `-TBPlace` is the only way in: simctl sends no input, so without it +# the run is a window of buttons nobody can press. It does NOT show the drawing +# (see the tortoise note below) — it is for the load path, the USDZ, and the +# program/code windows. +xcrun simctl install ~/Library/Developer/Xcode/DerivedData/TortoiseBlocks-*/Build/Products/Debug-xrsimulator/TortoiseBlocks.app +xcrun simctl launch space.hiraku.tortoiseblocks -TBPlace YES + # The App Store listing (appstore/). The check needs no key and no bundle; # the other two need ASC_ISSUER_ID / ASC_KEY_ID / ASC_PRIVATE_KEY_PATH. ruby fastlane/metadata_check.rb # what CI runs on every pull request From 8f374f9bcbcec3f50714aa437dce33da44315a02 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 18 Aug 2026 09:45:55 +0900 Subject: [PATCH 31/33] Group the viewer's controls by what they do (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Localizable.xcstrings | 100 +++++++++---------- App/Views/CodeWindow.swift | 36 ++++--- App/Views/ProgramWindow.swift | 5 + App/Views/TableCanvas.swift | 41 ++++++++ App/Views/ViewerWindow.swift | 180 ++++++++++++++++++++++++---------- CLAUDE.md | 21 ++++ 6 files changed, 268 insertions(+), 115 deletions(-) diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index cf7b9b9..ddaa5eb 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -14,6 +14,56 @@ } } }, + "Away" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ださない" + } + } + } + }, + "Blocks" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブロック" + } + } + } + }, + "In Front of You" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "めのまえ" + } + } + } + }, + "On a Table" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "つくえ" + } + } + } + }, + "Where should the drawing go?" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "えを どこに だす?" + } + } + } + }, "🎲" : { "localizations" : { "ja" : { @@ -854,16 +904,6 @@ } } }, - "Place on Table" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "つくえに おく" - } - } - } - }, "Play" : { "localizations" : { "ja" : { @@ -914,16 +954,6 @@ } } }, - "Put Away" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "しまう" - } - } - } - }, "Put in Box" : { "localizations" : { "ja" : { @@ -1064,16 +1094,6 @@ } } }, - "Sit on a Table" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "つくえに のせる" - } - } - } - }, "Speed" : { "localizations" : { "ja" : { @@ -1164,26 +1184,6 @@ } } }, - "Show Blocks" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブロックを みる" - } - } - } - }, - "Show Code" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "コードを みる" - } - } - } - }, "Tap a palette block, or drag one here" : { "localizations" : { "ja" : { diff --git a/App/Views/CodeWindow.swift b/App/Views/CodeWindow.swift index b21e669..fb008ee 100644 --- a/App/Views/CodeWindow.swift +++ b/App/Views/CodeWindow.swift @@ -21,20 +21,30 @@ let model: ViewerModel var body: some View { - // An `if`, where `ProgramWindow` puts the empty state in an - // `.overlay`. Deliberate, and the difference is what is behind it: - // an empty block list is nothing, while an empty code pane is a - // sheet of white paper carrying a "Copy Code" button that would - // copy an empty string. - if model.hasProgram { - CodePane(code: model.code) - .padding() - } - else { - ContentUnavailableView( - "No Drawing", systemImage: "curlybraces", - description: Text("Open a drawing, or start from a sample.")) + // A `Group`, so the lifecycle below reports the *window* rather + // than its content: the `if` swaps what is inside when a drawing + // loads, and the same modifiers on either branch would fire then + // too. SwiftUI offers nothing to read window state from, so the + // window telling the model is what lets the viewer's toggle show + // it as open and close it again. + Group { + // An `if`, where `ProgramWindow` puts the empty state in an + // `.overlay`. Deliberate, and the difference is what is behind + // it: an empty block list is nothing, while an empty code pane + // is a sheet of white paper carrying a "Copy Code" button that + // would copy an empty string. + if model.hasProgram { + CodePane(code: model.code) + .padding() + } + else { + ContentUnavailableView( + "No Drawing", systemImage: "curlybraces", + description: Text("Open a drawing, or start from a sample.")) + } } + .onAppear { model.isCodeWindowOpen = true } + .onDisappear { model.isCodeWindowOpen = false } } } diff --git a/App/Views/ProgramWindow.swift b/App/Views/ProgramWindow.swift index 9fc2b64..9c63d0d 100644 --- a/App/Views/ProgramWindow.swift +++ b/App/Views/ProgramWindow.swift @@ -48,6 +48,11 @@ // and the mouths' "add here" toggle look pressable, do nothing, and // are the two things #53 says a read-only program must not offer. .environment(\.showsBlockEditing, false) + // Reported so the viewer's toggle can show this window as open and + // close it again — SwiftUI offers nothing to read window state + // from, so the window is the only thing that knows. + .onAppear { model.isProgramWindowOpen = true } + .onDisappear { model.isProgramWindowOpen = false } .overlay { if !model.hasProgram { ContentUnavailableView( diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift index 13be132..9b46eb7 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/TableCanvas.swift @@ -140,6 +140,47 @@ var isPlaced = false + /// Where the drawing is, as one value (#53). + /// + /// The window used to ask this as two controls — a button that put the + /// drawing down or took it away, and a switch for whether to look for a + /// table — sitting in different rows with unrelated buttons between + /// them. But there are only ever **three** answers, and naming them is + /// what the window should be asking: nowhere, on a table, in the air. + /// One picker says that, and it retires two verbs that were almost the + /// same word for different things (「つくえに おく」 put the drawing + /// down, 「つくえに のせる」 look for a table at all). + /// + /// Read-only, and deliberately: `isPlaced` is only true once the system + /// has actually opened the immersive space, so this cannot be *set* + /// without waiting to find out. `ViewerWindow` drives it through an + /// async action instead, and a space that fails to open leaves the + /// picker showing `.away` — which is where the drawing is. + var placing: Placing { + guard isPlaced else { return .away } + return sitsOnTable ? .table : .inFront + } + + enum Placing: Hashable, CaseIterable { + case away + case table + case inFront + } + + // MARK: The other two surfaces + + /// Whether each window is on screen, so the buttons that open them can + /// also close them. + /// + /// SwiftUI has no "is this window open" to read, so the windows report + /// it themselves as they come and go. Worth the two flags: without + /// them the control is a switch with one position — pressing it again + /// when the window is already up only brings it forward, and the way + /// back is the window's own close button, which is somewhere else + /// entirely. + var isProgramWindowOpen = false + var isCodeWindowOpen = false + // MARK: Finding somewhere to put it /// How the sheet came to be where it is. diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 7dcec32..6105aa5 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -104,18 +104,29 @@ return } model.load(SampleBlocks.spiral(), title: String(localized: "Spiral")) - model.sitsOnTable = false openWindow(id: ViewerModel.programWindowID) - await place() + await place(.inFront) } } - private func place() async { - if model.isPlaced { + /// Moves the drawing to `destination`, opening or closing the + /// immersive space as that requires. + /// + /// The table/in-front choice is set *before* the space opens, because + /// the space reads it as it starts looking for somewhere to put the + /// sheet. Changing it while already placed rebuilds the sheet and + /// starts that search again, which is the intent — it is the wearer + /// saying "not there, here". + private func place(_ destination: ViewerModel.Placing) async { + guard destination != model.placing else { return } + guard destination != .away else { await dismissSpace() model.isPlaced = false + return } - else if case .opened = await openSpace(id: ViewerModel.spaceID) { + model.sitsOnTable = destination == .table + guard !model.isPlaced else { return } + if case .opened = await openSpace(id: ViewerModel.spaceID) { model.isPlaced = true } } @@ -212,63 +223,117 @@ } } - /// Putting the sheet down, and the two things about where it lands that - /// are worth a control rather than a gesture. + /// Where the drawing goes, and the two other surfaces it can be seen on. + /// + /// **Grouped by what a control does, not by what it looks like** (#53). + /// The row used to hold "put it on the table", "show blocks" and "show + /// code" side by side, whose only shared property was being buttons: one + /// was about placement and the other two opened windows, while placement's + /// own mode switch and reset sat in a *different* row underneath, with + /// those two wedged in between. Now placement is one group and the other + /// surfaces are another, with a divider saying so. private struct TablePlacementControls: View { let model: ViewerModel - let place: () async -> Void - - @Environment(\.openWindow) private var openWindow + let place: (ViewerModel.Placing) async -> Void var body: some View { - @Bindable var model = model VStack(spacing: 14) { - // The three places one drawing can be shown, side by side: on - // the table, as the program that draws it, and as the code that - // program stands for. None of them replaces another — having - // all three open at once is the point (#53), and is the one - // thing the iPad build cannot do. - HStack(spacing: 12) { - Button( - model.isPlaced ? "Put Away" : "Place on Table", - systemImage: model.isPlaced ? "xmark.circle" : "table.furniture" - ) { - Task { await place() } - } - .buttonStyle(.borderedProminent) - - Button("Show Blocks", systemImage: "square.stack.3d.up") { - openWindow(id: ViewerModel.programWindowID) - } - Button("Show Code", systemImage: "curlybraces") { - openWindow(id: ViewerModel.codeWindowID) - } - } - .buttonStyle(.bordered) - - // Size, spin and position are gestures on the sheet itself — - // there is nothing here for them. What is left is the choice a - // gesture cannot make (whether to look for a table at all) and - // the way back from having dragged the drawing out of reach. - HStack(spacing: 16) { - Toggle("Sit on a Table", isOn: $model.sitsOnTable) - .toggleStyle(.switch) - .fixedSize() - Button("Reset Position", systemImage: "arrow.counterclockwise") { - model.resetPlacement() + VStack(spacing: 6) { + Text("Where should the drawing go?") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + PlacementPicker(placing: model.placing, place: place) + // Inline, icon-only, and last. Size, spin and position + // are gestures on the sheet itself, so all that is left + // for a control is the way back from having dragged the + // drawing out of reach — which is rare, and must not + // read as louder than the question above it. On its own + // row with a title it did exactly that. + Button("Reset Position", systemImage: "arrow.counterclockwise") { + model.resetPlacement() + } + .labelStyle(.iconOnly) + .buttonStyle(.bordered) + .disabled(!model.isPlaced) } - .labelStyle(.iconOnly) - .disabled(!model.isPlaced) } - .font(.callout) PlacementStatus(model: model) .font(.caption) .foregroundStyle(.secondary) + + Divider() + + SurfaceToggles(model: model) } } } + /// The one question the placement controls ask: where is the drawing? + /// + /// A picker rather than a button and a switch, because the answers are a + /// closed set of three and naming them is clearer than composing them — + /// see `ViewerModel.placing`. Selecting is asynchronous (opening an + /// immersive space can fail, and does in a room that refuses world + /// sensing), so the binding's setter starts the work and the picker + /// follows whatever actually happened rather than what was asked for. + private struct PlacementPicker: View { + let placing: ViewerModel.Placing + let place: (ViewerModel.Placing) async -> Void + + var body: some View { + Picker( + "Where should the drawing go?", + selection: Binding(get: { placing }, set: { new in Task { await place(new) } }) + ) { + Text("Away").tag(ViewerModel.Placing.away) + Text("On a Table").tag(ViewerModel.Placing.table) + Text("In Front of You").tag(ViewerModel.Placing.inFront) + } + .pickerStyle(.segmented) + .labelsHidden() + } + } + + /// The other two surfaces this drawing can be shown on. + /// + /// **Toggles, not buttons**, so the control that opens a window can also + /// close it. `openWindow` on a window that is already up only brings it + /// forward, which made these a switch with one position — the way back was + /// the window's own close button, somewhere else entirely. The windows + /// report whether they are on screen (`ViewerModel.isProgramWindowOpen` / + /// `isCodeWindowOpen`), since SwiftUI offers nothing to read that from. + /// + /// Short labels on purpose. Under a heading that has just said where the + /// drawing goes, 「ブロック」 and 「コード」 are the two other things it can + /// be, and "show" was a word every button in the row was already saying. + private struct SurfaceToggles: View { + let model: ViewerModel + + @Environment(\.openWindow) private var openWindow + @Environment(\.dismissWindow) private var dismissWindow + + var body: some View { + HStack(spacing: 12) { + Toggle( + "Blocks", systemImage: "square.stack.3d.up", + isOn: binding(model.isProgramWindowOpen, ViewerModel.programWindowID)) + Toggle( + "Code", systemImage: "curlybraces", + isOn: binding(model.isCodeWindowOpen, ViewerModel.codeWindowID)) + } + .toggleStyle(.button) + .buttonStyle(.bordered) + } + + private func binding(_ isOpen: Bool, _ id: String) -> Binding { + Binding( + get: { isOpen }, + set: { $0 ? openWindow(id: id) : dismissWindow(id: id) }) + } + } + /// What the sheet is doing, under the placement controls. /// /// Finding a table takes **ten seconds or more** on device, and the sheet @@ -280,16 +345,27 @@ let model: ViewerModel var body: some View { - switch model.placement { - case .searching: + switch (model.isPlaced, model.placement) { + // Nothing is out, so there is nothing to report and nothing to + // pinch. The picker above has already said where the drawing is. + case (false, _): + EmptyView() + case (_, .searching): HStack(spacing: 8) { ProgressView() .controlSize(.small) Text("Looking for a table…") } - case .floating: - Text("No table found, so it is floating in front of you.") - case .onTable: + // Floating is two different things now that it can be *chosen* + // (#53). Asking for a table and not getting one is news; asking for + // the air and getting it is not, and telling someone their choice + // failed when it did not is worse than saying nothing. + case (_, .floating): + Text( + model.sitsOnTable + ? "No table found, so it is floating in front of you." + : "Pinch to resize, twist to turn, drag to move.") + case (_, .onTable): Text("Pinch to resize, twist to turn, drag to move.") } } diff --git a/CLAUDE.md b/CLAUDE.md index e1605e1..49acc60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -502,6 +502,27 @@ 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. +**The remote's controls are grouped by what they do, not by what they are.** +The row used to read 「つくえに おく」「ブロックを みる」「コードを みる」, whose +only shared property was being buttons — one placed the drawing, two opened +windows — while placement's own mode switch and reset sat in a *different* row +underneath with those two wedged between. Placement is now one group with its +own question over it, the other surfaces are another below a divider, and 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 apart and unbearable +once grouped — so `ViewerModel.placing` names the **three** states the window +actually has (away / table / in front) and one picker asks them. It stays +read-only and the window drives it through an async action, because `isPlaced` +is only true once the space has really opened and a refused world-sensing prompt +must leave the picker showing where the drawing *is*. The window buttons became +toggles, since `openWindow` on an open window only brings it forward — a switch +with one position — so the windows report themselves through +`isProgramWindowOpen` / `isCodeWindowOpen`, there being nothing in SwiftUI to +read that from. And floating stopped being an error: it can now be *chosen*, so +`PlacementStatus` says "no table found" only when a table was actually asked +for. + **SVG/PNG export was built here and then taken back out**, and the reason is worth keeping so it is not re-added as an oversight: it worked, and cost one view — `CanvasExportMenu` unchanged, rendering `lastRunCommands`, so moving the From 2f9fe4cb57e42ba5c4ef197cd8342570d323e098 Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Tue, 18 Aug 2026 09:55:35 +0900 Subject: [PATCH 32/33] Record that the ornament was considered and declined (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49acc60..fcdc503 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -515,9 +515,13 @@ once grouped — so `ViewerModel.placing` names the **three** states the window actually has (away / table / in front) and one picker asks them. It stays read-only and the window drives it through an async action, because `isPlaced` is only true once the space has really opened and a refused world-sensing prompt -must leave the picker showing where the drawing *is*. The window buttons became -toggles, since `openWindow` on an open window only brings it forward — a switch -with one position — so the windows report themselves through +must leave the picker showing where the drawing *is*. A visionOS **ornament** was the other candidate for those +two — the platform's own place for "belongs to this window but is not its +content" — and was turned down: it is always visible, so it hangs under the +window even in the small "えが ありません" state and adds its height to every +glance, and a divider already says the difference for nothing. The window +buttons became toggles, since `openWindow` on an open window only brings it +forward — a switch with one position — so the windows report themselves through `isProgramWindowOpen` / `isCodeWindowOpen`, there being nothing in SwiftUI to read that from. And floating stopped being an error: it can now be *chosen*, so `PlacementStatus` says "no table found" only when a table was actually asked From f2338ca1af06b7f9bb3ac7d40c387186111736be Mon Sep 17 00:00:00 2001 From: Tomoki Kobayashi Date: Wed, 19 Aug 2026 07:48:33 +0900 Subject: [PATCH 33/33] Open a drawing, and it goes on the table (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App/Views/TableCanvas.swift | 15 +++++++++++++++ App/Views/ViewerWindow.swift | 27 ++++++++++++++++++++++++++- CLAUDE.md | 19 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/App/Views/TableCanvas.swift b/App/Views/TableCanvas.swift index 9b46eb7..0165243 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/TableCanvas.swift @@ -58,11 +58,22 @@ var hasProgram: Bool { !blocks.isEmpty } + /// Bumped by every load, so the window can put the new drawing down + /// without each of the five call sites — the file importer and the + /// four samples — having to remember to. + /// + /// A counter rather than a flag, for the same reason + /// `RunnerModel.runGeneration` is one: `blocks` alone cannot say + /// "loaded again" when the same drawing is chosen twice, and it is the + /// *choosing* that means "show me this". + private(set) var loadGeneration = 0 + func load(_ blocks: [Block], title: String) { self.blocks = blocks self.title = title code = SwiftCodeGenerator.code(for: blocks) runner.run(blocks, startPaused: true) + loadGeneration += 1 } /// Opens a `.tortoise` from the file importer. The URL comes from @@ -136,6 +147,10 @@ /// wearer. The second is the fallback #53 asks for — a room with no /// table, a refused world-sensing prompt, and the simulator, where /// ARKit finds no planes at all. + /// + /// It is also the *remembered* choice: opening a drawing places it + /// (see `ViewerWindow`), and this is what says where. Someone who has + /// once said "in the air" is not asked again on the next file. var sitsOnTable = true var isPlaced = false diff --git a/App/Views/ViewerWindow.swift b/App/Views/ViewerWindow.swift index 6105aa5..7f14186 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/ViewerWindow.swift @@ -69,6 +69,26 @@ } message: { Text(model.openFailure ?? "") } + // **Opening a drawing puts it down.** The viewer has exactly one + // job, so choosing a drawing and then being asked where to put it + // is a question with one sensible answer — and until it is + // answered the room stays empty and nothing on screen says why, + // which reads as an app that did not open the file. An alert + // ("shall I put it on the table?") was the other candidate and + // fails for the same reason twice over: a modal whose answer is + // always yes, stacked in front of the world-sensing prompt that + // the first placement already brings with it. + // + // It stays an ordinary *placement*, not a special case. The picker + // below moves to wherever this put it and 「ださない」 takes it + // away again, so nothing here is a state the wearer cannot get + // out of. Opening a second drawing while one is already out + // changes what is on the sheet and leaves the sheet where it was + // dragged to — `place` sees the drawing is already there and + // returns. + .onChange(of: model.loadGeneration) { + Task { await place(model.sitsOnTable ? .table : .inFront) } + } .task { // Development only: the simulator cannot press any of these // buttons (simctl sends no input), so `-TBPlace YES` loads a @@ -103,9 +123,14 @@ else { return } + // Loading is what places it now, so the destination is + // chosen by setting the preference first. 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. + model.sitsOnTable = false model.load(SampleBlocks.spiral(), title: String(localized: "Spiral")) openWindow(id: ViewerModel.programWindowID) - await place(.inFront) } } diff --git a/CLAUDE.md b/CLAUDE.md index fcdc503..78c39d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -527,6 +527,25 @@ read that from. And floating stopped being an error: it can now be *chosen*, so `PlacementStatus` says "no table found" only when a table was actually asked for. +**Opening a drawing puts it down**, and that is the placement group's last +open question answered. Choosing a file used to change nothing but the window: +the room stayed empty until the picker was touched, so the app read as one +that had not opened the file — the state with the least to look at was the one +reached by doing the thing the app 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, through `ViewerModel.loadGeneration` — a counter rather than a flag, +because `blocks` cannot say "chosen again" when the same drawing is picked +twice, and because the five call sites (the importer and the four samples) +should not each have to remember. Where it goes is `sitsOnTable`, which is +therefore now a *remembered* preference rather than only the space's own +question: someone who has once said 「めのまえ」 is not asked again on the next +file. Nothing about it is a special case — 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 exactly where it was dragged to, because `place` +sees the drawing is already there and returns. + **SVG/PNG export was built here and then taken back out**, and the reason is worth keeping so it is not re-added as an oversight: it worked, and cost one view — `CanvasExportMenu` unchanged, rendering `lastRunCommands`, so moving the