diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df71d80..69feb12 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,7 +38,7 @@ jobs:
.cache/ci/linux-amd64/target/debug/.fingerprint
.cache/ci/linux-amd64/target/.rustc_info.json
.cache/ci/linux-amd64/home/.npm
- key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
+ key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'vendor/**') }}
- name: Check formatting, lint, tooling, and focused regressions
env:
N3_CI_FAILURE_ARTIFACTS: "1"
@@ -94,7 +94,7 @@ jobs:
.cache/ci/linux-amd64/target/debug/.fingerprint
.cache/ci/linux-amd64/target/.rustc_info.json
.cache/ci/linux-amd64/home/.npm
- key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
+ key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'vendor/**') }}
- name: Replay every guide feature and check exact artifacts
env:
N3_CI_FAILURE_ARTIFACTS: "1"
@@ -142,7 +142,7 @@ jobs:
target/debug/build
target/debug/.fingerprint
target/.rustc_info.json
- key: n3-v1-macos15-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock') }}
+ key: n3-v1-macos15-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }}
- name: Restore the pinned just installation
id: just
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -177,3 +177,100 @@ jobs:
~/.cargo/.crates.toml
~/.cargo/.crates2.json
key: ${{ steps.just.outputs.cache-primary-key }}
+
+ wasm:
+ name: WASM lint and browser build
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Set up the contributor Node.js version
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+ package-manager-cache: false
+ - name: Identify the browser build runner
+ id: environment
+ run: |
+ python3 - <<'PY' >> "$GITHUB_OUTPUT"
+ import hashlib
+ import os
+ image = os.environ.get("ImageVersion", "unreported")
+ print(f"platform={hashlib.sha256(image.encode()).hexdigest()}")
+ PY
+ - name: Restore WASM dependencies and build output
+ id: dependencies
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ .cache/cargo/registry/cache
+ .cache/cargo/registry/index
+ target/debug/deps
+ target/debug/build
+ target/debug/.fingerprint
+ target/web/deps
+ target/web/build
+ target/web/.fingerprint
+ target/wasm32-unknown-unknown/debug/deps
+ target/wasm32-unknown-unknown/debug/build
+ target/wasm32-unknown-unknown/debug/.fingerprint
+ target/wasm32-unknown-unknown/web/deps
+ target/wasm32-unknown-unknown/web/build
+ target/wasm32-unknown-unknown/web/.fingerprint
+ target/.rustc_info.json
+ key: n3-v1-wasm-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }}
+ - name: Restore pinned browser build tools
+ id: tools
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/bin/just
+ ~/.cargo/.crates.toml
+ ~/.cargo/.crates2.json
+ .cache/web-tools/bin/wasm-bindgen
+ .cache/web-tools/.crates.toml
+ .cache/web-tools/.crates2.json
+ key: n3-v1-wasm-tools-just1.46.0-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml') }}
+ - name: Install optional browser tooling using the repository Rust toolchain
+ run: |
+ cargo install just --version 1.46.0 --locked
+ just web-setup
+ # Compile and generate glue on every run. The native lanes continue to
+ # own behavioral tests and exact guide receipts; this job needs no GPU.
+ - name: Lint the WASM target and generate the browser application
+ run: just web-verify
+ - name: Save verified WASM dependencies and build output
+ if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.dependencies.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ .cache/cargo/registry/cache
+ .cache/cargo/registry/index
+ target/debug/deps
+ target/debug/build
+ target/debug/.fingerprint
+ target/web/deps
+ target/web/build
+ target/web/.fingerprint
+ target/wasm32-unknown-unknown/debug/deps
+ target/wasm32-unknown-unknown/debug/build
+ target/wasm32-unknown-unknown/debug/.fingerprint
+ target/wasm32-unknown-unknown/web/deps
+ target/wasm32-unknown-unknown/web/build
+ target/wasm32-unknown-unknown/web/.fingerprint
+ target/.rustc_info.json
+ key: ${{ steps.dependencies.outputs.cache-primary-key }}
+ - name: Save verified browser build tools
+ if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.tools.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/bin/just
+ ~/.cargo/.crates.toml
+ ~/.cargo/.crates2.json
+ .cache/web-tools/bin/wasm-bindgen
+ .cache/web-tools/.crates.toml
+ .cache/web-tools/.crates2.json
+ key: ${{ steps.tools.outputs.cache-primary-key }}
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 3ae353a..ff5703c 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -7,6 +7,7 @@
"/Cargo.lock",
"/docs/guide/**",
"/docs/preview/vendor/**",
+ "/vendor/**",
"/examples/**",
"/fixtures/gltf/*/**"
]
diff --git a/AGENTS.md b/AGENTS.md
index 29aefa3..5f20b66 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,8 +8,8 @@ decision.
## Product and interaction principles
-- Focus on hard-surface mesh modeling. Broader modeling tools, web support, and
- speculative UX ideas are separate work; do not silently expand a task to add them.
+- Focus on hard-surface mesh modeling. Broader modeling tools and speculative
+ UX ideas are separate work; do not silently expand a task to add them.
- Keep actions semantic and key bindings separate. UI controls, shortcuts, and
tutorial replay should reuse the same behavior rather than implement variants.
- Respect input ownership: text fields, popups, active gestures, and edit sessions
@@ -73,6 +73,12 @@ Keep one application crate until a real consumer and a clear dependency boundary
justify extraction. Prefer focused modules over speculative crates, public APIs,
or generic frameworks.
+Read [platform boundaries and performance](docs/architecture/platform-boundaries.md)
+before changing host adapters or platform-dependent behavior. Share product
+semantics, keep target selection at module boundaries, and preserve each host's
+execution policy. Browser constraints must not lower the primary native target's
+capabilities, performance policy, or verification baseline.
+
| Location | Responsibility |
| -------------------- | --------------------------------------------------------------------- |
| `src/model/` | Authored documents, units, native text codec, geometry evaluation |
@@ -108,6 +114,10 @@ The user guide is a primary development and review contract: maintainers give
feedback on the same narrative and evidence that real users read. Important
promises must be executable and fail visibly when they drift.
+Native and web share one feature UI and user guide. Follow the
+[shared product contract](docs/architecture/platform-boundaries.md#one-product-experience)
+for capability-based availability and the boundary with contributor documentation.
+
Every guide feature owns one Rust scenario in `src/documentation/scenarios/` and
one template in `docs/templates/`, registered in
`src/documentation/features.rs`. Edit those sources; never hand-edit generated
diff --git a/Cargo.lock b/Cargo.lock
index d5e1fa3..27c2ae7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -409,6 +409,16 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -625,8 +635,6 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
dependencies = [
"arboard",
"bytemuck",
@@ -1485,6 +1493,7 @@ dependencies = [
"alacritty_terminal",
"base64",
"bytemuck",
+ "console_error_panic_hook",
"earcutr",
"egui",
"egui-wgpu",
@@ -1492,6 +1501,7 @@ dependencies = [
"glam",
"gltf",
"image",
+ "js-sys",
"libc",
"percent-encoding",
"pollster 1.0.1",
@@ -1500,6 +1510,10 @@ dependencies = [
"serde_json",
"sha2",
"tobj",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "web-time",
"wgpu",
"winit",
]
diff --git a/Cargo.toml b/Cargo.toml
index ffd3975..3371eff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,21 +5,22 @@ edition = "2024"
rust-version = "1.95"
publish = false
+[lib]
+crate-type = ["rlib", "cdylib"]
+# This private application library owns the existing single test inventory.
+doctest = false
+
[[bin]]
name = "n3"
path = "src/main.rs"
+test = false
[dependencies]
-alacritty_terminal = "=0.26.0"
base64 = "0.22.1"
bytemuck = { version = "1.25.2", features = ["derive"] }
earcutr = "0.5.0"
egui = "=0.36.2"
egui-wgpu = "=0.36.2"
-egui-winit = { version = "=0.36.2", default-features = false, features = [
- "clipboard",
- "links",
-] }
glam = "0.33.11"
gltf = { version = "1.4.1", default-features = false, features = [
"utils",
@@ -35,21 +36,68 @@ image = { version = "=0.25.10", default-features = false, features = [
"png",
"webp",
] }
-libc = "0.2"
-pollster = "1.0.1"
percent-encoding = "2.3.2"
-rfd = "0.17.2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
sha2 = "0.10.9"
wgpu = "30.0.1"
winit = "=0.30.13"
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+alacritty_terminal = "=0.26.0"
+egui-winit = { version = "=0.36.2", default-features = false, features = [
+ "clipboard",
+ "links",
+] }
+libc = "0.2"
+pollster = "1.0.1"
+rfd = "0.17.2"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+console_error_panic_hook = "0.1.7"
+egui-winit = { version = "=0.36.2", default-features = false, features = [
+ "links",
+] }
+js-sys = "=0.3.106"
+wasm-bindgen = "=0.2.129"
+wasm-bindgen-futures = "=0.4.79"
+web-time = "1.1.0"
+web-sys = { version = "=0.3.106", features = [
+ "AddEventListenerOptions",
+ "console",
+ "CustomEvent",
+ "CustomEventInit",
+ "Document",
+ "DomRect",
+ "Element",
+ "Event",
+ "EventTarget",
+ "HtmlCanvasElement",
+ "HtmlElement",
+ "Navigator",
+ "Performance",
+ "Storage",
+ "StorageEvent",
+ "WheelEvent",
+ "Window",
+] }
+
[dev-dependencies]
tobj = { version = "4.0.5", features = ["use_f64"] }
+# egui-winit 0.36.2 implements only the native DroppedFile trait method.
+# Preserve its input integration with a narrow WASM compatibility patch.
+[patch.crates-io]
+egui-winit = { path = "vendor/egui-winit" }
+
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 2
+
+# Keep local browser iteration quick without shipping Rust debug sections.
+[profile.web]
+inherits = "dev"
+debug = false
+strip = "debuginfo"
diff --git a/README.md b/README.md
index 3564e07..db43ab1 100644
--- a/README.md
+++ b/README.md
@@ -99,13 +99,29 @@ Read the illustrated guide in your browser with `just docs` (requires Python 3).
It opens a local preview of the checked-in documentation; press Ctrl-C in the
terminal to stop the server.
+### Browser baseline
+
+The same Rust editor and viewport also run in a browser through WebAssembly and
+WebGPU. To build and serve the local baseline:
+
+```sh
+just web-setup
+just web
+```
+
+Use a desktop browser with WebGPU available. Native and web use the same
+workspace and [user guide](docs/guide/README.md); unsupported host features are
+omitted. See [development](docs/development.md#browser-build) for setup,
+embedding, and platform limits.
+
## Current scope
N3 currently supports object transforms, vertex editing,
[making a face](docs/guide/make-face.md) from a triangle or a single flat outline,
and explicit [X-ray selection](docs/guide/xray.md). Broader topology tools,
edge/face selection, geometry snapping, and sculpting are future work.
-Web support is also deferred.
+The browser build is a technical baseline; full browser and device coverage is
+still under evaluation.
OBJ import focuses on vertex positions and polygon faces. Materials, textures,
UVs, and authored normals are not preserved, and OBJ export is not implemented.
diff --git a/TODO.md b/TODO.md
index fec2e9f..50dcdba 100644
--- a/TODO.md
+++ b/TODO.md
@@ -31,7 +31,9 @@ and document the implemented behavior in the executable user guide.
| Inspector number overflow | Keep each numeric field frame fixed while clipping or scrolling only its value text; egui's `DragValue` currently expands both. |
| Native accessibility | Wire egui's AccessKit adapter through the native event loop, add polite live announcements for toasts, and verify with VoiceOver. Keyboard focus and widget labels alone do not provide screen-reader announcements. |
| OS font support | Fonts as document materials; discovery/loading boundary suitable for a later web implementation. Separate from bundled interface fonts. |
-| Initial WASM spike | Prove a minimal browser build and identify platform dependencies before deciding on a full port. |
+| Web platform follow-up | Build on the [WASM browser baseline](docs/development.md#browser-build): browser/device coverage, touch/IME, external asset resource workflows, persistence/recovery, and multiple embedded workspaces. |
+| Native/browser viewport profiling | [Issue #2](https://github.com/gridaco/n3/issues/2): reproduce large-mesh orbit stutter with comparable builds, real browser runs, and stage-level timings. Distinguish shared costs, web-host mistakes, and measured platform constraints before optimizing; follow the WASM baseline delivery. |
+| Linux capture test concurrency | Diagnose allocator aborts and segmentation faults in parallel lavapipe tests, reproduced on the native baseline. The [opt-in CI runner](docs/development.md#verification-and-git-hooks) uses one test thread while retaining every test and exact capture; establish the cause before restoring parallel execution. |
| Exact macOS 27 guide frame | Replace the approximate static window chrome with measurements from a native macOS 27 N3 capture or Apple's design kit. Evaluate the translucent traffic-light material, highlights, and shadow only if exact mirroring is worth the rendering complexity. |
Existing context: [UX ideas](docs/research/ux-ideas.md),
diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md
index 691659c..e94b792 100644
--- a/docs/architecture/architecture.md
+++ b/docs/architecture/architecture.md
@@ -1,32 +1,43 @@
# Architecture
-N3 is one Rust package with one native application and an executable documentation
-harness. Module boundaries are useful now; separate crates would imply an API
-contract that the editor does not yet have. The package is not a published SDK.
+N3 is one Rust package with native and browser application hosts and an executable
+documentation harness. Module boundaries are useful now; separate crates would
+imply an API contract that the editor does not yet have. The package is not a
+published SDK.
## Source layout
| Territory | Responsibility |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `src/model/` | Authored documents, geometry evaluation, units, validation, and the native text codec. |
-| `src/asset_io/` | Format adapters, resource resolution, open dispatch, and native document persistence. |
+| `src/asset_io/` | Shared format adapters and resource policy; filesystem operations in `native/`. |
| `src/scene/` | Internal imported scene model, validation, hierarchy and animation evaluation. |
| `src/scene_view.rs` | Asset inspection, playback clock and independent view state. |
| `src/editor/` | Selection, editing sessions, history integration, transforms, and derived picking state. |
| `src/input/` | Physical input identity, semantic shortcuts, ownership, and shared navigation routing. |
-| `src/render/` | Camera projection, derived mesh data, GPU resources, and scene rendering. |
+| `src/render/` | Camera projection, GPU resources, scene rendering, and shared workspace frame mechanics. |
| `src/ui/` | egui panels and viewport controls, properties, gizmo, rulers, and feedback. |
| `src/terminal/` | Terminal emulator session and its egui view, outside authored documents and history. |
| `src/documentation/` | Executable scenarios, virtual input, annotations, captures, and media generation. |
| `src/native.rs` | Window lifecycle, native events, dialogs, filesystem effects, and GPU presentation. |
+| `src/web.rs` | Browser canvas lifecycle, event delivery, WebGPU presentation, and application bridge. |
| `src/settings/` | Global preferences, validation, merging, and the host storage interface. |
-| `src/main.rs` | Composition and application entry points. |
+| `src/lib.rs` | Shared composition and target-specific application entry points. |
+| `src/main.rs` | Native executable entry point. |
+| `web/` | Framework-neutral browser wrapper, DOM file effects, and local demo page. |
The composition root retains internal module aliases so implementation modules
can refer to each other without a broad import rewrite. They remain private to
the application crate. These aliases are not public kernel exports, and the
directory layout alone does not enforce a strict dependency graph.
+Native and browser hosts share `WorkspaceUi`, `WorkspaceRenderer`, semantic
+commands, and navigation policy. Hosts own execution and external effects.
+[Platform boundaries and performance](platform-boundaries.md) defines this
+contract, including target selection, independent performance policies, and the
+shared UI and guide. Build, embedding, and platform limits belong in the
+[development guide](../development.md#browser-build).
+
[Actions and menus](actions-and-menus.md) describes shared command metadata,
live availability, shortcut presentation, and menu authoring.
@@ -225,7 +236,8 @@ Split only when a second host or independent headless consumer needs a stable
pure model/editing interface. First separate evaluation output from render
adapters, and selection/transaction operations from egui coordinates and paint.
A crate split should enforce those verified seams, not merely move the existing
-coupling across package boundaries. Browser delivery remains future work.
+coupling across package boundaries. The browser host currently reuses the
+application modules in the same package; it does not require a crate split.
## Evidence boundaries
@@ -234,3 +246,5 @@ renderer. Deterministic replay proves application routing, state transitions, an
rendered outcomes. It does not prove macOS gesture recognition, actual device
feel, file-dialog behavior, or window-system delivery. Keep focused native/manual
acceptance alongside headless assertions and visual review of generated media.
+Browser build and runtime checks establish a separate host boundary and do not
+replace the native guide baseline or physical browser/device testing.
diff --git a/docs/architecture/documentation-pipeline.md b/docs/architecture/documentation-pipeline.md
index 142f44f..0b99c61 100644
--- a/docs/architecture/documentation-pipeline.md
+++ b/docs/architecture/documentation-pipeline.md
@@ -77,7 +77,11 @@ separate from physical key events; a native key/text pair must not type twice.
Native scroll, pinch, and twist arrive outside egui's normal pointer routing.
[navigation_events](../../src/input/navigation_events.rs) represents those inputs with logical-point or wheel-line
deltas, native pinch deltas, rotation degrees, modifiers, and scroll phases.
-The native window and documentation session call the same navigation router.
+The native window, browser host, and documentation session call the same
+navigation router. The browser adapter converts DOM wheel units and cumulative
+WebKit gestures before routing; the existing navigation scenario exercises those
+normalized events through `Session::navigation` without a separate guide or
+capture baseline.
It respects pointer location, window focus, floating UI, open menus, a pending
view pie, and active mouse gestures. Active editor pointer gestures and property
edits exclude camera input; released transform previews allow navigation while
diff --git a/docs/architecture/platform-boundaries.md b/docs/architecture/platform-boundaries.md
new file mode 100644
index 0000000..39b6495
--- /dev/null
+++ b/docs/architecture/platform-boundaries.md
@@ -0,0 +1,126 @@
+# Platform boundaries and performance
+
+This is the engineering contract for N3's native and browser hosts.
+
+> Share product semantics. Let each host own execution and realize its
+> capabilities fully.
+
+Native remains the primary target. Browser support must preserve native
+capabilities, rendering quality, and performance policy. Each supported host
+should use the execution model and capabilities appropriate to its environment;
+the browser's restrictions do not establish a common performance ceiling.
+
+## Shared behavior and host ownership
+
+The same operation must retain its meaning across input sources and hosts.
+Document validation, editing transactions, history, units, selection rules, and
+navigation policy stay shared. An adapter translates host input or performs a
+host effect; it does not create an independent editing path.
+
+| Concern | Shared responsibility | Host responsibility |
+| ----------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| Editing | Document semantics, commands, sessions, validation, and history | Deliver input and execute requested external effects |
+| Navigation | Camera behavior, sensitivity, and input ownership | Translate device events, coordinates, units, and available gesture phases |
+| Rendering | Scene evaluation, revision caches, uploads, and frame composition | Adapter/device selection, GPU limits, surfaces, recovery, and presentation |
+| Time | Camera transitions and playback behavior | Monotonic or explicit replay clocks, frame pacing, and event-loop wakeups |
+| Assets | Format decoding, unit conversion, and resource accounting | File access, selected bytes, resource resolution, and save completion |
+| Preferences | Typed values, validation, and merge semantics | Storage implementation, synchronization triggers, and persistence guarantees |
+
+For example, DOM pinch events enter the shared navigation router through the
+[browser gesture adapter](../../src/web/gestures.rs). The adapter handles browser
+event delivery and duplicate signals; viewport ownership and camera behavior
+remain shared. Missing gesture phase information is an explicit host limitation,
+not a reason to discard native phase or momentum handling.
+
+## Focused implementation boundaries
+
+Select target implementations at composition and module boundaries. Keep `cfg`
+out of ordinary editing rules and feature UI. Prefer a capability value when the
+same UI needs to express whether an operation is available.
+
+Extract an adapter around an actual environmental difference, and share code
+when its behavior and ownership agree. Keep one application crate until a real
+consumer justifies a separate API. A port does not require a universal host trait,
+a public modeling kernel, or identical implementations of every service.
+
+Current boundaries illustrate this policy:
+
+- [`asset_io/native/`](../../src/asset_io/native/mod.rs) owns filesystem operations;
+ format parsers and retained-resource accounting remain portable.
+- [`WorkspaceRenderer`](../../src/render/workspace.rs) shares candidate preparation,
+ cache synchronization, rollback, scene rendering, and egui composition. Hosts
+ supply their actual device and target and retain presentation policy.
+- [Terminal implementations](../../src/terminal/mod.rs) and
+ [guide inspection hooks](../../src/ui/controls.rs) are selected as modules.
+- The [JavaScript wrapper](../../web/n3.js) forwards semantic commands and exposes
+ application state. An embedding UX must reuse the editor's transactions and
+ history. The existing Editor is still internal and depends on egui.
+
+## Performance, clocks, and scheduling
+
+Preserve each host's resource limits, cache behavior, concurrency options, and
+presentation choices when extracting shared code. A browser limit must not become
+a hard-coded limit in a shared renderer merely to make the port compile. Shared
+rendering uses the device's enabled capabilities and retains revision-based work:
+selection changes must not cause unnecessary geometry evaluation or uploads.
+
+Hosts acquire monotonic time and decide when to wake or present. Shared behavior
+consumes elapsed time or frame input and can request repaint deadlines; hosts
+schedule their delivery. Deterministic guide replay supplies an explicit clock.
+Do not spread host clock calls through editing semantics or make native scheduling
+inherit browser timer constraints. The native host currently uses
+`std::time::Instant`; the browser uses `web_time::Instant`.
+
+Storage scheduling follows the same ownership rule. Browser preferences react to
+local changes, storage events, and focus recovery, with timers for failed-attempt
+retries. Native preferences retain filesystem polling. Both wait for safe input
+boundaries before merging. Storage guarantees may differ and must be stated
+accurately; sharing a controller does not make browser writes atomic across tabs.
+
+Measure representative workloads before claiming a performance improvement.
+Compilation and architectural similarity establish neither frame-time parity nor
+the best achievable performance. Report the host, workload, and measured effect
+alongside any remaining limits.
+
+## One product experience
+
+Keep one feature UI, semantic action catalog, and user guide. Each feature owns
+one narrative, scenario, and generated page across hosts. Adapt availability by
+hiding or disabling unsupported controls in that shared UI; preserve familiar
+menu organization and supported behavior.
+
+Presentation can use host capabilities without changing feature semantics. For
+example, a future native Preferences window could be a real OS window while the
+browser retains the existing egui window. Native multi-window delivery remains
+future work; this example does not declare it implemented.
+
+Keep build, embedding, and host limitations in the
+[development guide](../development.md#browser-build). This contract owns the
+engineering policy; user-facing guides explain the shared features without
+linking back to engineering documents.
+
+## Verification and review
+
+Preserve the native verification path and existing guide baseline. A browser
+failure must not be resolved by weakening native checks, lowering rendering
+quality, skipping artifacts, or creating a separate browser edition of the guide.
+Any intentional native baseline change needs its own reason and review.
+
+Use shared behavioral regressions and native `just verify` for implementation
+changes, plus target compilation and host integration checks for the port.
+Separate the evidence: compilation proves build compatibility; runtime checks
+exercise event delivery, rendering, storage, and lifecycle; physical-device tests
+establish gesture recognition and feel; measurements support performance claims.
+Browser checks supplement the shared guide's executable contract. They do not
+replace its exact artifact checks or prove untested browsers and devices.
+
+Review a boundary change with these questions:
+
+1. Who owns the behavior, and do all entry points reuse its semantics?
+2. Does a restriction belong to one host, and is it confined to that adapter?
+3. Does the abstraction preserve each host's capabilities, scheduling, and caches?
+4. Does the shared UI express availability without splitting the feature or guide?
+5. What verifies native behavior, the affected host, and any performance claim?
+
+The [architecture overview](architecture.md) maps the modules, and
+[development](../development.md) owns the runnable verification commands.
diff --git a/docs/architecture/user-settings.md b/docs/architecture/user-settings.md
index d891794..8c61ae6 100644
--- a/docs/architecture/user-settings.md
+++ b/docs/architecture/user-settings.md
@@ -1,8 +1,9 @@
# Global user settings
-Settings are independent of documents. A single user-level `settings.json`
-supplies preferences for every document; there is no workspace, project lookup,
-inheritance chain, or settings embedded in `.n3.json`.
+Settings are independent of documents. Native persists preferences in one
+user-level `settings.json`; the browser uses an origin-local store. Each store
+supplies preferences for every document opened by its host. There is no
+workspace or project lookup, inheritance chain, or settings embedded in `.n3.json`.
## Responsibilities
@@ -13,20 +14,22 @@ inheritance chain, or settings embedded in `.n3.json`.
distinct from malformed or inaccessible data.
- `src/native/settings_store.rs` resolves the macOS user path and implements
bounded reads, guarded writes, permissions, and temporary-file ownership.
-- The native host schedules synchronization and handles opening the text file.
+- Each application host schedules synchronization; native also handles opening
+ the text file.
`src/ui/user_settings.rs` maps validated settings into existing runtime behavior;
Preferences emits host requests and displays errors without performing I/O.
-- The native host supplies the current OS appearance. The settings model keeps
+- Each host supplies its current appearance. The settings model keeps
the user's `System`, `Light`, or `Dark` choice distinct from the resolved
appearance used by egui. Style colors and measurements live in `src/theme.rs`;
they are not serialized as a general theme configuration.
- Core tests and documentation replay use memory stores. Native storage tests use
temporary directories. None of them reads or writes the user's preferences.
-The interface follows the needs of this native feature. A different host can
-provide another store and an appropriate way to edit its text without changing
-the settings model or widgets. This is not a browser implementation, and it does
-not claim that other native application services are already portable.
+Both hosts use the shared controller and Preferences UI. Persistence guarantees
+belong to each store; sharing the controller does not make their storage APIs
+equivalent. The [browser host constraints](../development.md#browser-host-constraints)
+describe its storage, synchronization, and omitted controls. Native file
+guarantees are specified below.
`src/theme.rs` follows [shadcn's semantic theme tokens](https://ui.shadcn.com/docs/theming#theme-tokens) for the interface:
background, card, popover, primary, secondary, muted, accent, borders, focus rings,
@@ -40,10 +43,11 @@ interface components.
## Synchronization and interaction
-Startup reads the user's file or uses defaults if it is absent. Reading defaults
-does not create a file. A UI change or the explicit Open settings.json action
-creates it. The first write includes the complete known defaults; an existing
-partial file keeps omitted defaults and unknown keys.
+Startup reads the host's stored snapshot or uses defaults if it is absent.
+Reading defaults does not create stored data. A UI change requests persistence; native's
+explicit Open settings.json action also creates a missing file. The first write
+includes the complete known defaults; an existing partial snapshot keeps omitted
+defaults and unknown keys.
UI changes apply immediately to the running editor. Persist them after an active
gesture or text entry finishes, rather than writing on every intermediate drag
@@ -55,7 +59,7 @@ The controller compares three values per known key: the last successfully applie
snapshot, current local preferences, and freshly read external preferences. It
merges changes to different keys, accepts identical concurrent changes, and
rejects conflicting changes to the same key. Failure does not advance the
-baseline or overwrite the file. Explicit Reload adopts valid file values and
+baseline or overwrite stored data. Explicit Reload adopts valid stored values and
discards pending local preference edits. Invalid input retains active preferences
and exposes an actionable error.
@@ -75,20 +79,23 @@ accent may adjust lightness for contrast in each appearance while preserving
the saved RGB value. Applying either
appearance preference changes only presentation, not the document or history.
-## File contract
+## JSON contract
-The macOS adapter uses `~/Library/Application Support/N3/settings.json`. The
-application does not search the current directory for settings. A plain UTF-8
-JSON object uses dotted keys. Unknown values are retained during UI writes;
+A plain UTF-8 JSON object uses dotted keys. Unknown values are retained during UI writes;
known keys are validated before application. Duplicate keys, invalid known types
or values, and input over 1 MiB are rejected. Comments and trailing commas are
not supported. The [generated guide](../guide/settings.md) contains defaults and
allowed values sourced from the settings model.
-Documentation replay uses an explicit Dark appearance for stable existing
+Documentation replay uses an explicit Light appearance for stable existing
images. The settings feature deliberately selects Light and Dark before its
respective captures; tests use isolated stores, never the host's actual file.
+## Native file persistence
+
+The macOS adapter uses `~/Library/Application Support/N3/settings.json`. The
+application does not search the current directory for settings.
+
Writes use a sibling temporary file, preserve existing permissions, and replace
the destination only after checking its expected bytes. Cooperating N3 writers
serialize through a sidecar lock. Creating a missing file must not overwrite a
diff --git a/docs/baselines/linux-vulkan-lavapipe.json b/docs/baselines/linux-vulkan-lavapipe.json
index d447788..b9132e3 100644
--- a/docs/baselines/linux-vulkan-lavapipe.json
+++ b/docs/baselines/linux-vulkan-lavapipe.json
@@ -2,7 +2,7 @@
"version": 1,
"renderer": "linux-vulkan-lavapipe",
"canonical_renderer": "macos-metal",
- "canonical_guide_sha256": "6dfc9b2fbe0188b70c03933d4db501f308d7ad40fa12db5c5bec00a3be01e421",
+ "canonical_guide_sha256": "cc7355f856d22adcee6f3d3eaa7e6bbac6c4e54a05efeebeeefb758f9fdcbc4a",
"artifacts": [
{
"path": "2d-ruler.md",
@@ -586,13 +586,13 @@
},
{
"path": "manifest.txt",
- "bytes": 412206,
- "sha256": "a687f5453f07a0cd9c1e8b80ee705efb991102b98a051b3c28ca9e684af10ec8"
+ "bytes": 414413,
+ "sha256": "3a32c409bb90f84ca365f7e9d001b4e8462d3aae0d11d56c0663f59e22cbf97a"
},
{
"path": "navigation.md",
- "bytes": 4618,
- "sha256": "bfda019826ab2e2d41d02c1de85433d8506970fa11dfa76299c0771f28fd58ca"
+ "bytes": 4699,
+ "sha256": "2d580ce151767d7e96e97c32b7475e96f1194e34304cd53a8ee5165d42fb2fbf"
},
{
"path": "numeric-transforms.md",
@@ -626,8 +626,8 @@
},
{
"path": "settings.md",
- "bytes": 5129,
- "sha256": "c4f97b286ced7b58aa2b308fd63d5dfadb06a9b3fc3f0042fff4c750d516816d"
+ "bytes": 5246,
+ "sha256": "373b6bb038c35ac502dfd28b68f04bf77d3f88e3565ab8ed11c6a3b948fa3e41"
},
{
"path": "shading.md",
@@ -641,8 +641,8 @@
},
{
"path": "terminal.md",
- "bytes": 2192,
- "sha256": "8c9508bd62bcc21de6d7c9038cd81330fe5bf0ccef94f82f4d2fafc30e76c856"
+ "bytes": 2265,
+ "sha256": "49692389ef06088589f38c0bd40a64d4fad30d85e9cf35793abcf9878e12adc1"
},
{
"path": "toasts.md",
diff --git a/docs/development.md b/docs/development.md
index 3a57033..325bcad 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -61,10 +61,11 @@ tests. `just ci` still runs the complete sequence in one container, and native
`just verify`, `just test`, and the pre-push hook always retain the full suite.
Hosted jobs cache dependency downloads and compiled build output under keys
-that include their platform, pinned toolchain/environment, and Cargo manifests.
+that include their platform, pinned toolchain/environment, Cargo manifests, and
+vendored dependency source.
Incremental compilation output, generated guide artifacts, renderer receipts,
diagnostics, and account files are excluded. Pull requests can restore caches;
-only successful checks and native jobs on pushes to `main` save them. The guide
+only successful checks, native, and WASM jobs on pushes to `main` save them. The guide
job restores the shared Ubuntu cache without publishing it. Cache hits still
execute every check, including the exact guide replay. The macOS job also caches
the pinned `just` installation while retaining its ordinary installation command.
@@ -74,6 +75,214 @@ review files keep their host ownership. The runner mounts isolated passwd/group
entries read-only from its cache; the terminal backend needs a resolvable account
even when its controlled shell is explicitly configured.
+## Browser build
+
+The browser target uses the same package with an additional WASM library entry
+point. The native application, documentation renderer, terminal backend, and
+filesystem host remain target-specific. Both hosts use the same workspace and
+user guide; host capabilities omit or disable unsupported controls.
+Read [platform boundaries and performance](architecture/platform-boundaries.md)
+before extending the port or changing host ownership.
+
+```sh
+just web-setup
+just web-check
+just web-verify
+just web-build
+just web
+```
+
+`web-setup` installs the `wasm32-unknown-unknown` target for the repository's Rust
+toolchain and the matching `wasm-bindgen-cli` version, 0.2.129, under
+`.cache/web-tools`. `web-check` checks the library for that target with the locked
+dependency graph. `web-build` uses the `web` Cargo profile and writes the static
+application to ignored `build/web/`, including `pkg/n3.js`, `pkg/n3_bg.wasm`, and
+the bundled font licenses.
+It requires no Node, bundler, React installation, Docker, or application server.
+
+`web-verify` runs the wrapper lifecycle regressions with Node.js 24, Clippy with
+warnings denied for the WASM library, and the complete static-site build. A
+dedicated Ubuntu WASM CI job installs the optional tooling and runs this gate;
+it supplements the native jobs without requiring a browser GPU on that runner.
+
+The pinned `egui-winit` 0.36.2 adapter requires narrow WASM compatibility patches
+for its dropped-file trait and browser OS modifier mapping. The maintained source, upstream
+licenses, and exact change are recorded in
+[vendor/egui-winit/N3-PATCH.md](../vendor/egui-winit/N3-PATCH.md). Native behavior
+is preserved; browser file contents enter through N3's selected-byte adapter.
+
+`just web` builds and serves that directory on localhost port 8000, then opens
+the browser. To select another port or leave browser opening to another tool:
+
+```sh
+just web --no-open --port 8001
+```
+
+Keep the server running while using the application; Ctrl-C stops it. A desktop
+browser with WebGPU enabled and available is required. Remote hosting requires
+HTTPS; opening the generated HTML as a local `file:` URL is not the supported
+launch path. The output is static and can be copied to an appropriate host when
+deployment is separately requested.
+
+Target compilation and browser runtime checks are separate from `just verify`.
+The native verification command continues to check shared behavior and the
+strict documentation baseline. Browser runtime evidence must additionally record
+successful WebGPU initialization, rendering, interaction, file round-tripping,
+and lifecycle behavior on the browser actually tested. These host checks use the
+same feature contract; they do not create a second guide or media baseline.
+
+Include a fresh-load input check before switching focus away from the canvas or
+using controls in an embedding page. Move the pointer into the viewport, use
+Shift+I to open Insert, create an object, click empty space to deselect, and
+double-click the object to enter edit mode. Check held Z and Space through the
+shared shading and navigation behavior, then verify focus loss cancels held
+input and returning to the canvas restores ordinary interaction. Repeat with
+multiple objects to check selection changes. Using wrapper buttons first can
+hide a startup focus bug by causing an extra blur/focus transition.
+
+The browser host initializes egui focus from the current window after asynchronous
+WebGPU setup; the canvas may already have received its initial focus event before
+the input adapter exists. Keep subsequent focus events ordered through the usual
+adapter. The local server disables HTTP caching, but an already-open tab retains
+its loaded WASM until reloaded after a rebuild.
+
+### Browser gestures
+
+The browser host captures wheel and WebKit gesture events on its canvas, then
+passes them through [browser_navigation](../src/input/browser_navigation.rs)
+into the shared [navigation router](../src/input/navigation_events.rs). The
+router retains viewport, focus, popup, and active-edit ownership. Canvas capture
+prevents the browser's default page zoom for an editor pinch; it does not install
+page-wide gesture handlers. Ordinary scrolling over egui controls remains UI
+input rather than camera input.
+
+Browsers encode trackpad pinch as a wheel event with `ctrlKey`, or as WebKit's
+cumulative `scale` and `rotation`. The wheel flag identifies the zoom event; it
+must not become a synthetic held keyboard Control modifier. A real Control-wheel
+combination is indistinguishable from pinch and also zooms. WebKit samples become
+relative zoom and rotation deltas; events from the same gesture must not also
+reach the camera through winit's wheel path.
+
+Pixel deltas use CSS pixels converted to egui logical points, independently of
+the display's backing-pixel ratio. Line deltas retain wheel-line units; page
+deltas use the canvas's CSS height. Browsers provide no reliable wheel-device
+identity: unmodified pixel scrolling uses the shared precise-scroll policy,
+including when a high-resolution mouse produces it. The adapter does not infer
+finger or momentum start/end phases from timing. WebKit exposes rotation;
+Chromium's pinch-wheel events contain no twist information.
+
+The shared navigation scenario exercises these conversions and ownership rules.
+DOM delivery, browser default prevention, and physical trackpad feel require
+browser testing; native replay alone cannot establish them.
+
+### Embedding
+
+The framework-neutral [wrapper](../web/n3.js) exports
+`mountN3(container, { onState, onError })`. It creates and sizes the canvas, awaits
+GPU startup, and returns a controller:
+
+| Method | Behavior |
+| ------------------------ | ------------------------------------------------------------------------------------------- |
+| `command(id)` | Dispatch a semantic action, such as `insert.cube` or `history.undo`, then focus the canvas. |
+| `open(file, { append })` | Read a browser `File` and confirm replacement when needed; `append` defaults to `false`. |
+| `download(filename)` | Request a document download; the default name is `Untitled.n3.json`. |
+| `documentJSON()` | Return serialized document text. |
+| `snapshot()` | Return the parsed status object. |
+| `destroy()` | Stop the editor, disconnect observation, and remove listeners and the canvas. |
+
+The controller exposes its `canvas`. `onState` receives status changes including
+object and selection counts, dirty state, dimensions, and errors; callbacks run
+after the Rust state borrow is released. A JavaScript or React application may
+own surrounding UI while Rust retains workspace behavior and document editing.
+This experimental bridge does not expose arbitrary document mutation, GPU
+resources, or a stable UI-independent editor API.
+
+```js
+import { mountN3 } from "/n3/n3.js";
+
+const editor = await mountN3(document.querySelector("#editor"), {
+ onState: (state) => console.log(state.objects, state.dirty),
+ onError: (error) => console.error(error),
+});
+```
+
+Serve `n3.js` and its generated `pkg/` together under `/n3/` in this example.
+Give the container an explicit usable size. Preserve the wrapper's file
+confirmation and teardown behavior when extending it.
+
+The lower-level generated `start(canvas)` export returns a `WebApp` handle and
+initializes the GPU asynchronously. The canvas emits `n3-ready` or `n3-error`;
+methods require a ready application. Its methods are `command(id)`,
+`load_file(name, bytes, append)`, `document_json()`, `new_document()`,
+`settings_json()`, `snapshot()`, `resize(width, height)`, and `destroy()`.
+Direct callers must resolve unsaved changes before replacing the document.
+
+Mounting is asynchronous: unmounting before startup completes must still destroy
+the eventual controller. The baseline supports one editor lifetime per WASM
+module; destroying it does not establish that the module can mount again.
+Direct React effects that remount, including development StrictMode, therefore
+require additional lifecycle work. Ordinary React mounting and removal can use
+an iframe, which gives each frame its own module lifetime:
+
+```jsx
+export function N3Panel() {
+ return (
+
+ );
+}
+```
+
+A cross-frame command/state bridge is not implemented. Direct mounting provides
+that integration in a persistent page; multiple editors within one module need
+a separate lifecycle design and verification.
+
+### Browser host constraints
+
+The host accepts selected bytes for `.n3.json`, OBJ, and self-contained glTF/GLB.
+Parsing and unit conversion remain in `asset_io`. Selected filenames are labels,
+not directory access: external buffers, textures, and linked source files need a
+resource resolution workflow. External asset resources fail explicitly; native
+documents preserve unresolved references for recovery. Saving does not embed
+imported assets or make those references portable.
+
+Downloads cannot confirm durable storage or promise an in-place overwrite or
+native save-conflict detection. The dirty indicator therefore remains set after
+a download request. The wrapper confirms destructive replacement and requests a
+`beforeunload` warning, subject to
+[browser restrictions](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event);
+this is not a recovery store.
+
+Preferences use the shared typed controller and an origin-local store, separate
+from documents and history. A different host or port has a different store.
+Storage can be [denied, cleared, or temporary](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage);
+failures remain visible without preventing editing. The read-before-write merge
+check is optimistic and has no atomic cross-tab lock.
+Local edits, storage events from other tabs, and focus recovery trigger merges
+at safe input boundaries. Successful synchronization has no periodic polling;
+storage failures retry with a bounded backoff. The native filesystem store keeps
+its independent synchronization policy.
+
+- WebGPU requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API)
+ and a compatible browser, device, and policy. There is no WebGL fallback;
+ initialization failures remain visible.
+- The host cannot launch a local shell/PTY or open a settings file in an external
+ editor. Those controls are omitted from the shared UI.
+- Browser-reserved shortcuts may not reach the canvas. Focused-workspace input
+ must preserve surrounding page controls. The gesture adapter above maps the
+ events the browser exposes without claiming native phase or momentum parity.
+- The egui adapter uses an internal clipboard fallback; external clipboard
+ integration requires a browser adapter.
+- The application runs on the main thread. Large decoding and geometry work can
+ interrupt responsiveness; workers and shared memory require separate work.
+- Touch, IME, accessibility, physical trackpads, and browser/GPU coverage remain
+ review gates. A desktop smoke test does not establish these capabilities.
+- One canvas is the presentation unit. Native multi-window delivery and multiple
+ editors in one module remain outside the baseline.
+
## Formatting
Native contributor formatting additionally uses Node.js 24/npm with `npx`.
@@ -128,10 +337,11 @@ It runs `just verify` and checks that the checkout did not change during the run
Switch to the branch to be verified before pushing it. Deletion-only pushes have
no source to verify. The hook never rewrites files or updates generated baselines.
-`just verify` runs Oxfmt and rustfmt checks, Clippy with warnings denied, all
-Python tooling tests, and all Rust tests on the host. The Rust suite includes
+`just verify` runs Oxfmt and rustfmt checks, Clippy with warnings denied, Python
+tooling tests, JavaScript wrapper regressions, and all Rust tests on the host. The Rust suite includes
required documentation replay and exact artifact comparisons. `just test`
-forwards optional Cargo test arguments; `just tools-test` runs the Python suite.
+forwards optional Cargo test arguments; `just tools-test` runs the Python and
+JavaScript suites.
Checks never update the guide, including when a renderer or driver changes.
The optional [CI image](../tools/ci/Dockerfile) fixes Ubuntu 24.04 on
@@ -141,6 +351,13 @@ selects accurate sqrt for sRGB conversion and prevents cached 128-bit code from
bypassing that choice. The [baseline contract](baselines/README.md) records the
cross-host evidence and diagnostic commands. It requests `N3_DOCS_RENDERER=lavapipe` explicitly;
normal developer commands select the native backend without that setting.
+The opt-in container uses one Rust test thread. Default parallel execution
+reproduced allocator aborts and a segmentation fault among GPU-backed tests on
+both the committed native baseline and the browser port. The underlying fault
+is not established; serial test scheduling is the contained CI policy. All
+tests and exact captures still execute, and native `just verify` retains the
+host's ordinary concurrency. Explicit `ci-test` arguments can override test
+thread count for diagnosis.
Updating a rendering profile requires reviewing its generated output. A Docker
build alone does not establish that the guide agrees with its baseline.
diff --git a/docs/guide/manifest.txt b/docs/guide/manifest.txt
index 8a89659..ae53fa1 100644
--- a/docs/guide/manifest.txt
+++ b/docs/guide/manifest.txt
@@ -130,6 +130,8 @@ assert: Reload settings emits an explicit host request
assert: Invalid JSON leaves active preferences and file contents intact and supplies a visible error
assert: The user can retry Reload after repairing their settings file
assert: Settings reload, persistence, and recovery leave document geometry and edit history untouched
+assert: Hosts without external settings-file access omit that control from the same Preferences window
+assert: Reload remains available through the shared control when settings use browser storage
image: assets/settings-dark.webp (71624 bytes)
input: clean
image: assets/settings-invalid-json.webp (88258 bytes)
@@ -454,6 +456,9 @@ assert: Closing the Tool Dock returns its tab bar to the viewport without a seco
assert: Closing and reopening the Tool Dock retains the running shell and its real output
assert: Terminal remains available when the application appearance changes to Dark
assert: Opening, focusing, scrolling, resizing, switching, and closing Tool Dock panels do not alter geometry or add document history
+assert: Hosts without a local shell omit Terminal from the shared Tool Dock and retain Animation
+assert: Semantic dispatch cannot open a Terminal that the host does not support
+assert: Animation remains usable in the shared Tool Dock without Terminal
image: assets/terminal-dark.webp (352142 bytes)
input: clean
image: assets/terminal-light.webp (364580 bytes)
@@ -2473,6 +2478,26 @@ assert: 2D pinch, wheel and Command-scroll magnify around the cursor with the ru
assert: 2D pinch, wheel and Command-scroll magnify around the cursor with the ruler visible or hidden
assert: 2D pinch, wheel and Command-scroll magnify around the cursor with the ruler visible or hidden
assert: Releasing Command restores 2D panning during the scroll gesture
+assert: The navigation walkthrough reaches the browser checks with no pending edit or undo entry
+assert: Browser pinch-wheel applies the same zoom once as a native pinch, without orbiting or holding a synthetic Control key
+assert: Ordinary browser pixel scrolling follows the same 3D orbit and Shift-pan policy as native precise scrolling
+assert: Ordinary browser pixel scrolling follows the same 3D orbit and Shift-pan policy as native precise scrolling
+assert: Browser line-unit wheel input keeps the shared mouse-wheel zoom sensitivity
+assert: A different preset starts the configured camera transition
+assert: The preset transition completes at its configured duration
+assert: The requested preset reaches its exact orthographic axis
+assert: Browser pinch-wheel preserves the point beneath the pointer in 2D
+assert: A WebKit gesture starts with a valid cumulative baseline
+assert: Cumulative WebKit pinch samples compose to their total scale while simultaneous twist leaves 2D unchanged
+assert: Ending a browser gesture discards its cumulative baseline until the next start
+assert: Ordinary browser pixel scrolling pans a 2D view after pinch without a synthetic modifier or sticky zoom action
+assert: Browser page-unit scrolling uses the canvas CSS height and the shared 2D pan policy
+assert: A different preset starts the configured camera transition
+assert: The preset transition completes at its configured duration
+assert: The perspective preset restores perspective projection
+assert: A new WebKit gesture starts independently
+assert: A browser that exposes cumulative twist rotates the 3D view once with the shared direction and sensitivity
+assert: Browser navigation preserves authored geometry, selection and dirty state and creates no undo entry
image: assets/navigation-aligned-trackpad.webp (53482 bytes)
input: tutorial @ 2.001s: focused=true; pointer=970.0,534.5; cursor=Default; held_mouse=[]; held_keys=[]; released_mouse=none; released_keys=none; gesture=Scroll +18.0, -12.0 Point/600ms; trail=0
image: assets/navigation-context-menu.webp (469014 bytes)
diff --git a/docs/guide/navigation.md b/docs/guide/navigation.md
index ea9d813..7f1cc54 100644
--- a/docs/guide/navigation.md
+++ b/docs/guide/navigation.md
@@ -48,7 +48,7 @@ keeping the current pan and zoom. Manual orbit continues from the visible
angle; it does not perform that recall. The gizmo preferences configure the
3D tab's return behavior.
-Pinching and a mouse wheel zoom. In **2D mode**, zoom keeps the point beneath
+Pinching and a mouse wheel zoom the viewport. In **2D mode**, zoom keeps the point beneath
the pointer in place. Command + two-finger scrolling also zooms
around the pointer; ordinary two-finger scrolling pans. In **3D mode**, zoom
stays centered on the camera target.
@@ -57,7 +57,8 @@ A two-finger twist is ignored in **2D**, so a
twist during panning or pinching cannot switch the view to 3D. In **3D**, twisting
rotates the view. Panning
and zooming preserve the viewing direction. These trackpad bindings work in
-object and vertex edit modes.
+object and vertex edit modes. Twisting is available only when the host exposes
+rotation gestures.
1. Press Shift + 1 or choose N3 → View → Frame in N3 → View to fit and recenter
the model and restore its default viewing angle.
diff --git a/docs/guide/settings.md b/docs/guide/settings.md
index 1640c0f..a7ff4f6 100644
--- a/docs/guide/settings.md
+++ b/docs/guide/settings.md
@@ -13,7 +13,7 @@ length inputs show bare numbers, and the chosen unit determines what an
unsuffixed number means. You can still type an explicit unit suffix.
Under Appearance, choose System, Light, or Dark in
-Preferences → Theme. System is the default and follows macOS
+Preferences → Theme. System is the default and follows the host's
appearance changes while N3 is open. Light and Dark keep the chosen appearance
regardless of the OS setting. Use Preferences → Accent color for a personal
highlight color, or Preferences → Reset accent to restore N3's default.
@@ -33,7 +33,8 @@ With an explicit Dark choice:

-Choose Preferences → Open settings.json to open the same preferences as text
+The browser saves preferences in local browser storage and omits the settings-file
+control. In the desktop app, choose Preferences → Open settings.json to open the same preferences as text
in your default text editor. N3 creates the file if it does not exist:
```text
diff --git a/docs/guide/terminal.md b/docs/guide/terminal.md
index 86690b6..74fd910 100644
--- a/docs/guide/terminal.md
+++ b/docs/guide/terminal.md
@@ -2,6 +2,8 @@
# Terminal
+Terminal requires a local shell. Its control is omitted in the browser.
+
Choose Tool Dock tab bar → Terminal in the Tool Dock tab bar to open a local shell
in the Terminal panel. The Tool Dock is the shared container for Animation and
Terminal. Its tab bar floats near the viewport's lower-left when the Tool Dock
diff --git a/docs/templates/navigation.md.in b/docs/templates/navigation.md.in
index d0104c6..99d9716 100644
--- a/docs/templates/navigation.md.in
+++ b/docs/templates/navigation.md.in
@@ -46,7 +46,7 @@ keeping the current pan and zoom. Manual orbit continues from the visible
angle; it does not perform that recall. The gizmo preferences configure the
3D tab's return behavior.
-Pinching and a mouse wheel zoom. In **2D mode**, zoom keeps the point beneath
+Pinching and a mouse wheel zoom the viewport. In **2D mode**, zoom keeps the point beneath
the pointer in place. {{modifier:command}} + two-finger scrolling also zooms
around the pointer; ordinary two-finger scrolling pans. In **3D mode**, zoom
stays centered on the camera target.
@@ -55,7 +55,8 @@ A two-finger twist is ignored in **2D**, so a
twist during panning or pinching cannot switch the view to 3D. In **3D**, twisting
rotates the view. Panning
and zooming preserve the viewing direction. These trackpad bindings work in
-object and vertex edit modes.
+object and vertex edit modes. Twisting is available only when the host exposes
+rotation gestures.
1. Press {{shortcut:view.frame}} or choose {{control:frame}} in {{control:view}} to fit and recenter
the model and restore its default viewing angle.
diff --git a/docs/templates/settings.md.in b/docs/templates/settings.md.in
index a5c3c59..3f0cabc 100644
--- a/docs/templates/settings.md.in
+++ b/docs/templates/settings.md.in
@@ -11,7 +11,7 @@ length inputs show bare numbers, and the chosen unit determines what an
unsuffixed number means. You can still type an explicit unit suffix.
Under Appearance, choose System, Light, or Dark in
-{{control:appearance.theme}}. System is the default and follows macOS
+{{control:appearance.theme}}. System is the default and follows the host's
appearance changes while N3 is open. Light and Dark keep the chosen appearance
regardless of the OS setting. Use {{control:appearance.accent}} for a personal
highlight color, or {{control:appearance.accent.reset}} to restore N3's default.
@@ -31,7 +31,8 @@ With an explicit Dark choice:
{{image:settings-dark}}
-Choose {{control:preferences.settings-json}} to open the same preferences as text
+The browser saves preferences in local browser storage and omits the settings-file
+control. In the desktop app, choose {{control:preferences.settings-json}} to open the same preferences as text
in your default text editor. N3 creates the file if it does not exist:
```text
diff --git a/docs/templates/terminal.md.in b/docs/templates/terminal.md.in
index c90bec1..d7936fd 100644
--- a/docs/templates/terminal.md.in
+++ b/docs/templates/terminal.md.in
@@ -1,5 +1,7 @@
# Terminal
+Terminal requires a local shell. Its control is omitted in the browser.
+
Choose {{control:terminal.toggle}} in the Tool Dock tab bar to open a local shell
in the Terminal panel. The Tool Dock is the shared container for Animation and
Terminal. Its tab bar floats near the viewport's lower-left when the Tool Dock
diff --git a/justfile b/justfile
index 8b14176..02f7bdd 100644
--- a/justfile
+++ b/justfile
@@ -91,6 +91,33 @@ cargo +args:
check *args:
cargo check --locked "$@"
+# Install the optional browser target and the matching local WASM glue generator.
+web-setup:
+ python3 tools/web.py setup
+
+# Check browser compilation without linking or generating the static site.
+web-check:
+ cargo check --locked --target wasm32-unknown-unknown --lib
+
+# Lint the browser library with warnings denied, independently of native lint.
+web-lint:
+ cargo clippy --locked --target wasm32-unknown-unknown --lib -- -D warnings
+
+# Check browser wrapper lifecycle and concurrency without building WASM.
+web-test:
+ node --test tools/tests/web_wrapper.test.mjs
+
+# Build the shared editor as WASM and write a self-contained site to build/web.
+web-build:
+ python3 tools/web.py build
+
+# Optional browser gate: lint Rust and generate runnable WASM/glue output.
+web-verify: web-test web-lint web-build
+
+# Build and serve the browser baseline locally (supports --port and --no-open).
+web *args: web-build
+ python3 tools/web.py serve "$@"
+
# Browse production UI components, or capture their deterministic internal evidence.
workbench *args:
cargo run --locked -- --workbench "$@"
@@ -152,7 +179,7 @@ runner-test:
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tools/tests -p 'test_ci_runner.py'
# Run all development tooling regressions on the native host.
-tools-test:
+tools-test: web-test
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tools/tests -p 'test_*.py'
# Opt in to the pinned Ubuntu CI environment; requires Docker.
diff --git a/rfcs/0002-authored-scenes.md b/rfcs/0002-authored-scenes.md
index d465b2c..321e139 100644
--- a/rfcs/0002-authored-scenes.md
+++ b/rfcs/0002-authored-scenes.md
@@ -62,7 +62,7 @@ These are observations from the local code, not proposed behavior:
display frame, geometry cache, and `EditHistory`.
- [`WorkspaceUi`](../src/ui/workspace_ui.rs) owns one camera/navigation state,
Local View return state, ruler/inspector caches, and asset previews.
-- [`asset_io::load`](../src/asset_io/linked.rs) both loads external content and
+- [`asset_io::load`](../src/asset_io/native/linked.rs) both loads external content and
immediately chooses its document organization. A scene asset becomes one
object referencing `asset.default_scene`.
- Imported `SceneAsset` retains multiple source scenes. An `AssetInstance` stores
@@ -557,14 +557,14 @@ Stages describe the eventual scope. The readiness review above gates proceeding
beyond the initial integrated proof. They are not separate editor implementations
or permission to publish an incomplete persistence change.
-| Current owner | Required change | Main risk |
-| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
-| `model/document.rs`, `asset_geometry.rs` | Scene-scoped geometry/bounds; all-document structural validation; explicit codec migration | Wrong scope silently joins unrelated scenes or drops inactive objects |
-| `editor/mod.rs`, `edit_history.rs`, `geometry_edit.rs` | Explicit target, entry context, session and ID ownership | Redo focuses the wrong scene; primitive provenance leaks across scenes |
-| `ui/workspace_ui.rs`, `local_view.rs`, `asset_instances.rs` | One active scene with separate remembered view/preview state | Camera normalization, held input, Local View, stale selection |
-| `asset_io/linked.rs`, `document.rs`, `native.rs` | All-scene reference traversal; import intent/preparation/publication | Wrong async target, ID collision, partial import, broken Save As links |
-| `render/renderer.rs`, `placed_scenes.rs` | Active-scene submission and bounded cache residency | Mixing display frames or defeating existing Local View caches |
-| Documentation scenarios/builders | Explicit scene targets in setup; production controls in illustrated operations | Tests accidentally keep proving only the former flat document |
+| Current owner | Required change | Main risk |
+| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
+| `model/document.rs`, `asset_geometry.rs` | Scene-scoped geometry/bounds; all-document structural validation; explicit codec migration | Wrong scope silently joins unrelated scenes or drops inactive objects |
+| `editor/mod.rs`, `edit_history.rs`, `geometry_edit.rs` | Explicit target, entry context, session and ID ownership | Redo focuses the wrong scene; primitive provenance leaks across scenes |
+| `ui/workspace_ui.rs`, `local_view.rs`, `asset_instances.rs` | One active scene with separate remembered view/preview state | Camera normalization, held input, Local View, stale selection |
+| `asset_io/native/linked.rs`, `asset_io/native/document.rs`, `native.rs` | All-scene reference traversal; import intent/preparation/publication | Wrong async target, ID collision, partial import, broken Save As links |
+| `render/renderer.rs`, `placed_scenes.rs` | Active-scene submission and bounded cache residency | Mixing display frames or defeating existing Local View caches |
+| Documentation scenarios/builders | Explicit scene targets in setup; production controls in illustrated operations | Tests accidentally keep proving only the former flat document |
History/activation is the highest-risk part. Most geometry math, material
rendering and imported pose evaluation should survive unchanged. This is a
diff --git a/src/asset_io/bytes.rs b/src/asset_io/bytes.rs
new file mode 100644
index 0000000..c0e277e
--- /dev/null
+++ b/src/asset_io/bytes.rs
@@ -0,0 +1,220 @@
+//! Browser file snapshots enter through the same format adapters as native I/O.
+//! No filesystem access, network fetch, or implicit resource discovery occurs.
+use std::{collections::BTreeSet, path::Path, sync::Arc};
+
+use sha2::{Digest, Sha256};
+
+use super::{LoadedDocument, ResourceResolver};
+use crate::document::{AssetInstance, Document, Geometry, Object, Transform};
+
+struct EmbeddedResources;
+
+impl ResourceResolver for EmbeddedResources {
+ fn read(&self, relative_path: &str, _maximum_bytes: usize) -> Result, String> {
+ Err(format!(
+ "External resource {relative_path:?} is unavailable. Open a self-contained GLB or glTF with embedded resources in the browser."
+ ))
+ }
+}
+
+/// Decode one browser-selected file. Native JSON references remain recoverable
+/// authored objects, but resolving a local package requires a future host API.
+/// The caller installs or imports this candidate through the normal editor path.
+pub(crate) fn load_bytes(name: &str, bytes: &[u8]) -> Result {
+ if bytes.len() as u64 > crate::document::MAX_BYTES {
+ return Err("File exceeds the 64 MiB input limit".into());
+ }
+ let path = Path::new(name);
+ if !super::is_supported_path(path) {
+ return Err("Choose an N3 JSON, OBJ, glTF, or GLB file.".into());
+ }
+ let filename = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or("The selected file needs a name.")?;
+ let stem = path
+ .file_stem()
+ .and_then(|stem| stem.to_str())
+ .unwrap_or("Imported object");
+ if super::is_scene_path(path) {
+ let asset = Arc::new(super::gltf::load(filename, bytes, &EmbeddedResources)?);
+ // Filenames are not unique browser resources. Content identity prevents
+ // a second selection named "model.glb" from replacing retained Undo data.
+ // This is a reference only; .n3.json still never embeds imported payloads.
+ let source = format!("browser-assets/{:x}/{filename}", Sha256::digest(bytes));
+ let document = Document {
+ objects: vec![Object {
+ id: 1,
+ name: stem.into(),
+ transform: Transform::default(),
+ geometry: Geometry::Asset(AssetInstance {
+ source: source.clone(),
+ scene: asset.default_scene,
+ }),
+ }],
+ ..Document::default()
+ };
+ document.validate()?;
+ let loaded = LoadedDocument {
+ document,
+ assets: [(source, asset)].into(),
+ diagnostics: Vec::new(),
+ saved_bytes: None,
+ };
+ super::validate_resource_cache(&loaded.assets)?;
+ return Ok(loaded);
+ }
+ let text = std::str::from_utf8(bytes)
+ .map_err(|error| format!("The selected text file is not valid UTF-8: {error}"))?;
+ let is_obj = path
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("obj"));
+ let document = if is_obj {
+ super::obj::parse_obj(text, stem)?
+ } else {
+ Document::from_json(text)?
+ };
+ let missing: BTreeSet<_> = document
+ .objects
+ .iter()
+ .filter_map(|object| match &object.geometry {
+ Geometry::Asset(instance) => Some(&instance.source),
+ _ => None,
+ })
+ .collect();
+ let diagnostics = missing
+ .into_iter()
+ .map(|source| {
+ format!(
+ "Linked asset {source:?} was retained but cannot be resolved from one browser file. Its source data is not embedded in N3 JSON."
+ )
+ })
+ .collect();
+ Ok(LoadedDocument {
+ document,
+ assets: Default::default(),
+ diagnostics,
+ saved_bytes: (!is_obj).then(|| bytes.to_vec()),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::asset_io::load_bytes;
+
+ #[test]
+ fn native_bytes_keep_recipes_exact_baseline_and_missing_links() {
+ let mut document = Document::default();
+ document
+ .insert_primitive(crate::document::PrimitiveKind::Cube)
+ .unwrap();
+ document.objects.push(Object {
+ id: 2,
+ name: "Linked object".into(),
+ transform: Transform::default(),
+ geometry: Geometry::Asset(AssetInstance {
+ source: "../assets/missing.glb".into(),
+ scene: 3,
+ }),
+ });
+ let bytes = format!("{}\n", document.to_json().unwrap()).into_bytes();
+ let loaded = load_bytes("document.n3.json", &bytes).unwrap();
+ assert_eq!(loaded.document, document);
+ assert_eq!(loaded.saved_bytes, Some(bytes));
+ assert!(loaded.assets.is_empty());
+ assert_eq!(loaded.diagnostics.len(), 1);
+ assert!(loaded.diagnostics[0].contains("../assets/missing.glb"));
+ }
+
+ #[test]
+ fn obj_bytes_preserve_polygon_topology_and_document_units() {
+ let loaded = load_bytes(
+ "Plane.OBJ",
+ b"v 0 0 0\nv 1.5 0 0\nv 1.5 2 0\nv 0 2 0\nf 1 2 3 4\n",
+ )
+ .unwrap();
+ let Geometry::Mesh(mesh) = &loaded.document.objects[0].geometry else {
+ panic!("OBJ must remain editable topology");
+ };
+ assert_eq!(loaded.document.objects[0].name, "Plane");
+ assert_eq!(mesh.faces.len(), 1);
+ assert_eq!(mesh.faces[0].vertices.len(), 4);
+ assert_eq!(mesh.vertices[1].position, [1.5, 0.0, 0.0]);
+ assert!(loaded.saved_bytes.is_none());
+ }
+
+ fn triangle_glb(width: f32) -> Vec {
+ let positions = [[0.0f32, 0.0, 0.0], [width, 0.0, 0.0], [0.0, 1.0, 0.0]];
+ let binary: Vec = positions
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_le_bytes())
+ .collect();
+ let mut json = serde_json::to_vec(&serde_json::json!({
+ "asset": {"version": "2.0"},
+ "buffers": [{"byteLength": binary.len()}],
+ "bufferViews": [{"buffer": 0, "byteLength": binary.len()}],
+ "accessors": [{"bufferView": 0, "componentType": 5126, "count": 3,
+ "type": "VEC3", "min": [0, 0, 0], "max": [width, 1, 0]}],
+ "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
+ "nodes": [{"mesh": 0}],
+ "scenes": [{"nodes": [0]}], "scene": 0
+ }))
+ .unwrap();
+ while !json.len().is_multiple_of(4) {
+ json.push(b' ');
+ }
+ let length = 12 + 8 + json.len() + 8 + binary.len();
+ let mut glb = Vec::new();
+ for word in [
+ 0x46546c67u32,
+ 2,
+ length as u32,
+ json.len() as u32,
+ 0x4e4f534a,
+ ] {
+ glb.extend_from_slice(&word.to_le_bytes());
+ }
+ glb.extend(json);
+ glb.extend_from_slice(&(binary.len() as u32).to_le_bytes());
+ glb.extend_from_slice(&0x004e4942u32.to_le_bytes());
+ glb.extend(binary);
+ glb
+ }
+
+ #[test]
+ fn glb_snapshots_decode_and_same_named_imports_have_distinct_identity() {
+ let bytes = triangle_glb(1.0);
+ let first = load_bytes("model.glb", &bytes).unwrap();
+ let again = load_bytes("model.glb", &bytes).unwrap();
+ let changed = load_bytes("model.glb", &triangle_glb(2.0)).unwrap();
+ let first_key = first.assets.keys().next().unwrap();
+ assert_eq!(first_key, again.assets.keys().next().unwrap());
+ assert_ne!(first_key, changed.assets.keys().next().unwrap());
+ assert!(first.saved_bytes.is_none());
+ assert!(first.diagnostics.is_empty());
+ let asset = &first.assets[first_key];
+ assert_eq!(
+ asset.meshes[0].primitives[0].vertices[1].position,
+ [100.0, 0.0, 0.0]
+ );
+ assert!(asset.evaluate(asset.default_scene, None).is_ok());
+ }
+
+ #[test]
+ fn external_gltf_resources_and_invalid_text_fail_without_host_access() {
+ let gltf = br#"{"asset":{"version":"2.0"},"buffers":[{"uri":"data.bin","byteLength":4}],"scenes":[{"nodes":[]}],"scene":0}"#;
+ assert!(
+ load_bytes("scene.gltf", gltf)
+ .unwrap_err()
+ .contains("External resource \"data.bin\" is unavailable")
+ );
+ assert!(
+ load_bytes("bad.n3.json", &[0xff])
+ .unwrap_err()
+ .contains("UTF-8")
+ );
+ assert!(load_bytes("other.txt", b"{}").is_err());
+ }
+}
diff --git a/src/asset_io/gltf/decode.rs b/src/asset_io/gltf/decode.rs
index 0197020..a499354 100644
--- a/src/asset_io/gltf/decode.rs
+++ b/src/asset_io/gltf/decode.rs
@@ -1,10 +1,10 @@
-use std::{collections::BTreeSet, io::Cursor, path::Path};
+use std::{collections::BTreeSet, io::Cursor};
use ::gltf::accessor::Dimensions;
use super::units::length_cm;
use super::*;
-use crate::asset_io::resources::{MAX_FILE_BYTES, MAX_RESOURCE_BYTES, MAX_TOTAL_BYTES, read_file};
+use crate::asset_io::resources::{MAX_FILE_BYTES, MAX_RESOURCE_BYTES, MAX_TOTAL_BYTES};
use crate::scene::validate::hierarchy_roots;
const SUPPORTED_EXTENSIONS: &[&str] = &[
@@ -13,22 +13,6 @@ const SUPPORTED_EXTENSIONS: &[&str] = &[
"KHR_texture_transform",
];
-pub(crate) fn load_path(path: &Path) -> Result {
- let bytes = read_file(path, MAX_FILE_BYTES)?;
- let resolver = FileResolver::new(
- path.parent()
- .filter(|path| !path.as_os_str().is_empty())
- .unwrap_or_else(|| Path::new(".")),
- )?;
- super::load(
- path.file_name()
- .and_then(|name| name.to_str())
- .unwrap_or("glTF scene"),
- &bytes,
- &resolver,
- )
-}
-
pub(crate) fn load(
name: &str,
bytes: &[u8],
diff --git a/src/asset_io/gltf/fingerprint_tests.rs b/src/asset_io/gltf/fingerprint_tests.rs
index 82640bb..5f81f66 100644
--- a/src/asset_io/gltf/fingerprint_tests.rs
+++ b/src/asset_io/gltf/fingerprint_tests.rs
@@ -1,5 +1,6 @@
//! Portable input fingerprints isolate CPU preparation from renderer drift.
use super::*;
+use crate::asset_io::native::load_scene_path as load_path;
use sha2::{Digest, Sha256};
use std::path::Path;
diff --git a/src/asset_io/gltf/mod.rs b/src/asset_io/gltf/mod.rs
index 186a766..794c2fc 100644
--- a/src/asset_io/gltf/mod.rs
+++ b/src/asset_io/gltf/mod.rs
@@ -7,9 +7,9 @@ mod materials;
mod resources;
mod units;
-use crate::asset_io::{FileResolver, ResourceResolver};
+use crate::asset_io::ResourceResolver;
use crate::scene::*;
-pub(crate) use decode::{load, load_path};
+pub(crate) use decode::load;
use glam::{DMat4, DQuat, DVec3};
#[cfg(test)]
diff --git a/src/asset_io/gltf/tests.rs b/src/asset_io/gltf/tests.rs
index 0c963b1..0aaa84e 100644
--- a/src/asset_io/gltf/tests.rs
+++ b/src/asset_io/gltf/tests.rs
@@ -4,6 +4,8 @@ use base64::Engine;
use serde_json::{Value, json};
use super::*;
+use crate::asset_io::FileResolver;
+use crate::asset_io::native::load_scene_path as load_path;
#[derive(Default)]
struct Memory(BTreeMap>);
diff --git a/src/asset_io/linked.rs b/src/asset_io/linked.rs
index 55d3740..9b09ce4 100644
--- a/src/asset_io/linked.rs
+++ b/src/asset_io/linked.rs
@@ -2,25 +2,16 @@
//! The document owns placement and source identity, not decoded resources. Load
//! resolves sources once; reopening sees current source bytes. There is no live
//! refresh, source rewrite, or embedded glTF package in the native JSON format.
-use std::{
- collections::{BTreeMap, BTreeSet},
- fs,
- path::{Component, Path, PathBuf},
- sync::Arc,
-};
-
-use crate::{
- document::{AssetInstance, Document, Geometry, Object, Transform},
- scene::SceneAsset,
-};
+use crate::{document::Document, scene::SceneAsset};
+use std::{collections::BTreeMap, sync::Arc};
type Result = std::result::Result;
-const MAX_LINKED_SOURCES: usize = 64;
-const MAX_LINKED_PAYLOAD_BYTES: usize = 512 * 1024 * 1024;
+pub(super) const MAX_LINKED_SOURCES: usize = 64;
+pub(super) const MAX_LINKED_PAYLOAD_BYTES: usize = 512 * 1024 * 1024;
#[derive(Debug)]
pub(crate) struct LoadedDocument {
- /// Asset sources are absolute in memory, independent of later Save As paths.
+ /// Native sources are absolute paths; browser sources have snapshot identity.
pub document: Document,
/// Keys match `AssetInstance::source`. Duplicates share decoded resources.
pub assets: BTreeMap>,
@@ -30,126 +21,12 @@ pub(crate) struct LoadedDocument {
pub saved_bytes: Option>,
}
-/// Open and import use the same decoding result. The host decides whether to
-/// replace the active document or append its objects through one editor edit.
-pub(crate) fn load(path: &Path) -> Result {
- if super::is_scene_path(path) {
- let source = absolute_reference(path)?;
- let asset = Arc::new(super::load_scene_path(Path::new(&source))?);
- if decoded_payload_bytes(&asset)? > MAX_LINKED_PAYLOAD_BYTES {
- return Err("Asset exceeds the 512 MiB linked payload budget.".into());
- }
- let name = path
- .file_stem()
- .and_then(|name| name.to_str())
- .unwrap_or("Imported asset")
- .to_owned();
- let document = Document {
- objects: vec![Object {
- id: 1,
- name,
- transform: Transform::default(),
- geometry: Geometry::Asset(AssetInstance {
- source: source.clone(),
- scene: asset.default_scene,
- }),
- }],
- ..Document::default()
- };
- document.validate()?;
- return Ok(LoadedDocument {
- document,
- assets: BTreeMap::from([(source, asset)]),
- diagnostics: Vec::new(),
- saved_bytes: None,
- });
- }
-
- let is_obj = path
- .extension()
- .is_some_and(|extension| extension.eq_ignore_ascii_case("obj"));
- let (document, saved_bytes) = if is_obj {
- (super::obj::load_path(path)?, None)
- } else {
- // One read supplies both the decoded document and the conflict baseline.
- let text = super::document::read_text(path)?;
- (Document::from_json(&text)?, Some(text.into_bytes()))
- };
- let base = absolute_parent(path)?;
- resolve(document, &base, saved_bytes)
-}
-
-fn resolve(
- mut document: Document,
- directory: &Path,
- saved_bytes: Option>,
-) -> Result {
- let mut assets = BTreeMap::new();
- let mut diagnostics = Vec::new();
- let mut attempted = BTreeSet::new();
- let mut retained_payload = 0usize;
- for object in &mut document.objects {
- let Geometry::Asset(instance) = &mut object.geometry else {
- continue;
- };
- let path = directory.join(&instance.source);
- instance.source = absolute_reference(&path)?;
- if attempted.insert(instance.source.clone()) {
- let result = if attempted.len() > MAX_LINKED_SOURCES {
- Err(format!(
- "Document exceeds the {MAX_LINKED_SOURCES} linked-source loading limit."
- ))
- } else if !super::is_scene_path(Path::new(&instance.source)) {
- Err("Linked assets must be glTF or GLB files.".to_owned())
- } else {
- super::load_scene_path(Path::new(&instance.source))
- };
- match result {
- Ok(asset) => {
- let bytes = decoded_payload_bytes(&asset)?;
- if let Some(total) = retained_payload
- .checked_add(bytes)
- .filter(|total| *total <= MAX_LINKED_PAYLOAD_BYTES)
- {
- retained_payload = total;
- assets.insert(instance.source.clone(), Arc::new(asset));
- } else {
- diagnostics.push(format!(
- "Cannot load linked asset {:?}: document exceeds the 512 MiB linked payload budget.",
- instance.source
- ));
- }
- }
- Err(error) => diagnostics.push(format!(
- "Cannot load linked asset {:?}: {error}",
- instance.source
- )),
- }
- }
- if let Some(asset) = assets.get(&instance.source)
- && let Err(error) = asset.evaluate(instance.scene, None)
- {
- diagnostics.push(format!(
- "Cannot evaluate linked object {:?}, scene {}: {error}",
- object.name, instance.scene
- ));
- }
- }
- document.validate()?;
- Ok(LoadedDocument {
- document,
- assets,
- diagnostics,
- saved_bytes,
- })
-}
-
/// Charge dominant retained decoded payloads across unique sources. Each source
/// also has its own parser/evaluator budgets; this prevents many individually
/// valid assets from multiplying retained buffers without a document-wide bound.
/// This is a payload budget, not a claim about total process/GPU memory. The
/// unified evaluator and renderer separately bound placed instances and draws.
-fn decoded_payload_bytes(asset: &SceneAsset) -> Result {
+pub(super) fn decoded_payload_bytes(asset: &SceneAsset) -> Result {
let mut bytes = asset.cached_frame_payload_bytes()?;
let mut add = |count: usize, size: usize| {
bytes = bytes.saturating_add(count.saturating_mul(size));
@@ -200,7 +77,7 @@ pub(crate) fn validate_resource_cache(assets: &BTreeMap>
validate_resource_cache_with_budget(assets, MAX_LINKED_PAYLOAD_BYTES)
}
-fn validate_resource_cache_with_budget(
+pub(super) fn validate_resource_cache_with_budget(
assets: &BTreeMap>,
maximum_payload: usize,
) -> Result<()> {
@@ -218,88 +95,3 @@ fn validate_resource_cache_with_budget(
}
Ok(())
}
-
-/// Source normalization affects the newly loaded snapshot only. Existing parent
-/// directories are resolved to avoid interpreting `..` across a symlink wrongly.
-/// The final file is not canonicalized: a symlink asset remains subject to the
-/// loader's regular-file policy. Missing parents retain their anchored path.
-fn absolute_reference(path: &Path) -> Result {
- let mut absolute = std::path::absolute(path).map_err(|error| error.to_string())?;
- if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
- && let Ok(parent) = fs::canonicalize(parent)
- {
- absolute = parent.join(name);
- }
- absolute
- .to_str()
- .map(str::to_owned)
- .ok_or_else(|| "Asset paths must be valid UTF-8 to persist in an N3 document.".into())
-}
-
-fn absolute_parent(path: &Path) -> Result {
- let absolute = std::path::absolute(path).map_err(|error| error.to_string())?;
- let parent = absolute
- .parent()
- .ok_or("Document path needs a directory.")?;
- fs::canonicalize(parent).map_err(|error| format!("Cannot resolve document directory: {error}"))
-}
-
-pub(super) fn snapshot_for_save(document: &Document, destination: &Path) -> Result {
- let mut snapshot = document.clone();
- // Documents without links keep the existing persistence behavior, including
- // its useful destination/create errors, without requiring path rebasing.
- if !snapshot
- .objects
- .iter()
- .any(|object| matches!(object.geometry, Geometry::Asset(_)))
- {
- return Ok(snapshot);
- }
- let directory = absolute_parent(destination)?;
- for object in &mut snapshot.objects {
- let Geometry::Asset(instance) = &mut object.geometry else {
- continue;
- };
- let source = Path::new(&instance.source);
- if !source.is_absolute() {
- return Err("Resolve relative asset references before saving the document.".into());
- }
- if let Some(relative) = relative_to(source, &directory) {
- instance.source = relative
- .to_str()
- .ok_or("Asset paths must be valid UTF-8.")?
- .to_owned();
- }
- }
- Ok(snapshot)
-}
-
-/// Both paths are absolute. Preserve source components (including unresolved
-/// missing-directory `..`) and fall back to an absolute reference across roots.
-fn relative_to(source: &Path, directory: &Path) -> Option {
- let source: Vec<_> = source.components().collect();
- let directory: Vec<_> = directory.components().collect();
- let common = source
- .iter()
- .zip(&directory)
- .take_while(|(a, b)| a == b)
- .count();
- if common == 0
- || source[common..]
- .iter()
- .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir))
- {
- return None;
- }
- let mut relative = PathBuf::new();
- for _ in &directory[common..] {
- relative.push("..");
- }
- for component in &source[common..] {
- relative.push(component.as_os_str());
- }
- Some(relative)
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/src/asset_io/mod.rs b/src/asset_io/mod.rs
index 5231dfe..97993ef 100644
--- a/src/asset_io/mod.rs
+++ b/src/asset_io/mod.rs
@@ -1,13 +1,19 @@
-//! Native open/import dispatch and linked resources for the authored document.
-pub(crate) mod document;
+//! Host open/import dispatch and linked resources for the authored document.
+#[cfg(any(target_arch = "wasm32", test))]
+mod bytes;
pub(crate) mod gltf;
mod linked;
+#[cfg(not(target_arch = "wasm32"))]
+mod native;
pub(crate) mod obj;
mod resources;
-pub(crate) use gltf::load_path as load_scene_path;
-pub(crate) use linked::{LoadedDocument, load, validate_resource_cache};
-pub(crate) use resources::{FileResolver, ResourceResolver};
+#[cfg(any(target_arch = "wasm32", test))]
+pub(crate) use bytes::load_bytes;
+pub(crate) use linked::{LoadedDocument, validate_resource_cache};
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) use native::{FileResolver, document, load};
+pub(crate) use resources::ResourceResolver;
use std::path::Path;
pub(crate) fn is_scene_path(path: &Path) -> bool {
diff --git a/src/asset_io/document.rs b/src/asset_io/native/document.rs
similarity index 99%
rename from src/asset_io/document.rs
rename to src/asset_io/native/document.rs
index 181fcbc..41f7f45 100644
--- a/src/asset_io/document.rs
+++ b/src/asset_io/native/document.rs
@@ -26,7 +26,7 @@ pub(crate) fn load_path(path: &Path) -> Result {
.and_then(|s| s.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case("obj"))
{
- super::obj::load_path(path)
+ super::load_obj_path(path)
} else {
Document::from_json(&read_text(path)?)
}
diff --git a/src/asset_io/native/linked.rs b/src/asset_io/native/linked.rs
new file mode 100644
index 0000000..577502e
--- /dev/null
+++ b/src/asset_io/native/linked.rs
@@ -0,0 +1,215 @@
+//! Native linked-source snapshots and serialization-only reference rebasing.
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs,
+ path::{Component, Path, PathBuf},
+ sync::Arc,
+};
+
+use crate::{
+ asset_io::linked::{
+ LoadedDocument, MAX_LINKED_PAYLOAD_BYTES, MAX_LINKED_SOURCES, decoded_payload_bytes,
+ },
+ document::{AssetInstance, Document, Geometry, Object, Transform},
+};
+
+type Result = std::result::Result;
+
+/// Open and import use the same decoding result. The host decides whether to
+/// replace the active document or append its objects through one editor edit.
+pub(crate) fn load(path: &Path) -> Result {
+ if crate::asset_io::is_scene_path(path) {
+ let source = absolute_reference(path)?;
+ let asset = Arc::new(super::load_scene_path(Path::new(&source))?);
+ if decoded_payload_bytes(&asset)? > MAX_LINKED_PAYLOAD_BYTES {
+ return Err("Asset exceeds the 512 MiB linked payload budget.".into());
+ }
+ let name = path
+ .file_stem()
+ .and_then(|name| name.to_str())
+ .unwrap_or("Imported asset")
+ .to_owned();
+ let document = Document {
+ objects: vec![Object {
+ id: 1,
+ name,
+ transform: Transform::default(),
+ geometry: Geometry::Asset(AssetInstance {
+ source: source.clone(),
+ scene: asset.default_scene,
+ }),
+ }],
+ ..Document::default()
+ };
+ document.validate()?;
+ return Ok(LoadedDocument {
+ document,
+ assets: BTreeMap::from([(source, asset)]),
+ diagnostics: Vec::new(),
+ saved_bytes: None,
+ });
+ }
+
+ let is_obj = path
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("obj"));
+ let (document, saved_bytes) = if is_obj {
+ (super::load_obj_path(path)?, None)
+ } else {
+ // One read supplies both the decoded document and the conflict baseline.
+ let text = super::document::read_text(path)?;
+ (Document::from_json(&text)?, Some(text.into_bytes()))
+ };
+ let base = absolute_parent(path)?;
+ resolve(document, &base, saved_bytes)
+}
+
+fn resolve(
+ mut document: Document,
+ directory: &Path,
+ saved_bytes: Option>,
+) -> Result {
+ let mut assets = BTreeMap::new();
+ let mut diagnostics = Vec::new();
+ let mut attempted = BTreeSet::new();
+ let mut retained_payload = 0usize;
+ for object in &mut document.objects {
+ let Geometry::Asset(instance) = &mut object.geometry else {
+ continue;
+ };
+ let path = directory.join(&instance.source);
+ instance.source = absolute_reference(&path)?;
+ if attempted.insert(instance.source.clone()) {
+ let result = if attempted.len() > MAX_LINKED_SOURCES {
+ Err(format!(
+ "Document exceeds the {MAX_LINKED_SOURCES} linked-source loading limit."
+ ))
+ } else if !crate::asset_io::is_scene_path(Path::new(&instance.source)) {
+ Err("Linked assets must be glTF or GLB files.".to_owned())
+ } else {
+ super::load_scene_path(Path::new(&instance.source))
+ };
+ match result {
+ Ok(asset) => {
+ let bytes = decoded_payload_bytes(&asset)?;
+ if let Some(total) = retained_payload
+ .checked_add(bytes)
+ .filter(|total| *total <= MAX_LINKED_PAYLOAD_BYTES)
+ {
+ retained_payload = total;
+ assets.insert(instance.source.clone(), Arc::new(asset));
+ } else {
+ diagnostics.push(format!(
+ "Cannot load linked asset {:?}: document exceeds the 512 MiB linked payload budget.",
+ instance.source
+ ));
+ }
+ }
+ Err(error) => diagnostics.push(format!(
+ "Cannot load linked asset {:?}: {error}",
+ instance.source
+ )),
+ }
+ }
+ if let Some(asset) = assets.get(&instance.source)
+ && let Err(error) = asset.evaluate(instance.scene, None)
+ {
+ diagnostics.push(format!(
+ "Cannot evaluate linked object {:?}, scene {}: {error}",
+ object.name, instance.scene
+ ));
+ }
+ }
+ document.validate()?;
+ Ok(LoadedDocument {
+ document,
+ assets,
+ diagnostics,
+ saved_bytes,
+ })
+}
+
+/// Source normalization affects the newly loaded snapshot only. Existing parent
+/// directories are resolved to avoid interpreting `..` across a symlink wrongly.
+/// The final file is not canonicalized: a symlink asset remains subject to the
+/// loader's regular-file policy. Missing parents retain their anchored path.
+fn absolute_reference(path: &Path) -> Result {
+ let mut absolute = std::path::absolute(path).map_err(|error| error.to_string())?;
+ if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
+ && let Ok(parent) = fs::canonicalize(parent)
+ {
+ absolute = parent.join(name);
+ }
+ absolute
+ .to_str()
+ .map(str::to_owned)
+ .ok_or_else(|| "Asset paths must be valid UTF-8 to persist in an N3 document.".into())
+}
+
+fn absolute_parent(path: &Path) -> Result {
+ let absolute = std::path::absolute(path).map_err(|error| error.to_string())?;
+ let parent = absolute
+ .parent()
+ .ok_or("Document path needs a directory.")?;
+ fs::canonicalize(parent).map_err(|error| format!("Cannot resolve document directory: {error}"))
+}
+
+pub(super) fn snapshot_for_save(document: &Document, destination: &Path) -> Result {
+ let mut snapshot = document.clone();
+ // Documents without links keep the existing persistence behavior, including
+ // its useful destination/create errors, without requiring path rebasing.
+ if !snapshot
+ .objects
+ .iter()
+ .any(|object| matches!(object.geometry, Geometry::Asset(_)))
+ {
+ return Ok(snapshot);
+ }
+ let directory = absolute_parent(destination)?;
+ for object in &mut snapshot.objects {
+ let Geometry::Asset(instance) = &mut object.geometry else {
+ continue;
+ };
+ let source = Path::new(&instance.source);
+ if !source.is_absolute() {
+ return Err("Resolve relative asset references before saving the document.".into());
+ }
+ if let Some(relative) = relative_to(source, &directory) {
+ instance.source = relative
+ .to_str()
+ .ok_or("Asset paths must be valid UTF-8.")?
+ .to_owned();
+ }
+ }
+ Ok(snapshot)
+}
+
+/// Both paths are absolute. Preserve source components (including unresolved
+/// missing-directory `..`) and fall back to an absolute reference across roots.
+fn relative_to(source: &Path, directory: &Path) -> Option {
+ let source: Vec<_> = source.components().collect();
+ let directory: Vec<_> = directory.components().collect();
+ let common = source
+ .iter()
+ .zip(&directory)
+ .take_while(|(a, b)| a == b)
+ .count();
+ if common == 0
+ || source[common..]
+ .iter()
+ .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir))
+ {
+ return None;
+ }
+ let mut relative = PathBuf::new();
+ for _ in &directory[common..] {
+ relative.push("..");
+ }
+ for component in &source[common..] {
+ relative.push(component.as_os_str());
+ }
+ Some(relative)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/asset_io/linked/tests.rs b/src/asset_io/native/linked/tests.rs
similarity index 92%
rename from src/asset_io/linked/tests.rs
rename to src/asset_io/native/linked/tests.rs
index 64a578e..f67eeee 100644
--- a/src/asset_io/linked/tests.rs
+++ b/src/asset_io/native/linked/tests.rs
@@ -1,4 +1,8 @@
use super::*;
+use crate::{
+ asset_io::linked::{validate_resource_cache, validate_resource_cache_with_budget},
+ scene::SceneAsset,
+};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_SCRATCH: AtomicU64 = AtomicU64::new(0);
@@ -81,7 +85,8 @@ fn save_and_save_as_rebase_only_the_written_snapshot() {
let loaded = load(&source).unwrap();
let original_document = loaded.document.clone();
let destination = scratch.0.join("project/model.n3.json");
- let bytes = super::super::document::save(&destination, &loaded.document, None, false).unwrap();
+ let bytes =
+ crate::asset_io::document::save(&destination, &loaded.document, None, false).unwrap();
let serialized = Document::from_json(std::str::from_utf8(&bytes).unwrap()).unwrap();
assert_eq!(reference(&serialized).source, "assets/Triangle.gltf");
let reopened = load(&destination).unwrap();
@@ -90,7 +95,7 @@ fn save_and_save_as_rebase_only_the_written_snapshot() {
fs::create_dir(scratch.0.join("elsewhere")).unwrap();
let save_as = scratch.0.join("elsewhere/copy.n3.json");
- let second = super::super::document::save(&save_as, &loaded.document, None, false).unwrap();
+ let second = crate::asset_io::document::save(&save_as, &loaded.document, None, false).unwrap();
let serialized = Document::from_json(std::str::from_utf8(&second).unwrap()).unwrap();
assert_eq!(
reference(&serialized).source,
@@ -108,7 +113,7 @@ fn relocating_document_and_its_asset_package_preserves_relative_links() {
let source = triangle(&folder.join("resources"), "Triangle", 1.5);
let loaded = load(&source).unwrap();
let path = folder.join("model.n3.json");
- super::super::document::save(&path, &loaded.document, None, false).unwrap();
+ crate::asset_io::document::save(&path, &loaded.document, None, false).unwrap();
let relocated = scratch.0.join("relocated package");
fs::rename(&folder, &relocated).unwrap();
let reopened = load(&relocated.join("model.n3.json")).unwrap();
@@ -127,14 +132,14 @@ fn missing_dependency_keeps_the_authored_reference_and_remains_saveable() {
let source = triangle(&scratch.0.join("assets"), "Triangle", 1.);
let loaded = load(&source).unwrap();
let path = scratch.0.join("model.n3.json");
- let saved = super::super::document::save(&path, &loaded.document, None, false).unwrap();
+ let saved = crate::asset_io::document::save(&path, &loaded.document, None, false).unwrap();
fs::remove_file(source.with_extension("bin")).unwrap();
let missing = load(&path).unwrap();
assert_eq!(missing.document, loaded.document);
assert!(missing.assets.is_empty());
assert_eq!(missing.diagnostics.len(), 1);
assert!(missing.diagnostics[0].contains("Cannot load linked asset"));
- let resaved = super::super::document::save(
+ let resaved = crate::asset_io::document::save(
&path,
&missing.document,
missing.saved_bytes.as_deref(),
@@ -163,7 +168,7 @@ fn missing_source_and_missing_parent_are_retained_as_recoverable_links() {
.unwrap()
.into();
let path = scratch.0.join("model.n3.json");
- super::super::document::save(&path, &document, None, false).unwrap();
+ crate::asset_io::document::save(&path, &document, None, false).unwrap();
let missing = load(&path).unwrap();
assert_eq!(missing.document, document);
assert!(missing.assets.is_empty());
@@ -177,14 +182,14 @@ fn load_is_a_snapshot_and_reopen_uses_current_source_bytes() {
let loaded = load(&source).unwrap();
let key = reference(&loaded.document).source.clone();
let path = scratch.0.join("model.n3.json");
- super::super::document::save(&path, &loaded.document, None, false).unwrap();
+ crate::asset_io::document::save(&path, &loaded.document, None, false).unwrap();
triangle(source.parent().unwrap(), "Triangle", 2.);
let changed_bytes = fs::read(&source).unwrap();
assert_eq!(width(&loaded.assets[&key]), 100.);
let reopened = load(&path).unwrap();
assert_eq!(width(&reopened.assets[&key]), 200.);
assert_eq!(reopened.document, loaded.document);
- super::super::document::save(
+ crate::asset_io::document::save(
&path,
&reopened.document,
reopened.saved_bytes.as_deref(),
@@ -208,7 +213,7 @@ fn duplicate_references_share_one_source_and_invalid_scene_is_diagnostic() {
instance.scene = 42;
document.objects.push(second);
let path = scratch.0.join("model.n3.json");
- super::super::document::save(&path, &document, None, false).unwrap();
+ crate::asset_io::document::save(&path, &document, None, false).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.assets.len(), 1);
assert_eq!(loaded.document.objects.len(), 2);
@@ -223,10 +228,10 @@ fn native_save_conflicts_still_compare_exact_bytes_after_reference_rebasing() {
let source = triangle(&scratch.0.join("assets"), "Triangle", 1.);
let loaded = load(&source).unwrap();
let path = scratch.0.join("model.n3.json");
- super::super::document::save(&path, &loaded.document, None, false).unwrap();
+ crate::asset_io::document::save(&path, &loaded.document, None, false).unwrap();
let loaded = load(&path).unwrap();
fs::write(&path, b"external change").unwrap();
- let error = super::super::document::save(
+ let error = crate::asset_io::document::save(
&path,
&loaded.document,
loaded.saved_bytes.as_deref(),
@@ -264,7 +269,7 @@ fn unresolved_relative_references_cannot_be_rebased_from_an_unknown_origin() {
instance.source = "assets/Triangle.gltf".into();
let path = scratch.0.join("model.n3.json");
assert!(
- super::super::document::save(&path, &document, None, false)
+ crate::asset_io::document::save(&path, &document, None, false)
.unwrap_err()
.contains("Resolve relative")
);
diff --git a/src/asset_io/native/mod.rs b/src/asset_io/native/mod.rs
new file mode 100644
index 0000000..88b7100
--- /dev/null
+++ b/src/asset_io/native/mod.rs
@@ -0,0 +1,40 @@
+//! Native I/O port. Filesystem policy and synchronous bounded reads stay here;
+//! format decoding and retained-resource accounting are shared with browser input.
+pub(crate) mod document;
+mod linked;
+mod resources;
+
+use std::path::Path;
+
+use super::resources::MAX_FILE_BYTES;
+use crate::{document::Document, scene::SceneAsset};
+
+pub(crate) use linked::load;
+pub(crate) use resources::FileResolver;
+use resources::read_file;
+
+type Result = std::result::Result;
+
+pub(crate) fn load_scene_path(path: &Path) -> Result {
+ let bytes = read_file(path, MAX_FILE_BYTES)?;
+ let resolver = super::FileResolver::new(
+ path.parent()
+ .filter(|path| !path.as_os_str().is_empty())
+ .unwrap_or_else(|| Path::new(".")),
+ )?;
+ super::gltf::load(
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("glTF scene"),
+ &bytes,
+ &resolver,
+ )
+}
+
+pub(crate) fn load_obj_path(path: &Path) -> Result {
+ let name = path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("Imported object");
+ super::obj::parse_obj(&document::read_text(path)?, name)
+}
diff --git a/src/asset_io/native/resources.rs b/src/asset_io/native/resources.rs
new file mode 100644
index 0000000..91f1eff
--- /dev/null
+++ b/src/asset_io/native/resources.rs
@@ -0,0 +1,65 @@
+//! Native bounded filesystem access. The portable decoders only see the resolver contract.
+use std::{
+ fs,
+ io::Read,
+ path::{Component, Path, PathBuf},
+};
+
+use crate::asset_io::resources::{ResourceResolver, validate_path};
+
+type Result = std::result::Result;
+
+pub(crate) struct FileResolver {
+ root: PathBuf,
+}
+impl FileResolver {
+ pub(crate) fn new(directory: &Path) -> Result {
+ let root = directory
+ .canonicalize()
+ .map_err(|error| format!("Cannot open asset directory: {error}"))?;
+ if !root.is_dir() {
+ return Err("The asset resource root is not a directory.".into());
+ }
+ Ok(Self { root })
+ }
+}
+impl ResourceResolver for FileResolver {
+ fn read(&self, relative_path: &str, maximum_bytes: usize) -> Result> {
+ validate_path(relative_path)?;
+ let mut path = self.root.clone();
+ for component in Path::new(relative_path).components() {
+ let Component::Normal(name) = component else {
+ continue;
+ };
+ path.push(name);
+ let metadata = fs::symlink_metadata(&path).map_err(|error| {
+ format!("Cannot read asset resource {relative_path:?}: {error}")
+ })?;
+ if metadata.file_type().is_symlink() {
+ return Err("Asset resources cannot follow symbolic links.".into());
+ }
+ }
+ read_file(&path, maximum_bytes)
+ }
+}
+
+pub(crate) fn read_file(path: &Path, maximum_bytes: usize) -> Result> {
+ let metadata =
+ fs::symlink_metadata(path).map_err(|error| format!("Cannot read asset: {error}"))?;
+ if !metadata.is_file() || metadata.file_type().is_symlink() {
+ return Err("Assets and resources must be regular files, not symbolic links.".into());
+ }
+ if metadata.len() > maximum_bytes as u64 {
+ return Err("Asset resource exceeds its byte budget.".into());
+ }
+ let mut bytes = Vec::new();
+ fs::File::open(path)
+ .map_err(|error| error.to_string())?
+ .take(maximum_bytes as u64 + 1)
+ .read_to_end(&mut bytes)
+ .map_err(|error| error.to_string())?;
+ if bytes.len() > maximum_bytes {
+ return Err("Asset resource exceeds its byte budget.".into());
+ }
+ Ok(bytes)
+}
diff --git a/src/asset_io/obj.rs b/src/asset_io/obj.rs
index f29378b..82b9995 100644
--- a/src/asset_io/obj.rs
+++ b/src/asset_io/obj.rs
@@ -3,7 +3,8 @@ use crate::document::{
Document, EditableMesh, Face, Geometry, MAX_FACE_CORNERS, MAX_OBJECTS, MAX_VERTICES,
MeshVertex, Object, Transform, check_budget,
};
-use std::{collections::BTreeSet, path::Path};
+use std::collections::BTreeSet;
+
type Result = std::result::Result;
/// Preserve OBJ position indices and source polygons. Each object owns its
@@ -14,14 +15,6 @@ type Result = std::result::Result;
/// and interpreted as centimeters, without guessing or applying a scale factor.
/// Non-surface records
/// (materials, UVs, normals, lines and points) are outside this importer.
-pub(crate) fn load_path(path: &Path) -> Result {
- let name = path
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("Imported object");
- parse_obj(&super::document::read_text(path)?, name)
-}
-
pub(crate) fn parse_obj(text: &str, default_name: &str) -> Result {
let mut positions = Vec::<[f64; 3]>::new();
let mut groups: Vec<(String, Vec)> = vec![(default_name.into(), Vec::new())];
diff --git a/src/asset_io/obj_tests.rs b/src/asset_io/obj_tests.rs
index 994719c..6ec2f02 100644
--- a/src/asset_io/obj_tests.rs
+++ b/src/asset_io/obj_tests.rs
@@ -1,6 +1,8 @@
use super::*;
+use crate::asset_io::native::load_obj_path as load_path;
use crate::{document::DisplayFrame, mesh::EditFace, units::CanonicalLengthUnit};
use glam::{DQuat, DVec3};
+use std::path::Path;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
diff --git a/src/asset_io/resources.rs b/src/asset_io/resources.rs
index 9cff591..82299e2 100644
--- a/src/asset_io/resources.rs
+++ b/src/asset_io/resources.rs
@@ -1,9 +1,5 @@
//! Bounded host resource access for import adapters. No model opens files.
-use std::{
- fs,
- io::Read,
- path::{Component, Path, PathBuf},
-};
+use std::path::{Component, Path};
type Result = std::result::Result;
@@ -17,61 +13,6 @@ pub(crate) trait ResourceResolver {
fn read(&self, relative_path: &str, maximum_bytes: usize) -> Result>;
}
-pub(crate) struct FileResolver {
- root: PathBuf,
-}
-impl FileResolver {
- pub(crate) fn new(directory: &Path) -> Result {
- let root = directory
- .canonicalize()
- .map_err(|error| format!("Cannot open asset directory: {error}"))?;
- if !root.is_dir() {
- return Err("The asset resource root is not a directory.".into());
- }
- Ok(Self { root })
- }
-}
-impl ResourceResolver for FileResolver {
- fn read(&self, relative_path: &str, maximum_bytes: usize) -> Result> {
- validate_path(relative_path)?;
- let mut path = self.root.clone();
- for component in Path::new(relative_path).components() {
- let Component::Normal(name) = component else {
- continue;
- };
- path.push(name);
- let metadata = fs::symlink_metadata(&path).map_err(|error| {
- format!("Cannot read asset resource {relative_path:?}: {error}")
- })?;
- if metadata.file_type().is_symlink() {
- return Err("Asset resources cannot follow symbolic links.".into());
- }
- }
- read_file(&path, maximum_bytes)
- }
-}
-
-pub(crate) fn read_file(path: &Path, maximum_bytes: usize) -> Result> {
- let metadata =
- fs::symlink_metadata(path).map_err(|error| format!("Cannot read asset: {error}"))?;
- if !metadata.is_file() || metadata.file_type().is_symlink() {
- return Err("Assets and resources must be regular files, not symbolic links.".into());
- }
- if metadata.len() > maximum_bytes as u64 {
- return Err("Asset resource exceeds its byte budget.".into());
- }
- let mut bytes = Vec::new();
- fs::File::open(path)
- .map_err(|error| error.to_string())?
- .take(maximum_bytes as u64 + 1)
- .read_to_end(&mut bytes)
- .map_err(|error| error.to_string())?;
- if bytes.len() > maximum_bytes {
- return Err("Asset resource exceeds its byte budget.".into());
- }
- Ok(bytes)
-}
-
pub(crate) fn validate_path(path: &str) -> Result<()> {
if path.is_empty()
|| path.starts_with('/')
diff --git a/src/documentation/scenarios/navigation.rs b/src/documentation/scenarios/navigation.rs
index efdff80..c445644 100644
--- a/src/documentation/scenarios/navigation.rs
+++ b/src/documentation/scenarios/navigation.rs
@@ -713,7 +713,8 @@ pub fn run(s: &mut Session<'_>) -> Result<()> {
s.settle()?;
s.witness(Control::Projection)?;
s.capture_image("navigation")?;
- verify_planar_zoom(s)
+ verify_planar_zoom(s)?;
+ verify_browser_navigation(s)
}
/// Exercise pointer anchoring through the native/replay funnel without adding
@@ -764,3 +765,190 @@ fn verify_planar_zoom(s: &mut Session<'_>) -> Result<()> {
}
Ok(())
}
+
+/// Host conversion belongs to the same navigation contract. These samples
+/// exercise browser units through the production router without adding a
+/// platform-specific illustration or claiming physical browser event delivery.
+fn verify_browser_navigation(s: &mut Session<'_>) -> Result<()> {
+ use crate::input::browser_navigation::{self, WebKitGesture, Wheel, WheelUnit};
+ let wheel = |delta, unit, ctrl, modifiers| {
+ browser_navigation::wheel(Wheel {
+ delta,
+ unit,
+ ctrl,
+ modifiers,
+ points_per_css_pixel: 1.0,
+ page_height_css: 720.0,
+ })
+ .ok_or_else(|| "A finite browser wheel sample must normalize".to_string())
+ };
+ let document = s.state.editor.document.clone();
+ let selected = s.state.editor.selected_objects.clone();
+ let dirty = s.state.is_dirty();
+ let undo_changed = s.state.editor.undo();
+ s.require(
+ !undo_changed,
+ "The navigation walkthrough reaches the browser checks with no pending edit or undo entry",
+ )?;
+ s.click_path(&[Control::N3Menu, Control::ViewMenu, Control::Frame])?;
+ s.hover(Control::Viewport)?;
+ let facing = orientation(s);
+ let mut expected = s.state.camera.clone();
+ expected.zoom(0.25 * 1.5);
+ s.navigation(
+ wheel(
+ egui::vec2(0.0, -25.0),
+ WheelUnit::Pixels,
+ true,
+ egui::Modifiers::NONE,
+ )?,
+ Duration::ZERO,
+ )?;
+ s.require(
+ matrix(s).abs_diff_eq(expected.view_projection(s.state.aspect()), 1e-5)
+ && orientation(s).abs_diff_eq(facing, 1e-5)
+ && s.input.modifiers() == egui::Modifiers::NONE,
+ "Browser pinch-wheel applies the same zoom once as a native pinch, without orbiting or holding a synthetic Control key",
+ )?;
+ for modifiers in [egui::Modifiers::NONE, egui::Modifiers::SHIFT] {
+ let mut expected = s.state.camera.clone();
+ if modifiers.shift {
+ expected.pan(18.0, -12.0, s.state.viewport.height());
+ } else {
+ expected.orbit(18.0, -12.0);
+ }
+ s.navigation(
+ wheel(egui::vec2(-18.0, 12.0), WheelUnit::Pixels, false, modifiers)?,
+ Duration::ZERO,
+ )?;
+ s.require(
+ matrix(s).abs_diff_eq(expected.view_projection(s.state.aspect()), 1e-5),
+ "Ordinary browser pixel scrolling follows the same 3D orbit and Shift-pan policy as native precise scrolling",
+ )?;
+ }
+ let mut expected = s.state.camera.clone();
+ expected.zoom(2.0 * 0.12);
+ s.navigation(
+ wheel(
+ egui::vec2(0.0, -2.0),
+ WheelUnit::Lines,
+ false,
+ egui::Modifiers::NONE,
+ )?,
+ Duration::ZERO,
+ )?;
+ s.require(
+ matrix(s).abs_diff_eq(expected.view_projection(s.state.aspect()), 1e-5),
+ "Browser line-unit wheel input keeps the shared mouse-wheel zoom sensitivity",
+ )?;
+
+ complete_view(s, Control::ViewFront, Some(Vec3::Z))?;
+ let rect = s.state.viewport;
+ let pointer = rect.center() + egui::vec2(rect.width() * 0.2, rect.height() * 0.15);
+ let anchor = Vec2::new(0.4, -0.3);
+ s.frame(vec![egui::Event::PointerMoved(pointer)], Duration::ZERO)?;
+ let point = matrix(s).inverse().project_point3(anchor.extend(0.5));
+ let scale = screen_geometry(s).1;
+ s.navigation(
+ wheel(
+ egui::vec2(0.0, -12.0),
+ WheelUnit::Pixels,
+ true,
+ egui::Modifiers::NONE,
+ )?,
+ Duration::ZERO,
+ )?;
+ s.require(
+ matrix(s)
+ .project_point3(point)
+ .truncate()
+ .abs_diff_eq(anchor, 1e-4)
+ && screen_geometry(s).1 > scale + 1e-5
+ && s.state.is_planar_navigation(),
+ "Browser pinch-wheel preserves the point beneath the pointer in 2D",
+ )?;
+ let facing = orientation(s);
+ let mut expected = s.state.camera.clone();
+ expected.zoom_at(1.25_f32.ln() * 1.5, anchor, s.state.aspect());
+ let mut gesture = WebKitGesture::default();
+ s.require(
+ gesture.begin(1.0, 0.0),
+ "A WebKit gesture starts with a valid cumulative baseline",
+ )?;
+ for (scale, rotation) in [(1.1, 12.0), (1.25, 25.0)] {
+ for event in gesture
+ .update(scale, rotation, egui::Modifiers::NONE)
+ .ok_or("A valid WebKit sample must normalize")?
+ {
+ s.navigation(event, Duration::ZERO)?;
+ }
+ }
+ s.require(
+ matrix(s).abs_diff_eq(expected.view_projection(s.state.aspect()), 1e-5)
+ && orientation(s).abs_diff_eq(facing, 1e-5)
+ && s.state.is_planar_navigation(),
+ "Cumulative WebKit pinch samples compose to their total scale while simultaneous twist leaves 2D unchanged",
+ )?;
+ gesture.reset();
+ s.require(
+ !gesture.is_active() && gesture.update(1.5, 35.0, egui::Modifiers::NONE).is_none(),
+ "Ending a browser gesture discards its cumulative baseline until the next start",
+ )?;
+ let before = screen_geometry(s);
+ s.navigation(
+ wheel(
+ egui::vec2(-18.0, 12.0),
+ WheelUnit::Pixels,
+ false,
+ egui::Modifiers::NONE,
+ )?,
+ Duration::ZERO,
+ )?;
+ s.require(
+ panned(before, screen_geometry(s)) && orientation(s).abs_diff_eq(facing, 1e-5),
+ "Ordinary browser pixel scrolling pans a 2D view after pinch without a synthetic modifier or sticky zoom action",
+ )?;
+ let mut expected = s.state.camera.clone();
+ expected.pan(0.0, -12.0, s.state.viewport.height());
+ s.navigation(
+ wheel(
+ egui::vec2(0.0, 12.0 / 720.0),
+ WheelUnit::Pages,
+ false,
+ egui::Modifiers::NONE,
+ )?,
+ Duration::ZERO,
+ )?;
+ s.require(
+ matrix(s).abs_diff_eq(expected.view_projection(s.state.aspect()), 1e-5),
+ "Browser page-unit scrolling uses the canvas CSS height and the shared 2D pan policy",
+ )?;
+ complete_view(s, Control::ViewPerspective, None)?;
+ s.hover(Control::Viewport)?;
+ let mut expected = s.state.camera.clone();
+ expected.orbit(25.0_f32.to_radians() / 0.006, 0.0);
+ s.require(
+ gesture.begin(1.0, 0.0),
+ "A new WebKit gesture starts independently",
+ )?;
+ for rotation in [12.0, 25.0] {
+ for event in gesture
+ .update(1.0, rotation, egui::Modifiers::NONE)
+ .ok_or("A valid WebKit twist must normalize")?
+ {
+ s.navigation(event, Duration::ZERO)?;
+ }
+ }
+ s.require(
+ matrix(s).abs_diff_eq(expected.view_projection(s.state.aspect()), 1e-5),
+ "A browser that exposes cumulative twist rotates the 3D view once with the shared direction and sensitivity",
+ )?;
+ let undo_changed = s.state.editor.undo();
+ s.require(
+ !undo_changed
+ && s.state.editor.document == document
+ && s.state.editor.selected_objects == selected
+ && s.state.is_dirty() == dirty,
+ "Browser navigation preserves authored geometry, selection and dirty state and creates no undo entry",
+ )
+}
diff --git a/src/documentation/scenarios/settings.rs b/src/documentation/scenarios/settings.rs
index c88253b..7ba91f9 100644
--- a/src/documentation/scenarios/settings.rs
+++ b/src/documentation/scenarios/settings.rs
@@ -269,6 +269,19 @@ pub fn run(s: &mut Session<'_>) -> Result<()> {
s.state.editor.document == document && s.state.editor.revision == revision,
"Settings reload, persistence, and recovery leave document geometry and edit history untouched",
)?;
+ s.state.host_capabilities = crate::workspace_ui::HostCapabilities::BROWSER;
+ s.settle()?;
+ s.require(
+ s.trace.get(Control::SettingsJson).is_err()
+ && s.trace.get(Control::ReloadSettings).is_ok(),
+ "Hosts without external settings-file access omit that control from the same Preferences window",
+ )?;
+ s.click(Control::ReloadSettings)?;
+ let requested_reload = std::mem::take(&mut s.state.request_reload_settings);
+ s.require(
+ requested_reload,
+ "Reload remains available through the shared control when settings use browser storage",
+ )?;
s.click(Control::PreferencesClose)?;
Ok(())
}
diff --git a/src/documentation/scenarios/terminal.rs b/src/documentation/scenarios/terminal.rs
index edae8b8..b4592de 100644
--- a/src/documentation/scenarios/terminal.rs
+++ b/src/documentation/scenarios/terminal.rs
@@ -244,5 +244,29 @@ pub fn run(s: &mut Session<'_>) -> Result<()> {
&& !s.state.is_dirty()
&& has_no_undo,
"Opening, focusing, scrolling, resizing, switching, and closing Tool Dock panels do not alter geometry or add document history",
+ )?;
+
+ s.click(Control::ToolDockClose)?;
+ s.state.host_capabilities = crate::workspace_ui::HostCapabilities::BROWSER;
+ s.settle()?;
+ let action = crate::input::actions::ActionId::TerminalPanel;
+ let availability = s.state.action_state(action);
+ s.require(
+ !availability.visible
+ && !availability.enabled
+ && s.trace.get(Control::TerminalPanelToggle).is_err()
+ && s.trace.get(Control::AnimationPanelToggle).is_ok(),
+ "Hosts without a local shell omit Terminal from the shared Tool Dock and retain Animation",
+ )?;
+ s.state.dispatch(action.command(), &s.ctx, false);
+ s.settle()?;
+ s.require(
+ s.state.tool_dock.active.is_none() && s.trace.get(Control::TerminalPanel).is_err(),
+ "Semantic dispatch cannot open a Terminal that the host does not support",
+ )?;
+ s.click(Control::AnimationPanelToggle)?;
+ s.require(
+ s.state.animation_panel_is_open(),
+ "Animation remains usable in the shared Tool Dock without Terminal",
)
}
diff --git a/src/documentation/tests/doc_navigation_tests.rs b/src/documentation/tests/doc_navigation_tests.rs
index f44e6e5..99ea1ee 100644
--- a/src/documentation/tests/doc_navigation_tests.rs
+++ b/src/documentation/tests/doc_navigation_tests.rs
@@ -87,6 +87,19 @@ fn pose(s: &Session<'_>) -> glam::Mat4 {
s.state.camera.view_projection(s.state.aspect())
}
+fn browser_pinch() -> NavigationEvent {
+ use crate::input::browser_navigation::{self, Wheel, WheelUnit};
+ browser_navigation::wheel(Wheel {
+ delta: Vec2::new(0.0, -40.0),
+ unit: WheelUnit::Pixels,
+ ctrl: true,
+ modifiers: Modifiers::NONE,
+ points_per_css_pixel: 1.0,
+ page_height_css: HEIGHT as f32,
+ })
+ .expect("A finite browser pinch-wheel sample normalizes")
+}
+
fn front(s: &mut Session<'_>) {
s.click_path(&[Control::N3Menu, Control::ViewMenu]).unwrap();
s.click(Control::ViewFront).unwrap();
@@ -153,6 +166,7 @@ fn replayed_gestures_obey_pointer_focus_popups_and_pending_pie() {
s.trackpad_scroll(30.0, 15.0, Modifiers::NONE, ScrollPhase::Started)
.unwrap();
s.pinch(0.4).unwrap();
+ s.navigation(browser_pinch(), Duration::ZERO).unwrap();
s.trackpad_rotate(35.0).unwrap();
assert_eq!(
pose(&s),
@@ -168,6 +182,7 @@ fn replayed_gestures_obey_pointer_focus_popups_and_pending_pie() {
s.frame(vec![egui::Event::WindowFocused(false)], Duration::ZERO)
.unwrap();
s.pinch(0.4).unwrap();
+ s.navigation(browser_pinch(), Duration::ZERO).unwrap();
assert_eq!(pose(&s), before, "Unfocused windows cannot navigate");
assert!(!s.input.summary(s.cursor).contains("Pinch"));
s.frame(vec![egui::Event::WindowFocused(true)], Duration::ZERO)
@@ -176,6 +191,7 @@ fn replayed_gestures_obey_pointer_focus_popups_and_pending_pie() {
s.frame(vec![egui::Event::PointerGone], Duration::ZERO)
.unwrap();
s.pinch(0.4).unwrap();
+ s.navigation(browser_pinch(), Duration::ZERO).unwrap();
assert_eq!(pose(&s), before, "Leaving the window blocks gestures");
s.hover(Control::Viewport).unwrap();
let event = NavigationEvent::Wheel {
@@ -197,6 +213,14 @@ fn replayed_gestures_obey_pointer_focus_popups_and_pending_pie() {
&pending,
event,
);
+ navigation_events::route(
+ &mut s.state,
+ &s.ctx,
+ s.input.position(),
+ true,
+ &pending,
+ browser_pinch(),
+ );
assert_eq!(
pose(&s),
before,
@@ -210,6 +234,7 @@ fn replayed_gestures_obey_pointer_focus_popups_and_pending_pie() {
)
.unwrap();
s.pinch(0.4).unwrap();
+ s.navigation(browser_pinch(), Duration::ZERO).unwrap();
assert_eq!(
pose(&s),
before,
@@ -280,6 +305,7 @@ fn mouse_camera_and_transform_drags_exclude_host_gestures() {
s.trackpad_rotate(25.0).unwrap();
s.trackpad_scroll(18.0, -12.0, Modifiers::NONE, ScrollPhase::Started)
.unwrap();
+ s.navigation(browser_pinch(), Duration::ZERO).unwrap();
assert_eq!(
pose(&s),
before,
@@ -307,6 +333,7 @@ fn mouse_camera_and_transform_drags_exclude_host_gestures() {
s.pinch(0.5).unwrap();
s.trackpad_rotate(25.0).unwrap();
s.scroll(0.0, 2.0, false, Modifiers::NONE).unwrap();
+ s.navigation(browser_pinch(), Duration::ZERO).unwrap();
assert_eq!(
pose(&s),
before,
diff --git a/src/editor/editor_tests.rs b/src/editor/editor_tests.rs
index 86bf11f..a104bd3 100644
--- a/src/editor/editor_tests.rs
+++ b/src/editor/editor_tests.rs
@@ -1388,7 +1388,7 @@ fn deleting_multiple_objects_is_one_undo_step_with_full_selection_restore() {
#[test]
fn vertex_delete_removes_incident_imported_polygons_without_cleanup_or_renumbering() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/obj/cube-quads.obj");
- let document = crate::asset_io::obj::load_path(&path).unwrap();
+ let document = crate::asset_io::document::load_path(&path).unwrap();
let mut editor = Editor::new(document).unwrap();
let id = editor.document.objects[0].id;
editor.select_object(id).unwrap();
diff --git a/src/editor/mod.rs b/src/editor/mod.rs
index 980c32f..baf604e 100644
--- a/src/editor/mod.rs
+++ b/src/editor/mod.rs
@@ -237,6 +237,7 @@ struct NumericTransform {
pub enum EditorAccess {
#[default]
ReadWrite,
+ #[allow(dead_code)] // Hosts may opt into the shared read-only access policy.
ReadOnly,
}
@@ -1675,6 +1676,7 @@ impl Editor {
/// vertex-sampled: a face sliver whose vertices are occluded does not qualify.
/// Near-clipped or otherwise unprojectable objects are excluded. The configurable
/// hit policy compares these screen bounds, not the exact mesh silhouette.
+ #[allow(dead_code)] // Read-only projection query used by executable guides.
pub fn object_selection_bounds(
&mut self,
viewport: Rect,
diff --git a/src/input/bindings.rs b/src/input/bindings.rs
index f3e791c..92815f2 100644
--- a/src/input/bindings.rs
+++ b/src/input/bindings.rs
@@ -10,6 +10,7 @@ pub enum HeldModifier {
}
impl HeldModifier {
+ #[allow(dead_code)] // Replay builds input from the same binding vocabulary.
pub fn modifiers(self) -> Modifiers {
match self {
Self::Alt => Modifiers::ALT,
diff --git a/src/input/browser_navigation.rs b/src/input/browser_navigation.rs
new file mode 100644
index 0000000..1ce3263
--- /dev/null
+++ b/src/input/browser_navigation.rs
@@ -0,0 +1,355 @@
+//! Browser gesture units translated into the shared navigation vocabulary.
+//!
+//! The host owns DOM listeners, focus and pointer coordinates. This adapter does
+//! not choose a camera action or bypass the shared viewport ownership checks.
+//! Browsers do not identify the device behind pixel wheel events, so ordinary
+//! pixel scrolling follows precise-scroll policy, including pixel mouse wheels.
+
+use crate::{navigation_events::Event, scroll_input::ScrollPhase};
+use egui::{Modifiers, Vec2};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WheelUnit {
+ Pixels,
+ Lines,
+ Pages,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct Wheel {
+ /// DOM deltas: down/right are positive, opposite N3's navigation deltas.
+ pub delta: Vec2,
+ pub unit: WheelUnit,
+ /// Browser pinch and physical Control-wheel are indistinguishable here.
+ pub ctrl: bool,
+ /// The host's physical modifiers, without the pinch's synthetic Control key.
+ pub modifiers: Modifiers,
+ pub points_per_css_pixel: f32,
+ pub page_height_css: f32,
+}
+
+/// Convert one wheel event without inventing gesture phases or device identity.
+pub fn wheel(input: Wheel) -> Option {
+ if !input.delta.is_finite()
+ || !input.points_per_css_pixel.is_finite()
+ || input.points_per_css_pixel <= 0.0
+ {
+ return None;
+ }
+ let css_pixels_per_unit = match input.unit {
+ WheelUnit::Pixels => 1.0,
+ // DOM line units do not prescribe a pixel size. Use an explicit 40 CSS
+ // pixel normalization only for browser pinch/Control-wheel zoom.
+ WheelUnit::Lines => 40.0,
+ WheelUnit::Pages => {
+ if !input.page_height_css.is_finite() || input.page_height_css <= 0.0 {
+ return None;
+ }
+ f64::from(input.page_height_css)
+ }
+ };
+ if input.ctrl {
+ // A hundred CSS pixels represents one canonical pinch delta. Keep this
+ // independent of display density and egui zoom; the shared camera owns
+ // its existing pinch sensitivity and pointer anchoring.
+ return Some(Event::Pinch {
+ delta: -f64::from(input.delta.y) * css_pixels_per_unit * 0.01,
+ modifiers: input.modifiers,
+ });
+ }
+ if input.unit == WheelUnit::Lines {
+ return Some(Event::Wheel {
+ delta: -input.delta,
+ modifiers: input.modifiers,
+ });
+ }
+ let delta = -input.delta * (css_pixels_per_unit as f32 * input.points_per_css_pixel);
+ delta.is_finite().then_some(Event::TrackpadScroll {
+ delta,
+ // WheelEvent exposes no finger/momentum phase. Shared scroll routing
+ // deliberately supports unphased Moved events, including Shift latching.
+ phase: ScrollPhase::Moved,
+ modifiers: input.modifiers,
+ })
+}
+
+/// WebKit reports cumulative scale and clockwise rotation from gesturestart.
+#[derive(Debug, Default)]
+pub struct WebKitGesture {
+ previous: Option<(f64, f32)>,
+}
+
+impl WebKitGesture {
+ pub fn begin(&mut self, scale: f64, rotation: f32) -> bool {
+ self.reset();
+ if !valid_gesture(scale, rotation) {
+ return false;
+ }
+ self.previous = Some((scale, rotation));
+ true
+ }
+
+ pub fn update(
+ &mut self,
+ scale: f64,
+ rotation: f32,
+ modifiers: Modifiers,
+ ) -> Option<[Event; 2]> {
+ let (previous_scale, previous_rotation) = self.previous?;
+ if !valid_gesture(scale, rotation) {
+ return None;
+ }
+ // Subtract logs instead of dividing first, avoiding ratio overflow for
+ // finite positive values. Successive deltas compose to the total ratio.
+ let delta = scale.ln() - previous_scale.ln();
+ let degrees = previous_rotation - rotation;
+ if !delta.is_finite() || !degrees.is_finite() {
+ return None;
+ }
+ self.previous = Some((scale, rotation));
+ Some([
+ Event::Pinch { delta, modifiers },
+ Event::Rotate { degrees, modifiers },
+ ])
+ }
+
+ pub fn reset(&mut self) {
+ self.previous = None;
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.previous.is_some()
+ }
+}
+
+fn valid_gesture(scale: f64, rotation: f32) -> bool {
+ scale.is_finite() && scale > 0.0 && rotation.is_finite()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample(unit: WheelUnit) -> Wheel {
+ Wheel {
+ delta: Vec2::new(2.0, -3.0),
+ unit,
+ ctrl: false,
+ modifiers: Modifiers::SHIFT,
+ points_per_css_pixel: 0.5,
+ page_height_css: 800.0,
+ }
+ }
+
+ fn pinch(event: Event) -> f64 {
+ let Event::Pinch { delta, .. } = event else {
+ panic!("expected a pinch event, got {event:?}");
+ };
+ delta
+ }
+
+ fn rotation(event: Event) -> f32 {
+ let Event::Rotate { degrees, .. } = event else {
+ panic!("expected a rotation event, got {event:?}");
+ };
+ degrees
+ }
+
+ #[test]
+ fn ordinary_wheel_preserves_units_sign_modifiers_and_unphased_scroll() {
+ for (unit, expected) in [
+ (WheelUnit::Pixels, Vec2::new(-1.0, 1.5)),
+ (WheelUnit::Pages, Vec2::new(-800.0, 1200.0)),
+ ] {
+ let Some(Event::TrackpadScroll {
+ delta,
+ phase,
+ modifiers,
+ }) = wheel(sample(unit))
+ else {
+ panic!("expected precise scrolling");
+ };
+ assert_eq!(delta, expected);
+ assert_eq!(phase, ScrollPhase::Moved);
+ assert_eq!(modifiers, Modifiers::SHIFT);
+ }
+ let Some(Event::Wheel { delta, modifiers }) = wheel(sample(WheelUnit::Lines)) else {
+ panic!("expected wheel lines");
+ };
+ assert_eq!(delta, Vec2::new(-2.0, 3.0));
+ assert_eq!(modifiers, Modifiers::SHIFT);
+ }
+
+ #[test]
+ fn browser_pinch_is_zoom_in_all_units_without_synthetic_control() {
+ for (unit, expected) in [
+ (WheelUnit::Pixels, 0.03),
+ (WheelUnit::Lines, 1.2),
+ (WheelUnit::Pages, 24.0),
+ ] {
+ let input = Wheel {
+ ctrl: true,
+ ..sample(unit)
+ };
+ for points_per_css_pixel in [0.5, 1.0, 2.0] {
+ let event = wheel(Wheel {
+ points_per_css_pixel,
+ ..input
+ })
+ .unwrap();
+ assert!((pinch(event) - expected).abs() < 1e-12);
+ assert_eq!(event.modifiers(), Modifiers::SHIFT);
+ assert!(!event.modifiers().ctrl);
+ }
+ }
+ }
+
+ #[test]
+ fn wheel_rejects_nonfinite_deltas_and_invalid_conversion() {
+ let input = sample(WheelUnit::Pixels);
+ for delta in [Vec2::new(f32::NAN, 1.0), Vec2::new(1.0, f32::INFINITY)] {
+ for ctrl in [false, true] {
+ assert!(
+ wheel(Wheel {
+ delta,
+ ctrl,
+ ..input
+ })
+ .is_none()
+ );
+ }
+ }
+ for points_per_css_pixel in [0.0, -1.0, f32::NAN, f32::INFINITY] {
+ assert!(
+ wheel(Wheel {
+ points_per_css_pixel,
+ ..input
+ })
+ .is_none()
+ );
+ }
+ for page_height_css in [0.0, -1.0, f32::NAN, f32::INFINITY] {
+ assert!(
+ wheel(Wheel {
+ page_height_css,
+ ..sample(WheelUnit::Pages)
+ })
+ .is_none()
+ );
+ }
+ assert!(
+ wheel(Wheel {
+ delta: Vec2::splat(f32::MAX),
+ points_per_css_pixel: 2.0,
+ ..input
+ })
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn wheel_zero_and_inverse_deltas_do_not_accumulate_zoom() {
+ let input = Wheel {
+ ctrl: true,
+ ..sample(WheelUnit::Pixels)
+ };
+ assert_eq!(
+ pinch(
+ wheel(Wheel {
+ delta: Vec2::ZERO,
+ ..input
+ })
+ .unwrap()
+ ),
+ 0.0
+ );
+ assert_eq!(
+ pinch(wheel(input).unwrap())
+ + pinch(
+ wheel(Wheel {
+ delta: -input.delta,
+ ..input
+ })
+ .unwrap()
+ ),
+ 0.0,
+ );
+ }
+
+ #[test]
+ fn webkit_cumulative_values_become_incremental_native_events() {
+ let mut gesture = WebKitGesture::default();
+ assert!(gesture.begin(1.0, 0.0));
+ assert!(gesture.is_active());
+ let first = gesture.update(1.5, 20.0, Modifiers::ALT).unwrap();
+ let second = gesture.update(2.0, 35.0, Modifiers::ALT).unwrap();
+ assert!((pinch(first[0]) - 1.5_f64.ln()).abs() < 1e-12);
+ assert!((pinch(second[0]) - (2.0_f64 / 1.5).ln()).abs() < 1e-12);
+ assert!((pinch(first[0]) + pinch(second[0]) - 2.0_f64.ln()).abs() < 1e-12);
+ assert_eq!(rotation(first[1]), -20.0);
+ assert_eq!(rotation(second[1]), -15.0);
+ assert!(
+ first
+ .into_iter()
+ .chain(second)
+ .all(|event| event.modifiers() == Modifiers::ALT)
+ );
+ }
+
+ #[test]
+ fn webkit_zero_inverse_and_reset_preserve_the_gesture_baseline() {
+ let mut gesture = WebKitGesture::default();
+ assert!(gesture.update(2.0, 10.0, Modifiers::NONE).is_none());
+ assert!(gesture.begin(1.0, 0.0));
+ let zero = gesture.update(1.0, 0.0, Modifiers::NONE).unwrap();
+ assert_eq!(pinch(zero[0]), 0.0);
+ assert_eq!(rotation(zero[1]), 0.0);
+ let outward = gesture.update(2.0, -15.0, Modifiers::NONE).unwrap();
+ let inward = gesture.update(1.0, 0.0, Modifiers::NONE).unwrap();
+ assert_eq!(pinch(outward[0]) + pinch(inward[0]), 0.0);
+ assert_eq!(rotation(outward[1]) + rotation(inward[1]), 0.0);
+ gesture.reset();
+ assert!(!gesture.is_active());
+ assert!(gesture.update(2.0, 10.0, Modifiers::NONE).is_none());
+ assert!(gesture.begin(2.0, 10.0));
+ let restarted = gesture.update(4.0, 12.0, Modifiers::NONE).unwrap();
+ assert!((pinch(restarted[0]) - 2.0_f64.ln()).abs() < 1e-12);
+ assert_eq!(rotation(restarted[1]), -2.0);
+ }
+
+ #[test]
+ fn webkit_invalid_values_never_poison_the_last_valid_sample() {
+ let mut gesture = WebKitGesture::default();
+ for (scale, invalid_rotation) in [
+ (0.0, 0.0),
+ (-1.0, 0.0),
+ (f64::NAN, 0.0),
+ (f64::INFINITY, 0.0),
+ (1.0, f32::NAN),
+ (1.0, f32::INFINITY),
+ ] {
+ assert!(!gesture.begin(scale, invalid_rotation));
+ assert!(!gesture.is_active());
+ assert!(gesture.begin(1.0, 0.0));
+ assert!(
+ gesture
+ .update(scale, invalid_rotation, Modifiers::NONE)
+ .is_none()
+ );
+ let recovered = gesture.update(2.0, 5.0, Modifiers::NONE).unwrap();
+ assert!((pinch(recovered[0]) - 2.0_f64.ln()).abs() < 1e-12);
+ assert_eq!(rotation(recovered[1]), -5.0);
+ }
+ assert!(gesture.begin(f64::MIN_POSITIVE, -f32::MAX));
+ assert!(
+ gesture
+ .update(f64::MAX, f32::MAX, Modifiers::NONE)
+ .is_none()
+ );
+ let finite = gesture
+ .update(f64::MAX, -f32::MAX, Modifiers::NONE)
+ .unwrap();
+ assert!(pinch(finite[0]).is_finite());
+ assert_eq!(rotation(finite[1]), 0.0);
+ }
+}
diff --git a/src/input/keyboard_input.rs b/src/input/keyboard_input.rs
index c2e23b0..b01752e 100644
--- a/src/input/keyboard_input.rs
+++ b/src/input/keyboard_input.rs
@@ -42,6 +42,7 @@ impl NumberKey {
}
/// Logical event used by scripted inputs. Physical origin stays in metadata.
+ #[allow(dead_code)] // Replay supplies logical keys alongside physical metadata.
pub fn egui_key(self) -> Option {
let (Self::TopRow(digit) | Self::Numpad(digit)) = self;
[
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 9fc4b3e..5b6a374 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -1,5 +1,6 @@
pub(crate) mod actions;
pub(crate) mod bindings;
+pub(crate) mod browser_navigation;
pub(crate) mod keyboard_input;
pub(crate) mod move_input;
pub(crate) mod navigation_events;
diff --git a/src/input/navigation_events.rs b/src/input/navigation_events.rs
index ed0edb9..fadd2d0 100644
--- a/src/input/navigation_events.rs
+++ b/src/input/navigation_events.rs
@@ -1,4 +1,4 @@
-//! Host navigation events shared by the native window and executable tutorials.
+//! Host navigation events shared by native, browser and executable tutorials.
//!
//! Deltas are logical points (precise scroll), wheel lines, native pinch delta,
//! or native rotation degrees. The replay cue is derived from the same event
@@ -41,7 +41,7 @@ impl Event {
}
/// Match egui-winit's translation. Native windows already receive these
- /// events from egui-winit; replay derives them here for UI input and cues.
+ /// events from egui-winit; browser scrolling and replay derive them here.
pub fn egui_event(self) -> Option {
match self {
Self::ModifiersChanged(modifiers) => Some(egui::Event::ModifiersChanged(modifiers)),
diff --git a/src/input/pie_input.rs b/src/input/pie_input.rs
index 8253937..281493d 100644
--- a/src/input/pie_input.rs
+++ b/src/input/pie_input.rs
@@ -1,4 +1,8 @@
//! Ordered temporary input ownership shared by held viewport pie menus.
+
+// Replay and workbench inspection, without a browser runtime dependency.
+#[cfg(not(target_arch = "wasm32"))]
+mod inspection;
use super::bindings;
use crate::{
editor::Tool,
@@ -123,21 +127,9 @@ pub struct PieInput {
}
impl PieInput {
- pub(crate) fn for_instance(id: egui::Id) -> Self {
- Self {
- instance: Some(id),
- ..Self::default()
- }
- }
pub fn active(&self) -> bool {
self.pie.is_some()
}
- pub fn view_active(&self) -> bool {
- matches!(self.pie, Some(ActivePie::View(_)))
- }
- pub fn shading_active(&self) -> bool {
- matches!(self.pie, Some(ActivePie::Shading(_)))
- }
pub fn reset(&mut self) {
self.pie = None;
self.pointer = None;
@@ -160,24 +152,6 @@ impl PieInput {
)
}
- /// Hosts can constrain an isolated component while reusing the same input
- /// lifecycle. The editor supplies its full window bounds through `begin`.
- pub(crate) fn begin_in(
- &mut self,
- ctx: &Context,
- viewport: Rect,
- bounds: Rect,
- context: PieContext,
- ) -> Vec {
- self.begin_with_pointer_policy(ctx, viewport, bounds, context, |ctx, viewport, point| {
- // An isolated component has no editor gizmo. Preserve egui's
- // pointer ownership while leaving editor hit regions to `begin`.
- viewport.contains(point)
- && !ctx.egui_is_using_pointer()
- && ctx.layer_id_at(point) == Some(egui::LayerId::background())
- })
- }
-
fn begin_with_pointer_policy(
&mut self,
ctx: &Context,
diff --git a/src/input/pie_input/inspection.rs b/src/input/pie_input/inspection.rs
new file mode 100644
index 0000000..9db3010
--- /dev/null
+++ b/src/input/pie_input/inspection.rs
@@ -0,0 +1,37 @@
+//! Inspection and isolated fixtures for executable native tooling.
+use super::*;
+
+impl PieInput {
+ pub(crate) fn for_instance(id: egui::Id) -> Self {
+ Self {
+ instance: Some(id),
+ ..Self::default()
+ }
+ }
+
+ pub fn view_active(&self) -> bool {
+ matches!(self.pie, Some(ActivePie::View(_)))
+ }
+
+ pub fn shading_active(&self) -> bool {
+ matches!(self.pie, Some(ActivePie::Shading(_)))
+ }
+
+ /// Hosts can constrain an isolated component while reusing the same input
+ /// lifecycle. The editor supplies its full window bounds through `begin`.
+ pub(crate) fn begin_in(
+ &mut self,
+ ctx: &Context,
+ viewport: Rect,
+ bounds: Rect,
+ context: PieContext,
+ ) -> Vec {
+ self.begin_with_pointer_policy(ctx, viewport, bounds, context, |ctx, viewport, point| {
+ // An isolated component has no editor gizmo. Preserve egui's
+ // pointer ownership while leaving editor hit regions to `begin`.
+ viewport.contains(point)
+ && !ctx.egui_is_using_pointer()
+ && ctx.layer_id_at(point) == Some(egui::LayerId::background())
+ })
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..14c47fa
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,100 @@
+//! Shared N3 application composition. Implementation modules remain private.
+//! Browser exports are a host adapter, not a public modeling kernel.
+mod asset_io;
+#[cfg(not(target_arch = "wasm32"))]
+mod documentation;
+mod editor;
+mod input;
+mod model;
+#[cfg(not(target_arch = "wasm32"))]
+mod native;
+mod render;
+mod scene;
+mod scene_view;
+mod settings;
+mod terminal;
+mod theme;
+mod ui;
+#[cfg(target_arch = "wasm32")]
+mod web;
+#[cfg(not(target_arch = "wasm32"))]
+mod workbench;
+
+// Browser host policies are portable and tested without a browser or its clock.
+#[cfg(all(test, not(target_arch = "wasm32")))]
+#[path = "web/settings.rs"]
+mod web_settings_tests;
+#[cfg(all(test, not(target_arch = "wasm32")))]
+#[path = "web/status.rs"]
+mod web_status_tests;
+
+#[cfg(not(target_arch = "wasm32"))]
+use asset_io::document as document_io;
+#[cfg(not(target_arch = "wasm32"))]
+use documentation as doc_harness;
+#[cfg(not(target_arch = "wasm32"))]
+use documentation::{animation as doc_animation, capture as doc_capture, input as doc_input};
+use editor::{edit_history, numeric_transform, snapping};
+use input::{
+ keyboard_input, move_input, navigation_events, navigation_input, navigation_state, pie_input,
+ pointer_policy, scroll_input, shortcuts,
+};
+use model::{document, mesh, units};
+use render::{camera, edit_feedback, object_feedback, orientation, renderer};
+#[cfg(not(target_arch = "wasm32"))]
+use std::path::PathBuf;
+use ui::{axis_gizmo, controls, ruler_2d, workspace_ui};
+
+#[cfg(not(target_arch = "wasm32"))]
+pub fn run_native() -> Result<(), Box> {
+ let mut args = std::env::args_os().skip(1);
+ let mut initial = args.next();
+ if initial.as_deref() == Some(std::ffi::OsStr::new("--workbench")) {
+ return match (args.next(), args.next()) {
+ (None, None) => workbench::run(),
+ (Some(mode), None) if mode == "evidence" => {
+ workbench::evidence().map_err(|error| std::io::Error::other(error).into())
+ }
+ _ => Err("Usage: n3 --workbench [evidence]".into()),
+ };
+ }
+ let access = if initial.as_deref() == Some(std::ffi::OsStr::new("--read-only")) {
+ initial = args.next();
+ editor::EditorAccess::ReadOnly
+ } else {
+ editor::EditorAccess::ReadWrite
+ };
+ if initial.as_deref() == Some(std::ffi::OsStr::new("--docs")) {
+ let mode = args
+ .next()
+ .and_then(|mode| mode.into_string().ok())
+ .ok_or("Usage: n3 --docs update|check")?;
+ if !matches!(mode.as_str(), "update" | "check") || args.next().is_some() {
+ return Err("Usage: n3 --docs update|check".into());
+ }
+ doc_harness::run(&mode).map_err(std::io::Error::other)?;
+ return Ok(());
+ }
+ if initial.as_deref() == Some(std::ffi::OsStr::new("--check")) {
+ let path = args
+ .next()
+ .map(PathBuf::from)
+ .ok_or("Usage: n3 --check PATH")?;
+ if args.next().is_some() {
+ return Err("Usage: n3 --check PATH".into());
+ }
+ let asset = asset_io::load(&path).map_err(std::io::Error::other)?;
+ asset.document.validate().map_err(std::io::Error::other)?;
+ let summary = serde_json::json!({
+ "valid": true, "version": asset.document.version,
+ "length_unit": asset.document.length_unit, "objects": asset.document.objects.len(),
+ "linked_assets": asset.assets.len(), "diagnostics": asset.diagnostics,
+ });
+ println!("{summary}");
+ return Ok(());
+ }
+ if args.next().is_some() {
+ return Err("Usage: n3 [--read-only] [PATH] | --check PATH | --docs update|check | --workbench [evidence]".into());
+ }
+ native::run(initial.map(PathBuf::from), access)
+}
diff --git a/src/main.rs b/src/main.rs
index 984594e..c038e68 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,81 +1,8 @@
-//! N3 composition root. Implementation modules are private to this application.
-mod asset_io;
-mod documentation;
-mod editor;
-mod input;
-mod model;
-mod native;
-mod render;
-mod scene;
-mod scene_view;
-mod settings;
-mod terminal;
-mod theme;
-mod ui;
-mod workbench;
-
-use asset_io::document as document_io;
-use documentation as doc_harness;
-use documentation::{animation as doc_animation, capture as doc_capture, input as doc_input};
-use editor::{edit_history, numeric_transform, snapping};
-use input::{
- keyboard_input, move_input, navigation_events, navigation_input, navigation_state, pie_input,
- pointer_policy, scroll_input, shortcuts,
-};
-use model::{document, mesh, units};
-use render::{camera, edit_feedback, object_feedback, orientation, renderer};
-use std::path::PathBuf;
-use ui::{axis_gizmo, controls, ruler_2d, workspace_ui};
-
+//! Native entry point for the shared N3 application.
+#[cfg(not(target_arch = "wasm32"))]
fn main() -> Result<(), Box> {
- let mut args = std::env::args_os().skip(1);
- let mut initial = args.next();
- if initial.as_deref() == Some(std::ffi::OsStr::new("--workbench")) {
- return match (args.next(), args.next()) {
- (None, None) => workbench::run(),
- (Some(mode), None) if mode == "evidence" => {
- workbench::evidence().map_err(|error| std::io::Error::other(error).into())
- }
- _ => Err("Usage: n3 --workbench [evidence]".into()),
- };
- }
- let access = if initial.as_deref() == Some(std::ffi::OsStr::new("--read-only")) {
- initial = args.next();
- editor::EditorAccess::ReadOnly
- } else {
- editor::EditorAccess::ReadWrite
- };
- if initial.as_deref() == Some(std::ffi::OsStr::new("--docs")) {
- let mode = args
- .next()
- .and_then(|mode| mode.into_string().ok())
- .ok_or("Usage: n3 --docs update|check")?;
- if !matches!(mode.as_str(), "update" | "check") || args.next().is_some() {
- return Err("Usage: n3 --docs update|check".into());
- }
- doc_harness::run(&mode).map_err(std::io::Error::other)?;
- return Ok(());
- }
- if initial.as_deref() == Some(std::ffi::OsStr::new("--check")) {
- let path = args
- .next()
- .map(PathBuf::from)
- .ok_or("Usage: n3 --check PATH")?;
- if args.next().is_some() {
- return Err("Usage: n3 --check PATH".into());
- }
- let asset = asset_io::load(&path).map_err(std::io::Error::other)?;
- asset.document.validate().map_err(std::io::Error::other)?;
- let summary = serde_json::json!({
- "valid": true, "version": asset.document.version,
- "length_unit": asset.document.length_unit, "objects": asset.document.objects.len(),
- "linked_assets": asset.assets.len(), "diagnostics": asset.diagnostics,
- });
- println!("{summary}");
- return Ok(());
- }
- if args.next().is_some() {
- return Err("Usage: n3 [--read-only] [PATH] | --check PATH | --docs update|check | --workbench [evidence]".into());
- }
- native::run(initial.map(PathBuf::from), access)
+ n3::run_native()
}
+
+#[cfg(target_arch = "wasm32")]
+fn main() {}
diff --git a/src/model/document.rs b/src/model/document.rs
index b14f721..0f39ef1 100644
--- a/src/model/document.rs
+++ b/src/model/document.rs
@@ -788,6 +788,7 @@ impl Document {
Ok(())
}
+ #[allow(dead_code)] // Explicit conversion supports fixtures; editing uses transactions.
pub fn convert_object(&mut self, id: u64) -> Result<()> {
let mesh = self.eval_object(id)?;
self.transact(move |document| {
diff --git a/src/native.rs b/src/native.rs
index 4efb09a..37f89d9 100644
--- a/src/native.rs
+++ b/src/native.rs
@@ -3,13 +3,13 @@ mod settings_host;
mod settings_store;
use crate::asset_io::LoadedDocument;
+use crate::render::workspace::{FrameTarget, WorkspaceRenderer};
use crate::{
- document_io, keyboard_input, navigation_events, renderer, scroll_input,
+ document_io, keyboard_input, navigation_events, scroll_input,
settings::{ResolvedTheme, Settings, ThemeMode},
shortcuts, workspace_ui,
};
use keyboard_input::{NumberKey, NumberKeyInput};
-use renderer::{SceneRenderer, ViewportRenderOptions};
use settings_host::SettingsHost;
use settings_store::FileSettingsStore;
use shortcuts::{HostEffect, ShortcutFrame};
@@ -76,12 +76,9 @@ struct NativeWindow {
config: wgpu::SurfaceConfiguration,
context: egui::Context,
input: egui_winit::State,
- ui_renderer: egui_wgpu::Renderer,
- scene: SceneRenderer,
+ graphics: WorkspaceRenderer,
state: WorkspaceUi,
applied_window_theme: ThemeMode,
- scene_size: [u32; 2],
- uploaded_revision: u64,
load_document: crate::document::Document,
cursor: Option,
number_keys: NumberKeyInput,
@@ -165,12 +162,8 @@ impl NativeWindow {
window.theme(),
Some(device.limits().max_texture_dimension_2d as usize),
);
- let mut ui_renderer =
- egui_wgpu::Renderer::new(&device, format, egui_wgpu::RendererOptions::default());
- let scene = SceneRenderer::new(&device, config.width, config.height);
- let scene_texture =
- ui_renderer.register_native_texture(&device, &scene.view, wgpu::FilterMode::Linear);
- let scene_size = [config.width, config.height];
+ let (graphics, scene_texture) =
+ WorkspaceRenderer::new(&device, format, [config.width, config.height]);
let mut state = WorkspaceUi::new(scene_texture);
// Native sessions are opt-in: guide/workbench hosts keep isolated fixtures.
// The process starts only after opening Terminal and measuring its grid.
@@ -186,12 +179,9 @@ impl NativeWindow {
config,
context,
input,
- ui_renderer,
- scene,
+ graphics,
state,
applied_window_theme: ThemeMode::System,
- scene_size,
- uploaded_revision: u64::MAX,
load_document: crate::document::Document::default(),
cursor: None,
number_keys: NumberKeyInput::default(),
@@ -434,58 +424,14 @@ impl NativeWindow {
loaded: LoadedDocument,
append: bool,
) -> Result<(), String> {
- // Prepare against an isolated candidate before publishing either CPU or
- // GPU state. Opening/importing a file is infrequent; placement afterwards
- // reuses the normal renderer resource cache.
- let mut candidate = WorkspaceUi::new(self.state.scene_texture);
- let copy = LoadedDocument {
- document: loaded.document.clone(),
- assets: loaded.assets.clone(),
- diagnostics: loaded.diagnostics.clone(),
- saved_bytes: loaded.saved_bytes.clone(),
- };
- if append {
- candidate.editor = crate::editor::Editor::new(self.state.editor.document.clone())?;
- candidate.editor.frame = self.state.editor.frame;
- candidate.asset_views = self.state.asset_views.clone();
- candidate
- .editor
- .set_asset_frames(self.state.editor.asset_frames().clone())?;
- candidate.import_loaded_document(copy)?;
- } else {
- candidate.install_loaded_document(path.clone(), copy)?;
- }
- if let Some(mesh) = &candidate.mesh {
- SceneRenderer::validate_mesh(&self.device, mesh)?;
- }
- let mut gpu = SceneRenderer::new(
- &self.device,
- self.scene_size[0].max(1),
- self.scene_size[1].max(1),
- );
- gpu.set_assets(
+ self.graphics.install(
&self.device,
&self.queue,
- &candidate.placed_scenes(),
- &candidate.editor.frame,
- )?;
- if let Some(mesh) = &candidate.mesh {
- gpu.set_mesh(&self.device, mesh)?;
- }
- if append {
- self.state.import_loaded_document(loaded)?;
- } else {
- self.state.install_loaded_document(path, loaded)?;
- }
- self.ui_renderer.update_egui_texture_from_wgpu_texture(
- &self.device,
- &gpu.view,
- wgpu::FilterMode::Linear,
- self.state.scene_texture,
- );
- self.scene = gpu;
- self.uploaded_revision = self.state.mesh_revision;
- Ok(())
+ &mut self.state,
+ path,
+ loaded,
+ append,
+ )
}
fn save_document(&mut self, save_as: bool) -> bool {
if !self.state.editor.can_edit() {
@@ -861,77 +807,7 @@ impl NativeWindow {
self.state.error = Some(error);
}
self.input
- .handle_platform_output(&self.window, output.platform_output);
- let pixels_per_point = output.pixels_per_point;
- let limit = self.device.limits().max_texture_dimension_2d;
- let width = (self.state.viewport.width() * pixels_per_point)
- .round()
- .clamp(1.0, limit as f32) as u32;
- let height = (self.state.viewport.height() * pixels_per_point)
- .round()
- .clamp(1.0, limit as f32) as u32;
- let resized = self.scene_size != [width, height];
- if resized {
- self.scene.resize(&self.device, width, height);
- }
- // Preflight expanded proxy buffers before updating imported geometry:
- // either cache must retain the previous frame if this GPU cannot upload.
- let upload_allowed = match self
- .state
- .mesh
- .as_ref()
- .map(|mesh| SceneRenderer::validate_mesh(&self.device, mesh))
- .transpose()
- {
- Ok(_) => true,
- Err(error) => {
- self.state.error = Some(error);
- false
- }
- };
- if upload_allowed
- && let Err(error) = self.scene.set_assets(
- &self.device,
- &self.queue,
- &self.state.placed_scenes(),
- &self.state.editor.frame,
- )
- {
- self.state.asset_views = previous_assets;
- for view in self.state.asset_views.values_mut() {
- view.playback.playing = false;
- }
- let frames = self
- .state
- .asset_views
- .iter()
- .map(|(key, view)| (key.clone(), view.frame.clone()))
- .collect();
- let _ = self.state.editor.set_asset_frames(frames);
- let _ = self.state.refresh_mesh();
- self.state.error = Some(error);
- }
- if resized {
- self.ui_renderer.update_egui_texture_from_wgpu_texture(
- &self.device,
- &self.scene.view,
- wgpu::FilterMode::Linear,
- self.state.scene_texture,
- );
- self.scene_size = [width, height];
- }
- if upload_allowed && self.uploaded_revision != self.state.mesh_revision {
- let result = if let Some(mesh) = &self.state.mesh {
- self.scene.set_mesh(&self.device, mesh)
- } else {
- self.scene.clear_mesh();
- Ok(())
- };
- match result {
- Ok(()) => self.uploaded_revision = self.state.mesh_revision,
- Err(error) => self.state.error = Some(error),
- }
- }
+ .handle_platform_output(&self.window, std::mem::take(&mut output.platform_output));
let title = self
.state
.save_path
@@ -943,89 +819,25 @@ impl NativeWindow {
title,
if self.state.is_dirty() { " •" } else { "" }
));
- let jobs = ctx.tessellate(output.shapes, pixels_per_point);
- for (id, deltas) in std::mem::take(&mut output.textures_delta.set) {
- for delta in deltas {
- self.ui_renderer
- .update_texture(&self.device, &self.queue, id, &delta);
- }
- }
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
- let mut encoder = self
- .device
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("workspace frame"),
- });
- self.scene.set_visible_objects(self.state.visible_objects());
- self.scene.set_highlights(self.state.object_highlights());
- self.scene
- .set_edit_selection(&self.device, self.state.edit_selection());
- self.scene.set_pixel_scale(pixels_per_point);
- let gizmo = self
- .state
- .editor
- .transform_gizmo_vertices(self.state.viewport, &self.state.camera, self.state.z_up)
- .unwrap_or_else(|error| {
- self.state.error = Some(error);
- Vec::new()
- });
- self.scene.set_transform_gizmo(&self.queue, gizmo);
- if let Err(error) = self.scene.render(
- &self.queue,
- &mut encoder,
- &self.state.camera,
- ViewportRenderOptions {
- shading: self.state.shading,
- xray: self.state.editor.xray_enabled(),
- show_edges: self.state.show_edges,
- show_grid: self.state.show_grid,
- z_up: self.state.z_up,
- background: crate::theme::Palette::new(
- self.state.resolved_theme(),
- self.state.accent_color,
- )
- .workbench_viewport,
+ self.graphics.paint(
+ FrameTarget {
+ device: &self.device,
+ queue: &self.queue,
+ view: &view,
+ size: [self.config.width, self.config.height],
},
- ) {
- self.state.error = Some(error);
- }
- let screen = egui_wgpu::ScreenDescriptor {
- size_in_pixels: [self.config.width, self.config.height],
- pixels_per_point,
- };
- let buffers = self.ui_renderer.update_buffers(
- &self.device,
- &self.queue,
- &mut encoder,
- &jobs,
- &screen,
+ &ctx,
+ &mut self.state,
+ &mut output,
+ previous_assets,
);
- {
- let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
- label: Some("egui composite"),
- color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- view: &view,
- resolve_target: None,
- depth_slice: None,
- ops: wgpu::Operations {
- load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
- store: wgpu::StoreOp::Store,
- },
- })],
- ..Default::default()
- });
- self.ui_renderer
- .render(&mut pass.forget_lifetime(), &jobs, &screen);
- }
- self.queue
- .submit(buffers.into_iter().chain(std::iter::once(encoder.finish())));
self.window.pre_present_notify();
self.queue.present(frame);
- for id in std::mem::take(&mut output.textures_delta.free) {
- self.ui_renderer.free_texture(&id);
- }
+ self.graphics
+ .finish_frame(std::mem::take(&mut output.textures_delta.free));
if reconfigure_after_present {
self.resize();
}
diff --git a/src/render/camera.rs b/src/render/camera.rs
index 62419f9..910b538 100644
--- a/src/render/camera.rs
+++ b/src/render/camera.rs
@@ -266,6 +266,7 @@ impl Camera {
}
/// Snap to an orthographic view from this world direction, preserving target and zoom.
+ #[allow(dead_code)] // Instant orientation is also useful to replay and measurement consumers.
pub fn look_from(&mut self, direction: Vec3) {
self.look_from_with_transition(direction, Transition::Instant);
}
diff --git a/src/render/edit_feedback.rs b/src/render/edit_feedback.rs
index 59aae27..49a9cf9 100644
--- a/src/render/edit_feedback.rs
+++ b/src/render/edit_feedback.rs
@@ -32,6 +32,7 @@ impl EditSelection {
/// One color per endpoint allows the GPU to interpolate the orange-to-dark
/// transition. An edge becomes uniformly orange only when both ends are
/// selected; it is not implicitly promoted to an edge-selection mode.
+ #[allow(dead_code)] // Guide assertions inspect the same endpoint-color contract.
pub fn edge_colors(&self, object: u64, vertices: [u64; 2]) -> [egui::Color32; 2] {
vertices.map(|vertex| {
if self.vertex_selected(object, vertex) {
diff --git a/src/render/mod.rs b/src/render/mod.rs
index 2cea194..0c8080b 100644
--- a/src/render/mod.rs
+++ b/src/render/mod.rs
@@ -9,3 +9,4 @@ pub(crate) mod renderer;
pub(crate) mod scene_renderer;
pub(crate) mod shading;
pub(crate) mod transform_gizmo;
+pub(crate) mod workspace;
diff --git a/src/render/workspace.rs b/src/render/workspace.rs
new file mode 100644
index 0000000..9c12b5c
--- /dev/null
+++ b/src/render/workspace.rs
@@ -0,0 +1,256 @@
+//! Shared frame mechanics; hosts retain device, surface, clock and scheduling policy.
+//! This concrete owner reuses revision caches without dynamic dispatch or a
+//! browser-sized capability floor. Each caller supplies its actual GPU limits.
+use super::renderer::{SceneRenderer, ViewportRenderOptions};
+use crate::{
+ asset_io::LoadedDocument, document::AssetInstance, scene_view::SceneView,
+ workspace_ui::WorkspaceUi,
+};
+use std::{collections::BTreeMap, path::PathBuf};
+
+pub(crate) struct FrameTarget<'a> {
+ pub device: &'a wgpu::Device,
+ pub queue: &'a wgpu::Queue,
+ pub view: &'a wgpu::TextureView,
+ pub size: [u32; 2],
+}
+
+#[cfg(test)]
+mod tests;
+
+pub(crate) struct WorkspaceRenderer {
+ ui: egui_wgpu::Renderer,
+ scene: SceneRenderer,
+ scene_size: [u32; 2],
+ uploaded_revision: u64,
+}
+
+impl WorkspaceRenderer {
+ pub(crate) fn new(
+ device: &wgpu::Device,
+ format: wgpu::TextureFormat,
+ size: [u32; 2],
+ ) -> (Self, egui::TextureId) {
+ let mut ui =
+ egui_wgpu::Renderer::new(device, format, egui_wgpu::RendererOptions::default());
+ let scene = SceneRenderer::new(device, size[0], size[1]);
+ let texture = ui.register_native_texture(device, &scene.view, wgpu::FilterMode::Linear);
+ (
+ Self {
+ ui,
+ scene,
+ scene_size: size,
+ uploaded_revision: u64::MAX,
+ },
+ texture,
+ )
+ }
+
+ pub(crate) fn install(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ state: &mut WorkspaceUi,
+ path: PathBuf,
+ loaded: LoadedDocument,
+ append: bool,
+ ) -> Result<(), String> {
+ // Prepare against an isolated candidate before publishing either CPU or
+ // GPU state. Opening/importing a file is infrequent; placement afterwards
+ // reuses the normal renderer resource cache.
+ let mut candidate = WorkspaceUi::new(state.scene_texture);
+ let copy = LoadedDocument {
+ document: loaded.document.clone(),
+ assets: loaded.assets.clone(),
+ diagnostics: loaded.diagnostics.clone(),
+ saved_bytes: loaded.saved_bytes.clone(),
+ };
+ if append {
+ candidate.editor = crate::editor::Editor::new(state.editor.document.clone())?;
+ candidate.editor.frame = state.editor.frame;
+ candidate.asset_views = state.asset_views.clone();
+ candidate
+ .editor
+ .set_asset_frames(state.editor.asset_frames().clone())?;
+ candidate.import_loaded_document(copy)?;
+ } else {
+ candidate.install_loaded_document(path.clone(), copy)?;
+ }
+ if let Some(mesh) = &candidate.mesh {
+ SceneRenderer::validate_mesh(device, mesh)?;
+ }
+ let mut gpu =
+ SceneRenderer::new(device, self.scene_size[0].max(1), self.scene_size[1].max(1));
+ gpu.set_assets(
+ device,
+ queue,
+ &candidate.placed_scenes(),
+ &candidate.editor.frame,
+ )?;
+ if let Some(mesh) = &candidate.mesh {
+ gpu.set_mesh(device, mesh)?;
+ }
+ if append {
+ state.import_loaded_document(loaded)?;
+ } else {
+ state.install_loaded_document(path, loaded)?;
+ }
+ self.ui.update_egui_texture_from_wgpu_texture(
+ device,
+ &gpu.view,
+ wgpu::FilterMode::Linear,
+ state.scene_texture,
+ );
+ self.scene = gpu;
+ self.uploaded_revision = state.mesh_revision;
+ Ok(())
+ }
+ /// Synchronize shared caches, encode and submit this frame. Surface acquire,
+ /// presentation notification, and presentation remain host operations.
+ pub(crate) fn paint(
+ &mut self,
+ target: FrameTarget<'_>,
+ ctx: &egui::Context,
+ state: &mut WorkspaceUi,
+ output: &mut egui::FullOutput,
+ previous_assets: BTreeMap,
+ ) {
+ let device = target.device;
+ let queue = target.queue;
+ let pixels_per_point = output.pixels_per_point;
+ let limit = device.limits().max_texture_dimension_2d;
+ let width = (state.viewport.width() * pixels_per_point)
+ .round()
+ .clamp(1.0, limit as f32) as u32;
+ let height = (state.viewport.height() * pixels_per_point)
+ .round()
+ .clamp(1.0, limit as f32) as u32;
+ let resized = self.scene_size != [width, height];
+ if resized {
+ self.scene.resize(device, width, height);
+ }
+ // Preflight expanded proxy buffers before updating imported geometry:
+ // either cache must retain the previous frame if this GPU cannot upload.
+ let upload_allowed = match state
+ .mesh
+ .as_ref()
+ .map(|mesh| SceneRenderer::validate_mesh(device, mesh))
+ .transpose()
+ {
+ Ok(_) => true,
+ Err(error) => {
+ state.error = Some(error);
+ false
+ }
+ };
+ if upload_allowed
+ && let Err(error) =
+ self.scene
+ .set_assets(device, queue, &state.placed_scenes(), &state.editor.frame)
+ {
+ state.asset_views = previous_assets;
+ for view in state.asset_views.values_mut() {
+ view.playback.playing = false;
+ }
+ let frames = state
+ .asset_views
+ .iter()
+ .map(|(key, view)| (key.clone(), view.frame.clone()))
+ .collect();
+ let _ = state.editor.set_asset_frames(frames);
+ let _ = state.refresh_mesh();
+ state.error = Some(error);
+ }
+ if resized {
+ self.ui.update_egui_texture_from_wgpu_texture(
+ device,
+ &self.scene.view,
+ wgpu::FilterMode::Linear,
+ state.scene_texture,
+ );
+ self.scene_size = [width, height];
+ }
+ if upload_allowed && self.uploaded_revision != state.mesh_revision {
+ let result = if let Some(mesh) = &state.mesh {
+ self.scene.set_mesh(device, mesh)
+ } else {
+ self.scene.clear_mesh();
+ Ok(())
+ };
+ match result {
+ Ok(()) => self.uploaded_revision = state.mesh_revision,
+ Err(error) => state.error = Some(error),
+ }
+ }
+ let jobs = ctx.tessellate(std::mem::take(&mut output.shapes), pixels_per_point);
+ for (id, deltas) in std::mem::take(&mut output.textures_delta.set) {
+ for delta in deltas {
+ self.ui.update_texture(device, queue, id, &delta);
+ }
+ }
+ let view = target.view;
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("workspace frame"),
+ });
+ self.scene.set_visible_objects(state.visible_objects());
+ self.scene.set_highlights(state.object_highlights());
+ self.scene
+ .set_edit_selection(device, state.edit_selection());
+ self.scene.set_pixel_scale(pixels_per_point);
+ let gizmo = state
+ .editor
+ .transform_gizmo_vertices(state.viewport, &state.camera, state.z_up)
+ .unwrap_or_else(|error| {
+ state.error = Some(error);
+ Vec::new()
+ });
+ self.scene.set_transform_gizmo(queue, gizmo);
+ if let Err(error) = self.scene.render(
+ queue,
+ &mut encoder,
+ &state.camera,
+ ViewportRenderOptions {
+ shading: state.shading,
+ xray: state.editor.xray_enabled(),
+ show_edges: state.show_edges,
+ show_grid: state.show_grid,
+ z_up: state.z_up,
+ background: crate::theme::Palette::new(state.resolved_theme(), state.accent_color)
+ .workbench_viewport,
+ },
+ ) {
+ state.error = Some(error);
+ }
+ let screen = egui_wgpu::ScreenDescriptor {
+ size_in_pixels: target.size,
+ pixels_per_point,
+ };
+ let buffers = self
+ .ui
+ .update_buffers(device, queue, &mut encoder, &jobs, &screen);
+ {
+ let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("egui composite"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ self.ui.render(&mut pass.forget_lifetime(), &jobs, &screen);
+ }
+ queue.submit(buffers.into_iter().chain(std::iter::once(encoder.finish())));
+ }
+
+ /// Free UI textures only after the host has presented the submitted frame.
+ pub(crate) fn finish_frame(&mut self, textures: impl IntoIterator- ) {
+ for id in textures {
+ self.ui.free_texture(&id);
+ }
+ }
+}
diff --git a/src/render/workspace/tests.rs b/src/render/workspace/tests.rs
new file mode 100644
index 0000000..bbe17e2
--- /dev/null
+++ b/src/render/workspace/tests.rs
@@ -0,0 +1,101 @@
+use super::*;
+
+#[test]
+fn shared_frame_preserves_revisions_and_rejects_invalid_replacement() {
+ let capture = pollster::block_on(crate::doc_capture::Capture::new(640, 480)).unwrap();
+ let device = &capture.device;
+ let queue = &capture.queue;
+ let size = [640, 480];
+ let format = wgpu::TextureFormat::Rgba8Unorm;
+ let (mut graphics, texture) = WorkspaceRenderer::new(device, format, size);
+ let mut state = WorkspaceUi::new(texture);
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/obj/cube-quads.obj");
+ graphics
+ .install(
+ device,
+ queue,
+ &mut state,
+ fixture.clone(),
+ crate::asset_io::load(&fixture).unwrap(),
+ false,
+ )
+ .unwrap();
+ let document = state.editor.document.clone();
+ let revision = state.mesh_revision;
+ let mut invalid = crate::asset_io::load(&fixture).unwrap();
+ invalid
+ .document
+ .objects
+ .push(invalid.document.objects[0].clone());
+ assert!(
+ graphics
+ .install(device, queue, &mut state, fixture, invalid, false)
+ .is_err()
+ );
+ assert_eq!(state.editor.document, document);
+ assert_eq!(state.mesh_revision, revision);
+ assert_eq!(graphics.uploaded_revision, revision);
+
+ let target = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("shared host renderer test"),
+ size: wgpu::Extent3d {
+ width: size[0],
+ height: size[1],
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[],
+ });
+ let view = target.create_view(&wgpu::TextureViewDescriptor::default());
+ let ctx = egui::Context::default();
+ crate::workspace_ui::configure_context(&ctx);
+ for selected in [false, true, false] {
+ if selected {
+ state.dispatch(crate::shortcuts::Command::SelectAll, &ctx, false);
+ } else {
+ state.editor.selected_objects.clear();
+ }
+ let previous_assets = state.asset_views.clone();
+ let mut output = ctx.run_ui(
+ egui::RawInput {
+ screen_rect: Some(egui::Rect::from_min_size(
+ egui::Pos2::ZERO,
+ egui::vec2(size[0] as f32, size[1] as f32),
+ )),
+ ..Default::default()
+ },
+ |ui| state.ui(ui),
+ );
+ state.refresh_mesh().unwrap();
+ graphics.paint(
+ FrameTarget {
+ device,
+ queue,
+ view: &view,
+ size,
+ },
+ &ctx,
+ &mut state,
+ &mut output,
+ previous_assets,
+ );
+ graphics.finish_frame(std::mem::take(&mut output.textures_delta.free));
+ assert_eq!(
+ state.mesh_revision, revision,
+ "Selection does not rebuild authored geometry"
+ );
+ assert_eq!(graphics.uploaded_revision, revision);
+ assert_eq!(state.editor.document, document);
+ assert!(state.error.is_none(), "{:?}", state.error);
+ }
+ device
+ .poll(wgpu::PollType::Wait {
+ submission_index: None,
+ timeout: Some(std::time::Duration::from_secs(30)),
+ })
+ .unwrap();
+}
diff --git a/src/settings/mod.rs b/src/settings/mod.rs
index 7c68141..be4f996 100644
--- a/src/settings/mod.rs
+++ b/src/settings/mod.rs
@@ -5,9 +5,12 @@ mod controller;
mod model;
pub use controller::{SettingsController, SettingsStore};
+#[allow(unused_imports)]
+// Available to host stores; the controller also enforces this shared limit.
+pub use model::MAX_SETTINGS_BYTES;
pub use model::{
- AccentColor, DEFAULT_VIEW_DURATION_MS, DisplayUnit, MAX_SETTINGS_BYTES, MAX_VIEW_DURATION_MS,
- ResolvedTheme, ReturnTo3D, Settings, SnapMode, ThemeMode,
+ AccentColor, DEFAULT_VIEW_DURATION_MS, DisplayUnit, MAX_VIEW_DURATION_MS, ResolvedTheme,
+ ReturnTo3D, Settings, SnapMode, ThemeMode,
};
#[cfg(test)]
diff --git a/src/settings/model.rs b/src/settings/model.rs
index 84db967..cc642a5 100644
--- a/src/settings/model.rs
+++ b/src/settings/model.rs
@@ -189,12 +189,14 @@ impl Settings {
/// Decode a strict JSON object with built-in defaults for omitted keys.
/// Unknown data is ignored here; the controller retains it for file updates.
+ #[allow(dead_code)] // Standalone codec for validation and guide consumers.
pub fn from_json(json: &str) -> Result {
Ok(SettingsFile::parse(Some(json.as_bytes()), &Self::default())?.effective)
}
/// Canonical, readable JSON for the known preferences. Controller writes
/// additionally preserve unknown data already present in the user's file.
+ #[allow(dead_code)] // Standalone codec for validation and guide consumers.
pub fn to_json(&self) -> Result {
let bytes = SettingsFile::encode(&self.raw_values()?)?;
String::from_utf8(bytes).map_err(|error| error.to_string())
diff --git a/src/terminal/browser.rs b/src/terminal/browser.rs
new file mode 100644
index 0000000..7aa9894
--- /dev/null
+++ b/src/terminal/browser.rs
@@ -0,0 +1,176 @@
+//! A browser owns no local process or PTY. Keep the Tool Dock contract without
+//! compiling a native terminal dependency or presenting an executable prompt.
+use crate::theme::{self, Palette};
+use egui::{FontId, Id, Pos2, Rect, Sense, Stroke, Vec2};
+
+const NOTICE: &str = "Local shells are unavailable in the browser.";
+
+pub(crate) struct TerminalSession;
+
+impl TerminalSession {
+ pub(crate) fn placeholder() -> Self {
+ Self
+ }
+
+ pub(crate) fn set_default_colors(&mut self, _foreground: [u8; 3], _background: [u8; 3]) {}
+
+ pub(crate) fn poll(&mut self, _ctx: &egui::Context) {}
+}
+
+pub(crate) struct TerminalView {
+ id: Id,
+}
+
+pub(crate) struct TerminalViewResponse {
+ pub content_rect: Rect,
+ pub notice_rect: Rect,
+ pub focused: bool,
+ pub status: String,
+ pub start_requested: bool,
+}
+
+impl TerminalView {
+ pub(crate) fn new(id: Id) -> Self {
+ Self { id }
+ }
+
+ fn focus_id(&self) -> Id {
+ self.id.with("contents")
+ }
+
+ pub(crate) fn is_focused(&self, ctx: &egui::Context) -> bool {
+ ctx.memory(|memory| memory.has_focus(self.focus_id()))
+ }
+
+ pub(crate) fn request_focus(&self, ctx: &egui::Context) {
+ ctx.memory_mut(|memory| {
+ memory.request_focus(self.focus_id());
+ memory.set_focus_lock_filter(
+ self.focus_id(),
+ egui::EventFilter {
+ escape: true,
+ ..Default::default()
+ },
+ );
+ });
+ }
+
+ pub(crate) fn deactivate(&mut self, ctx: &egui::Context, _session: &mut TerminalSession) {
+ ctx.memory_mut(|memory| memory.surrender_focus(self.focus_id()));
+ }
+
+ pub(crate) fn show(
+ &mut self,
+ ui: &mut egui::Ui,
+ session: &mut TerminalSession,
+ ) -> TerminalViewResponse {
+ let (rect, _) = ui.allocate_exact_size(ui.available_size().max(Vec2::ZERO), Sense::hover());
+ let visible = rect.intersect(ui.clip_rect());
+ let palette = Palette::from_context(ui.ctx(), crate::settings::AccentColor::DEFAULT);
+ let painter = ui.painter().with_clip_rect(visible);
+ painter.rect_filled(rect, 0, palette.background);
+ let padding = theme::space::LG;
+ let notice = painter.layout(
+ NOTICE.into(),
+ FontId::proportional(theme::text::XS),
+ palette.muted_foreground,
+ (rect.width() - padding * 2.0).max(1.0),
+ );
+ let notice_rect = Rect::from_min_max(
+ rect.min,
+ Pos2::new(
+ rect.right(),
+ (rect.top() + (notice.size().y + padding * 2.0).max(theme::ROW_HEIGHT))
+ .min(rect.bottom()),
+ ),
+ );
+ painter.galley(
+ notice_rect.left_top() + Vec2::splat(padding),
+ notice,
+ palette.muted_foreground,
+ );
+ painter.hline(
+ rect.x_range(),
+ notice_rect.bottom(),
+ Stroke::new(1.0, palette.border),
+ );
+ let content_rect = Rect::from_min_max(
+ Pos2::new(rect.left(), notice_rect.bottom()),
+ rect.right_bottom(),
+ )
+ .intersect(visible);
+ let response = ui.interact(content_rect, self.focus_id(), Sense::click());
+ response.widget_info(|| {
+ egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Terminal. {NOTICE}"))
+ });
+ let competing = egui::Popup::is_any_open(ui.ctx())
+ || ui.memory(|memory| memory.top_modal_layer().is_some());
+ if !competing && response.clicked() {
+ self.request_focus(ui.ctx());
+ }
+ let focused = self.is_focused(ui.ctx()) && !competing;
+ if !ui.input(|input| input.focused)
+ || response.clicked_elsewhere()
+ || (focused && ui.input(|input| input.key_pressed(egui::Key::Escape)))
+ {
+ self.deactivate(ui.ctx(), session);
+ }
+ // The shared dock claims viewport input for this frame, even when Escape
+ // just relinquished terminal focus. This panel never dispatches commands.
+ TerminalViewResponse {
+ content_rect,
+ notice_rect: notice_rect.intersect(visible),
+ focused,
+ status: NOTICE.into(),
+ start_requested: false,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn unavailable_terminal_never_requests_a_shell_and_escape_releases_focus() {
+ let ctx = egui::Context::default();
+ let mut view = TerminalView::new(Id::new("browser-terminal"));
+ let mut session = TerminalSession::placeholder();
+ session.set_default_colors([255; 3], [0; 3]);
+ session.poll(&ctx);
+ let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
+ view.show(ui, &mut session);
+ });
+ output.textures_delta.clear();
+ view.request_focus(&ctx);
+ for escape in [false, true] {
+ let input = egui::RawInput {
+ screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(640.0, 300.0))),
+ events: if escape {
+ vec![egui::Event::Key {
+ key: egui::Key::Escape,
+ physical_key: None,
+ pressed: true,
+ repeat: false,
+ modifiers: egui::Modifiers::NONE,
+ }]
+ } else {
+ Vec::new()
+ },
+ ..Default::default()
+ };
+ let mut output = ctx.run_ui(input, |ui| {
+ let output = view.show(ui, &mut session);
+ assert_eq!(output.status, NOTICE);
+ assert!(!output.start_requested);
+ if !escape {
+ assert!(output.focused);
+ }
+ assert!(output.content_rect.is_positive());
+ assert!(output.notice_rect.is_positive());
+ });
+ output.textures_delta.clear();
+ assert_eq!(view.is_focused(&ctx), !escape);
+ }
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 8505c3e..b5ef126 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -4,16 +4,14 @@
//! its implementation: https://github.com/zed-industries/zed/tree/main/crates/terminal_view
//! Alacritty-specific types stay inside this feature. Only the native host opts
//! into shell creation; workbench and test consumers use deterministic sessions.
-mod backend;
-mod colors;
-mod input;
-mod session;
-mod view;
+#[cfg_attr(target_arch = "wasm32", path = "browser.rs")]
+#[cfg_attr(not(target_arch = "wasm32"), path = "native.rs")]
+mod host;
-pub(crate) use session::{SessionStatus, TerminalSession};
-pub(crate) use view::TerminalView;
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) use host::SessionStatus;
+pub(crate) use host::{TerminalSession, TerminalView};
-pub(crate) const PLACEHOLDER_NOTICE: &str = "Sample output · no shell process";
-
-#[cfg(test)]
-mod tests;
+// Exercise the unavailable-host contract in the ordinary native test suite.
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod browser;
diff --git a/src/terminal/native.rs b/src/terminal/native.rs
new file mode 100644
index 0000000..827665e
--- /dev/null
+++ b/src/terminal/native.rs
@@ -0,0 +1,20 @@
+//! Native terminal composition. Process, PTY, and emulator dependencies stay here.
+#[path = "backend.rs"]
+mod backend;
+#[path = "colors.rs"]
+mod colors;
+#[path = "input.rs"]
+mod input;
+#[path = "session.rs"]
+mod session;
+#[path = "view.rs"]
+mod view;
+
+pub(crate) use session::{SessionStatus, TerminalSession};
+pub(crate) use view::TerminalView;
+
+const PLACEHOLDER_NOTICE: &str = "Sample output · no shell process";
+
+#[cfg(test)]
+#[path = "tests.rs"]
+mod tests;
diff --git a/src/theme.rs b/src/theme.rs
index 8354128..f0de46f 100644
--- a/src/theme.rs
+++ b/src/theme.rs
@@ -205,6 +205,7 @@ fn legible_accent(
}
pub const ROW_HEIGHT: f32 = size::XL_3;
+#[allow(dead_code)] // Shared layout expectation witnessed by the executable guide.
pub const TOOL_DOCK_TAB_BAR_HEIGHT: f32 = size::STEP_8;
pub const TOOL_DOCK_TAB_BAR_INSET: f32 = space::SM;
/// Tailwind's default font-size scale in egui points, plus N3's compact and
diff --git a/src/ui/action_state.rs b/src/ui/action_state.rs
index b1468f7..5872564 100644
--- a/src/ui/action_state.rs
+++ b/src/ui/action_state.rs
@@ -19,8 +19,11 @@ impl WorkspaceUi {
!self.editor.is_interacting() && !self.mouse_navigation.wants_input()
}
TerminalPanel => {
+ state.visible = self.host_capabilities.local_terminal;
state.checked = Some(self.tool_dock.active == Some(ToolDockPanel::Terminal));
- !self.editor.is_interacting() && !self.mouse_navigation.wants_input()
+ state.visible
+ && !self.editor.is_interacting()
+ && !self.mouse_navigation.wants_input()
}
CloseToolDock => {
self.tool_dock.active.is_some()
diff --git a/src/ui/controls.rs b/src/ui/controls.rs
index dc634b8..1068b10 100644
--- a/src/ui/controls.rs
+++ b/src/ui/controls.rs
@@ -1,10 +1,13 @@
//! Stable UI identities. Labels come from the same catalog used by real widgets.
//! Recording is opt-in; the native viewer does not retain a documentation trace.
use crate::document::PolyhedronType;
-use std::{
- collections::BTreeMap,
- sync::{Arc, Mutex},
-};
+
+// The browser has no guide recorder: select no-op hooks without per-widget
+// context lookups, locks, or retained trace data. Native tracing stays opt-in.
+#[cfg_attr(target_arch = "wasm32", path = "controls/no_trace.rs")]
+#[cfg_attr(not(target_arch = "wasm32"), path = "controls/trace.rs")]
+mod tracing;
+pub use tracing::*;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Control {
@@ -20,6 +23,7 @@ pub enum Control {
AnimationRuler,
AnimationFit,
SceneHierarchy,
+ #[allow(dead_code)] // Retained catalog identity for scene inspection controls.
ScenePicker,
SceneClip,
ScenePlay,
@@ -27,6 +31,7 @@ pub enum Control {
SceneTime,
SceneLoop,
SceneSpeed,
+ #[allow(dead_code)] // Retained catalog identity for scene inspection controls.
SceneCamera,
SceneExposure,
SceneDetails,
@@ -186,183 +191,6 @@ pub enum Control {
}
impl Control {
- pub const ALL: [Self; 175] = [
- Self::ToolDock,
- Self::TerminalPanelToggle,
- Self::TerminalPanel,
- Self::TerminalViewport,
- Self::TerminalStatus,
- Self::AnimationPanelToggle,
- Self::ToolDockTabBar,
- Self::ToolDockClose,
- Self::AnimationTimeline,
- Self::AnimationRuler,
- Self::AnimationFit,
- Self::SceneHierarchy,
- Self::ScenePicker,
- Self::SceneClip,
- Self::ScenePlay,
- Self::SceneRest,
- Self::SceneTime,
- Self::SceneLoop,
- Self::SceneSpeed,
- Self::SceneCamera,
- Self::SceneExposure,
- Self::SceneDetails,
- Self::SceneDiagnostics,
- Self::N3Menu,
- Self::FileMenu,
- Self::Open,
- Self::Import,
- Self::Frame,
- Self::Projection,
- Self::ViewMenu,
- Self::LocalViewMenu,
- Self::Xray,
- Self::ViewPie,
- Self::ShadingPie,
- Self::PieSolid,
- Self::PieWireframe,
- Self::PieMaterialPreview,
- Self::PieTop,
- Self::PieBottom,
- Self::PieFront,
- Self::PieBack,
- Self::PieLeft,
- Self::PieRight,
- Self::PieSelection,
- Self::ViewPerspective,
- Self::ViewFront,
- Self::ViewRight,
- Self::ViewTop,
- Self::ViewBack,
- Self::ViewLeft,
- Self::ViewBottom,
- Self::Edges,
- Self::Grid,
- Self::SnapGrid,
- Self::SnapSpacingMenu,
- Self::SnapAdaptive,
- Self::SnapFixed,
- Self::SnapStep,
- Self::SnapFeedback,
- Self::ZUp,
- Self::Preferences,
- Self::PreferencesWindow,
- Self::PreferencesTitle,
- Self::PreferencesClose,
- Self::AppearanceThemeMenu,
- Self::ThemeSystem,
- Self::ThemeLight,
- Self::ThemeDark,
- Self::AccentColor,
- Self::ResetAccent,
- Self::SettingsJson,
- Self::ReloadSettings,
- Self::PreciseScroll,
- Self::AnimateViews,
- Self::Duration,
- Self::Return3DMenu,
- Self::Return3DPerspective,
- Self::Return3DOrientation,
- Self::Return3DBoth,
- Self::Gizmo,
- Self::GizmoPreferences,
- Self::NavigationPlanar,
- Self::NavigationFree,
- Self::AxisX,
- Self::AxisNegX,
- Self::AxisY,
- Self::AxisNegY,
- Self::AxisZ,
- Self::AxisNegZ,
- Self::Viewport,
- Self::ViewportToolbar,
- Self::EditToolbar,
- Self::StatusBar,
- Self::SceneInfo,
- Self::ToastStack,
- Self::ToastDismiss,
- Self::ToastAction,
- Self::ViewportMenu,
- Self::ViewportFrame,
- Self::FrameSelection,
- Self::ViewportPreferences,
- Self::SelectAll,
- Self::DuplicateSelection,
- Self::DeleteSelection,
- Self::MakeFace,
- Self::Dismiss,
- Self::New,
- Self::Save,
- Self::SaveAs,
- Self::InsertMenu,
- Self::EmptyStateInsert,
- Self::InsertCube,
- Self::InsertCylinder,
- Self::InsertCone,
- Self::InsertTorus,
- Self::InsertPlane,
- Self::InsertCircle,
- Self::InsertSphere,
- Self::InsertPolyhedron,
- Self::ToolView,
- Self::ToolMove,
- Self::ToolRotate,
- Self::ToolScale,
- Self::LeaveEdit,
- Self::Inspector,
- Self::LengthUnitMenu,
- Self::LengthMillimeters,
- Self::LengthCentimeters,
- Self::LengthMeters,
- Self::LengthInches,
- Self::LengthFeet,
- Self::PrimitiveX,
- Self::PrimitiveY,
- Self::PrimitiveZ,
- Self::CircleRadius,
- Self::CircleVertices,
- Self::CircleFill,
- Self::PositionX,
- Self::PositionY,
- Self::PositionZ,
- Self::RotationX,
- Self::RotationY,
- Self::RotationZ,
- Self::ScaleX,
- Self::ScaleY,
- Self::ScaleZ,
- Self::VertexDeltaX,
- Self::VertexDeltaY,
- Self::VertexDeltaZ,
- Self::Segments,
- Self::MinorSegments,
- Self::SphereRings,
- Self::PolyhedronTypeMenu,
- Self::PolyhedronTetrahedron,
- Self::PolyhedronCube,
- Self::PolyhedronOctahedron,
- Self::PolyhedronIcosahedron,
- Self::PolyhedronDodecahedron,
- Self::PolyhedronSize,
- Self::TubeRatio,
- Self::ObjectList,
- Self::LayerRename,
- Self::TransformX,
- Self::TransformY,
- Self::TransformZ,
- Self::TransformXY,
- Self::TransformXZ,
- Self::TransformYZ,
- Self::TransformUniform,
- Self::MoveAxisLock,
- Self::TransformValue,
- Self::Ruler2DHorizontal,
- Self::Ruler2DVertical,
- Self::Ruler2DViewToggle,
- Self::Ruler2DHide,
- ];
pub fn id(self) -> &'static str {
match self {
Self::ToolDock => "tool-dock",
@@ -721,12 +549,6 @@ impl Control {
Self::Ruler2DHide => "Hide 2D ruler",
}
}
- pub fn parse(id: &str) -> Result {
- Self::ALL
- .into_iter()
- .find(|control| control.id() == id)
- .ok_or_else(|| format!("Unknown control binding: {id}"))
- }
}
pub fn shortcut_label(id: &str) -> String {
@@ -735,100 +557,6 @@ pub fn shortcut_label(id: &str) -> String {
.label()
}
-#[derive(Clone, Debug)]
-pub struct Observed {
- pub label: String,
- pub parents: Vec,
- pub rect: egui::Rect,
- pub enabled: bool,
-}
-
-#[derive(Clone, Debug, Default)]
-pub struct Trace {
- pub controls: BTreeMap,
- parents: Vec,
- errors: Vec,
-}
-
-impl Trace {
- pub fn get(&self, control: Control) -> Result<&Observed, String> {
- self.validate()?;
- self.controls
- .get(&control)
- .ok_or_else(|| format!("Control {} is not visible in the real UI", control.id()))
- }
- pub fn validate(&self) -> Result<(), String> {
- if self.errors.is_empty() {
- Ok(())
- } else {
- Err(self.errors.join("\n"))
- }
- }
- pub fn path(&self, control: Control) -> Result {
- let item = self.get(control)?;
- let mut labels = Vec::new();
- for parent in &item.parents {
- labels.push(self.get(*parent)?.label.as_str());
- }
- labels.push(&item.label);
- Ok(labels.join(" → "))
- }
-}
-
-type SharedTrace = Arc>;
-fn key() -> egui::Id {
- egui::Id::new("n3.documentation.trace")
-}
-fn active(ctx: &egui::Context) -> Option {
- ctx.data(|data| data.get_temp(key()))
-}
-
-pub fn enable(ctx: &egui::Context) {
- ctx.data_mut(|data| data.insert_temp(key(), Arc::new(Mutex::new(Trace::default()))));
-}
-pub(crate) fn disable(ctx: &egui::Context) {
- ctx.data_mut(|data| data.remove::(key()));
-}
-pub fn begin_pass(ctx: &egui::Context) {
- if let Some(trace) = active(ctx) {
- *trace.lock().unwrap() = Trace::default();
- }
-}
-pub fn snapshot(ctx: &egui::Context) -> Trace {
- active(ctx)
- .expect("documentation tracing enabled")
- .lock()
- .unwrap()
- .clone()
-}
-pub fn record(ctx: &egui::Context, control: Control, label: &str, rect: egui::Rect, enabled: bool) {
- if let Some(trace) = active(ctx) {
- let mut trace = trace.lock().unwrap();
- let observed = Observed {
- label: label.to_owned(),
- parents: trace.parents.clone(),
- rect,
- enabled,
- };
- if trace.controls.insert(control, observed).is_some() {
- trace.errors.push(format!(
- "Ambiguous control: {} appears twice in one UI pass",
- control.id()
- ));
- }
- }
-}
-pub fn scope(ctx: &egui::Context, parent: Control, draw: impl FnOnce() -> R) -> R {
- let trace = active(ctx);
- if let Some(trace) = &trace {
- trace.lock().unwrap().parents.push(parent);
- }
- let result = draw();
- if let Some(trace) = &trace {
- trace.lock().unwrap().parents.pop();
- }
- result
-}
/// Extend menu dividers through the popup frame's content padding.
pub fn menu_separator(ui: &mut egui::Ui) -> egui::Response {
ui.add(egui::Separator::default().grow(f32::from(ui.spacing().menu_margin.left)))
diff --git a/src/ui/controls/no_trace.rs b/src/ui/controls/no_trace.rs
new file mode 100644
index 0000000..475844b
--- /dev/null
+++ b/src/ui/controls/no_trace.rs
@@ -0,0 +1,20 @@
+//! Browser runtime hooks. Guide tracing is absent, so these calls retain no state.
+use super::Control;
+
+#[inline]
+pub fn begin_pass(_ctx: &egui::Context) {}
+
+#[inline]
+pub fn record(
+ _ctx: &egui::Context,
+ _control: Control,
+ _label: &str,
+ _rect: egui::Rect,
+ _enabled: bool,
+) {
+}
+
+#[inline]
+pub fn scope(_ctx: &egui::Context, _parent: Control, draw: impl FnOnce() -> R) -> R {
+ draw()
+}
diff --git a/src/ui/controls/trace.rs b/src/ui/controls/trace.rs
new file mode 100644
index 0000000..9cba29b
--- /dev/null
+++ b/src/ui/controls/trace.rs
@@ -0,0 +1,288 @@
+//! Opt-in live UI witnesses for the executable guide and workbench.
+use super::Control;
+use std::{
+ collections::BTreeMap,
+ sync::{Arc, Mutex},
+};
+
+impl Control {
+ pub const ALL: [Self; 175] = [
+ Self::ToolDock,
+ Self::TerminalPanelToggle,
+ Self::TerminalPanel,
+ Self::TerminalViewport,
+ Self::TerminalStatus,
+ Self::AnimationPanelToggle,
+ Self::ToolDockTabBar,
+ Self::ToolDockClose,
+ Self::AnimationTimeline,
+ Self::AnimationRuler,
+ Self::AnimationFit,
+ Self::SceneHierarchy,
+ Self::ScenePicker,
+ Self::SceneClip,
+ Self::ScenePlay,
+ Self::SceneRest,
+ Self::SceneTime,
+ Self::SceneLoop,
+ Self::SceneSpeed,
+ Self::SceneCamera,
+ Self::SceneExposure,
+ Self::SceneDetails,
+ Self::SceneDiagnostics,
+ Self::N3Menu,
+ Self::FileMenu,
+ Self::Open,
+ Self::Import,
+ Self::Frame,
+ Self::Projection,
+ Self::ViewMenu,
+ Self::LocalViewMenu,
+ Self::Xray,
+ Self::ViewPie,
+ Self::ShadingPie,
+ Self::PieSolid,
+ Self::PieWireframe,
+ Self::PieMaterialPreview,
+ Self::PieTop,
+ Self::PieBottom,
+ Self::PieFront,
+ Self::PieBack,
+ Self::PieLeft,
+ Self::PieRight,
+ Self::PieSelection,
+ Self::ViewPerspective,
+ Self::ViewFront,
+ Self::ViewRight,
+ Self::ViewTop,
+ Self::ViewBack,
+ Self::ViewLeft,
+ Self::ViewBottom,
+ Self::Edges,
+ Self::Grid,
+ Self::SnapGrid,
+ Self::SnapSpacingMenu,
+ Self::SnapAdaptive,
+ Self::SnapFixed,
+ Self::SnapStep,
+ Self::SnapFeedback,
+ Self::ZUp,
+ Self::Preferences,
+ Self::PreferencesWindow,
+ Self::PreferencesTitle,
+ Self::PreferencesClose,
+ Self::AppearanceThemeMenu,
+ Self::ThemeSystem,
+ Self::ThemeLight,
+ Self::ThemeDark,
+ Self::AccentColor,
+ Self::ResetAccent,
+ Self::SettingsJson,
+ Self::ReloadSettings,
+ Self::PreciseScroll,
+ Self::AnimateViews,
+ Self::Duration,
+ Self::Return3DMenu,
+ Self::Return3DPerspective,
+ Self::Return3DOrientation,
+ Self::Return3DBoth,
+ Self::Gizmo,
+ Self::GizmoPreferences,
+ Self::NavigationPlanar,
+ Self::NavigationFree,
+ Self::AxisX,
+ Self::AxisNegX,
+ Self::AxisY,
+ Self::AxisNegY,
+ Self::AxisZ,
+ Self::AxisNegZ,
+ Self::Viewport,
+ Self::ViewportToolbar,
+ Self::EditToolbar,
+ Self::StatusBar,
+ Self::SceneInfo,
+ Self::ToastStack,
+ Self::ToastDismiss,
+ Self::ToastAction,
+ Self::ViewportMenu,
+ Self::ViewportFrame,
+ Self::FrameSelection,
+ Self::ViewportPreferences,
+ Self::SelectAll,
+ Self::DuplicateSelection,
+ Self::DeleteSelection,
+ Self::MakeFace,
+ Self::Dismiss,
+ Self::New,
+ Self::Save,
+ Self::SaveAs,
+ Self::InsertMenu,
+ Self::EmptyStateInsert,
+ Self::InsertCube,
+ Self::InsertCylinder,
+ Self::InsertCone,
+ Self::InsertTorus,
+ Self::InsertPlane,
+ Self::InsertCircle,
+ Self::InsertSphere,
+ Self::InsertPolyhedron,
+ Self::ToolView,
+ Self::ToolMove,
+ Self::ToolRotate,
+ Self::ToolScale,
+ Self::LeaveEdit,
+ Self::Inspector,
+ Self::LengthUnitMenu,
+ Self::LengthMillimeters,
+ Self::LengthCentimeters,
+ Self::LengthMeters,
+ Self::LengthInches,
+ Self::LengthFeet,
+ Self::PrimitiveX,
+ Self::PrimitiveY,
+ Self::PrimitiveZ,
+ Self::CircleRadius,
+ Self::CircleVertices,
+ Self::CircleFill,
+ Self::PositionX,
+ Self::PositionY,
+ Self::PositionZ,
+ Self::RotationX,
+ Self::RotationY,
+ Self::RotationZ,
+ Self::ScaleX,
+ Self::ScaleY,
+ Self::ScaleZ,
+ Self::VertexDeltaX,
+ Self::VertexDeltaY,
+ Self::VertexDeltaZ,
+ Self::Segments,
+ Self::MinorSegments,
+ Self::SphereRings,
+ Self::PolyhedronTypeMenu,
+ Self::PolyhedronTetrahedron,
+ Self::PolyhedronCube,
+ Self::PolyhedronOctahedron,
+ Self::PolyhedronIcosahedron,
+ Self::PolyhedronDodecahedron,
+ Self::PolyhedronSize,
+ Self::TubeRatio,
+ Self::ObjectList,
+ Self::LayerRename,
+ Self::TransformX,
+ Self::TransformY,
+ Self::TransformZ,
+ Self::TransformXY,
+ Self::TransformXZ,
+ Self::TransformYZ,
+ Self::TransformUniform,
+ Self::MoveAxisLock,
+ Self::TransformValue,
+ Self::Ruler2DHorizontal,
+ Self::Ruler2DVertical,
+ Self::Ruler2DViewToggle,
+ Self::Ruler2DHide,
+ ];
+
+ pub fn parse(id: &str) -> Result {
+ Self::ALL
+ .into_iter()
+ .find(|control| control.id() == id)
+ .ok_or_else(|| format!("Unknown control binding: {id}"))
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct Observed {
+ pub label: String,
+ pub parents: Vec,
+ pub rect: egui::Rect,
+ pub enabled: bool,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct Trace {
+ pub controls: BTreeMap,
+ parents: Vec,
+ errors: Vec,
+}
+
+impl Trace {
+ pub fn get(&self, control: Control) -> Result<&Observed, String> {
+ self.validate()?;
+ self.controls
+ .get(&control)
+ .ok_or_else(|| format!("Control {} is not visible in the real UI", control.id()))
+ }
+ pub fn validate(&self) -> Result<(), String> {
+ if self.errors.is_empty() {
+ Ok(())
+ } else {
+ Err(self.errors.join("\n"))
+ }
+ }
+ pub fn path(&self, control: Control) -> Result {
+ let item = self.get(control)?;
+ let mut labels = Vec::new();
+ for parent in &item.parents {
+ labels.push(self.get(*parent)?.label.as_str());
+ }
+ labels.push(&item.label);
+ Ok(labels.join(" → "))
+ }
+}
+
+type SharedTrace = Arc>;
+fn key() -> egui::Id {
+ egui::Id::new("n3.documentation.trace")
+}
+fn active(ctx: &egui::Context) -> Option {
+ ctx.data(|data| data.get_temp(key()))
+}
+
+pub fn enable(ctx: &egui::Context) {
+ ctx.data_mut(|data| data.insert_temp(key(), Arc::new(Mutex::new(Trace::default()))));
+}
+pub(crate) fn disable(ctx: &egui::Context) {
+ ctx.data_mut(|data| data.remove::(key()));
+}
+pub fn begin_pass(ctx: &egui::Context) {
+ if let Some(trace) = active(ctx) {
+ *trace.lock().unwrap() = Trace::default();
+ }
+}
+pub fn snapshot(ctx: &egui::Context) -> Trace {
+ active(ctx)
+ .expect("documentation tracing enabled")
+ .lock()
+ .unwrap()
+ .clone()
+}
+pub fn record(ctx: &egui::Context, control: Control, label: &str, rect: egui::Rect, enabled: bool) {
+ if let Some(trace) = active(ctx) {
+ let mut trace = trace.lock().unwrap();
+ let observed = Observed {
+ label: label.to_owned(),
+ parents: trace.parents.clone(),
+ rect,
+ enabled,
+ };
+ if trace.controls.insert(control, observed).is_some() {
+ trace.errors.push(format!(
+ "Ambiguous control: {} appears twice in one UI pass",
+ control.id()
+ ));
+ }
+ }
+}
+pub fn scope(ctx: &egui::Context, parent: Control, draw: impl FnOnce() -> R) -> R {
+ let trace = active(ctx);
+ if let Some(trace) = &trace {
+ trace.lock().unwrap().parents.push(parent);
+ }
+ let result = draw();
+ if let Some(trace) = &trace {
+ trace.lock().unwrap().parents.pop();
+ }
+ result
+}
diff --git a/src/ui/menu.rs b/src/ui/menu.rs
index dc9c739..5babeb1 100644
--- a/src/ui/menu.rs
+++ b/src/ui/menu.rs
@@ -60,6 +60,7 @@ impl Item {
}
/// Contextual presentation keeps the action and its shortcut authoritative.
+ #[allow(dead_code)] // Contextual labels are part of the shared menu contract.
pub(crate) fn label(mut self, label: &'static str) -> Self {
self.label = Some(label);
self
diff --git a/src/ui/ruler_2d.rs b/src/ui/ruler_2d.rs
index 5d1f4a8..c907180 100644
--- a/src/ui/ruler_2d.rs
+++ b/src/ui/ruler_2d.rs
@@ -26,6 +26,7 @@ pub fn content_rect(viewport: Rect) -> Rect {
pub struct Tick {
/// Absolute logical screen coordinate along this ruler.
pub position: f32,
+ #[allow(dead_code)] // Preserve the measured value alongside its formatted label.
pub value: f64,
/// Each tick is a labeled graduation.
pub label: Option,
diff --git a/src/ui/timeline/inspection.rs b/src/ui/timeline/inspection.rs
new file mode 100644
index 0000000..e250423
--- /dev/null
+++ b/src/ui/timeline/inspection.rs
@@ -0,0 +1,34 @@
+//! Inspection and isolated fixtures for executable native tooling.
+use super::*;
+
+impl Timeline {
+ pub(crate) fn visible_range(&self) -> Option {
+ self.visible
+ }
+
+ pub(crate) fn selection(&self) -> Option<&Selection> {
+ self.selected.as_ref()
+ }
+
+ pub(crate) fn is_scrubbing(&self) -> bool {
+ self.scrub.is_some()
+ }
+
+ /// Read only selected identities through the current revision's indexes.
+ /// Hosts can present metadata without searching/cloning the whole snapshot.
+ pub(crate) fn inspected_keys<'a>(&'a self, data: &'a Data) -> impl Iterator
- {
+ self.selected.iter().flat_map(move |selection| {
+ selection.keys.iter().filter_map(move |key| {
+ let prepared = self.prepared.as_ref()?;
+ if self.source != Some((std::ptr::from_ref(data) as usize, data.revision)) {
+ return None;
+ }
+ let track = prepared.track_index(key.track)?;
+ data.tracks
+ .get(track)?
+ .keys
+ .get(prepared.key_index(key.track, key.key)?)
+ })
+ })
+ }
+}
diff --git a/src/ui/timeline/mod.rs b/src/ui/timeline/mod.rs
index 5efd8ce..998e490 100644
--- a/src/ui/timeline/mod.rs
+++ b/src/ui/timeline/mod.rs
@@ -1,5 +1,9 @@
//! Read-only time presentation. Hosts own evaluation, clocks, and accepted state.
+
+// Replay and workbench inspection, without a browser runtime dependency.
pub(crate) mod data;
+#[cfg(not(target_arch = "wasm32"))]
+mod inspection;
mod navigation;
mod paint;
#[cfg(test)]
@@ -70,7 +74,9 @@ pub(crate) struct Metrics {
pub keys_considered: usize,
pub markers: usize,
pub prepared: bool,
+ #[allow(dead_code)] // Performance observers compare total and visible work.
pub total_tracks: usize,
+ #[allow(dead_code)] // Performance observers compare total and visible work.
pub total_keys: usize,
}
#[derive(Default)]
@@ -171,15 +177,6 @@ pub(crate) struct Timeline {
frame: Option,
}
impl Timeline {
- pub(crate) fn visible_range(&self) -> Option {
- self.visible
- }
- pub(crate) fn selection(&self) -> Option<&Selection> {
- self.selected.as_ref()
- }
- pub(crate) fn is_scrubbing(&self) -> bool {
- self.scrub.is_some()
- }
pub(crate) fn is_marquee_active(&self) -> bool {
self.box_select
.as_ref()
@@ -213,23 +210,6 @@ impl Timeline {
.ok()
.map(TimeRange::fit_visible);
}
- /// Read only selected identities through the current revision's indexes.
- /// Hosts can present metadata without searching/cloning the whole snapshot.
- pub(crate) fn inspected_keys<'a>(&'a self, data: &'a Data) -> impl Iterator
- {
- self.selected.iter().flat_map(move |selection| {
- selection.keys.iter().filter_map(move |key| {
- let prepared = self.prepared.as_ref()?;
- if self.source != Some((std::ptr::from_ref(data) as usize, data.revision)) {
- return None;
- }
- let track = prepared.track_index(key.track)?;
- data.tracks
- .get(track)?
- .keys
- .get(prepared.key_index(key.track, key.key)?)
- })
- })
- }
fn reconcile(&mut self, data: &Data) {
let Some(prepared) = &self.prepared else {
diff --git a/src/ui/tool_dock.rs b/src/ui/tool_dock.rs
index f60af4c..250d9ea 100644
--- a/src/ui/tool_dock.rs
+++ b/src/ui/tool_dock.rs
@@ -252,6 +252,9 @@ impl WorkspaceUi {
for &tab in TABS {
let action = tab.action();
let state = self.action_state(action);
+ if !state.visible {
+ continue;
+ }
let response = ui
.add_enabled(
state.enabled,
diff --git a/src/ui/workspace_ui.rs b/src/ui/workspace_ui.rs
index ec9df86..43463e5 100644
--- a/src/ui/workspace_ui.rs
+++ b/src/ui/workspace_ui.rs
@@ -36,6 +36,12 @@ use std::{
time::Duration,
};
+// Executable guide/workbench observations are separate from the shared runtime.
+#[cfg(not(target_arch = "wasm32"))]
+mod inspection;
+#[cfg(not(target_arch = "wasm32"))]
+pub use inspection::SavedLayout;
+
#[path = "action_state.rs"]
mod action_state;
#[path = "animation_panel.rs"]
@@ -151,45 +157,6 @@ fn apply_sidebar_colors(ui: &mut egui::Ui, palette: Palette) {
visuals.selection.stroke.color = palette.sidebar_primary;
}
-/// The parts of a workspace arrangement that can be reused without changing
-/// the document, camera, selection, or edit history. This is intentionally an
-/// in-memory snapshot; there is no file format or compatibility contract yet.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub struct SavedLayout {
- pub hierarchy_width: f32,
- pub inspector_width: f32,
-}
-
-impl SavedLayout {
- pub const DEFAULT: Self = Self {
- hierarchy_width: DEFAULT_PANEL_WIDTH,
- inspector_width: DEFAULT_PANEL_WIDTH,
- };
-
- /// A stable, spacious arrangement for the executable guide's HD captures.
- pub const DOCUMENTATION: Self = Self::DEFAULT;
-
- fn normalized(self) -> Self {
- fn width(value: f32, min: f32, max: f32) -> f32 {
- if value.is_finite() {
- value.clamp(min, max)
- } else {
- DEFAULT_PANEL_WIDTH
- }
- }
- Self {
- hierarchy_width: width(self.hierarchy_width, HIERARCHY_MIN_WIDTH, 420.0),
- inspector_width: width(self.inspector_width, INSPECTOR_MIN_WIDTH, 480.0),
- }
- }
-}
-
-impl Default for SavedLayout {
- fn default() -> Self {
- Self::DEFAULT
- }
-}
-
/// A section is a heading and its existing controls. Keeping this spacing in
/// one place gives the inspector a consistent rhythm as controls are added.
fn inspector_section(ui: &mut egui::Ui, title: &str, content: impl FnOnce(&mut egui::Ui)) {
@@ -404,8 +371,34 @@ struct Ruler2DSelectionCache {
corners: Vec>,
}
-/// Shared application state and UI. Native I/O is dispatched by the host.
+/// Host-owned availability, separate from documents, preferences and history.
+/// Shared controls and dispatch consult these capabilities instead of defining
+/// another feature UI for each platform.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct HostCapabilities {
+ pub local_terminal: bool,
+ pub open_settings_file: bool,
+}
+
+impl Default for HostCapabilities {
+ fn default() -> Self {
+ Self {
+ local_terminal: true,
+ open_settings_file: true,
+ }
+ }
+}
+
+impl HostCapabilities {
+ pub(crate) const BROWSER: Self = Self {
+ local_terminal: false,
+ open_settings_file: false,
+ };
+}
+
+/// Shared application state and UI. I/O is dispatched by the host.
pub struct WorkspaceUi {
+ pub(crate) host_capabilities: HostCapabilities,
pub scene_texture: egui::TextureId,
n3_logo_texture: Option,
pub camera: Camera,
@@ -497,6 +490,7 @@ pub fn configure_context(context: &egui::Context) {
impl WorkspaceUi {
pub fn new(scene_texture: egui::TextureId) -> Self {
let mut state = Self {
+ host_capabilities: HostCapabilities::default(),
scene_texture,
n3_logo_texture: None,
camera: Camera::default(),
@@ -578,47 +572,6 @@ impl WorkspaceUi {
state
}
- /// Capture the current panel arrangement from egui's live resize state.
- /// The snapshot is deliberately ephemeral and contains no editor state.
- pub fn save_layout(&self, ctx: &egui::Context) -> SavedLayout {
- use egui::containers::panel::PanelState;
-
- SavedLayout {
- hierarchy_width: PanelState::load(ctx, egui::Id::new("hierarchy"))
- .map_or(DEFAULT_PANEL_WIDTH, |state| state.outer_rect.width()),
- inspector_width: PanelState::load(ctx, egui::Id::new("inspector"))
- .map_or(DEFAULT_PANEL_WIDTH, |state| state.outer_rect.width()),
- }
- .normalized()
- }
-
- /// Apply a layout before the next UI pass, including panels that egui has
- /// previously resized. The normal resize handles keep working afterward.
- pub fn apply_saved_layout(&mut self, ctx: &egui::Context, layout: SavedLayout) {
- use egui::containers::panel::PanelState;
-
- let layout = layout.normalized();
- ctx.data_mut(|data| {
- for (id, width) in [
- ("hierarchy", layout.hierarchy_width),
- ("inspector", layout.inspector_width),
- ] {
- // Panel reads only the previous rect's width, then writes
- // the actual on-screen rect during its next layout pass.
- data.insert_persisted(
- egui::Id::new(id),
- PanelState {
- outer_rect: egui::Rect::from_min_size(
- egui::Pos2::ZERO,
- egui::vec2(width, 0.0),
- ),
- },
- );
- }
- });
- ctx.request_repaint();
- }
-
pub fn set_system_theme(&mut self, theme: ResolvedTheme) {
self.system_theme = theme;
}
@@ -642,25 +595,6 @@ impl WorkspaceUi {
}
}
- /// Fixture convenience: native I/O callers pass the loader's exact snapshot.
- pub fn install_document(&mut self, path: PathBuf, document: Document) -> Result<(), String> {
- let loaded = if crate::document_io::is_native_path(&path) {
- let loaded = crate::asset_io::load(&path)?;
- if loaded.document != document {
- return Err("The file changed while opening. Open it again.".into());
- }
- loaded
- } else {
- crate::asset_io::LoadedDocument {
- document,
- assets: BTreeMap::new(),
- diagnostics: Vec::new(),
- saved_bytes: None,
- }
- };
- self.install_loaded_document(path, loaded)
- }
-
pub fn install_loaded_document(
&mut self,
path: PathBuf,
@@ -856,30 +790,6 @@ impl WorkspaceUi {
}
}
- /// Current visible row bounds, keyed by document identity rather than order.
- pub fn layer_row_rect(&self, id: u64) -> Option {
- if !self
- .editor
- .document
- .objects
- .iter()
- .any(|object| object.id == id)
- {
- return None;
- }
- self.layer_rows
- .iter()
- .find(|row| row.id == id)
- .map(|row| row.rect)
- }
-
- pub(crate) fn layer_row_icon(&self, id: u64) -> Option {
- self.layer_rows
- .iter()
- .find(|row| row.id == id)
- .map(|row| row.icon)
- }
-
pub fn layer_row_color(&self, id: u64) -> Option {
if !self
.editor
@@ -898,6 +808,8 @@ impl WorkspaceUi {
highlights.hovered == Some(id),
)
}
+ /// A host calls this only after persistence has acknowledged the exact bytes.
+ #[allow(dead_code)] // Download-only hosts cannot acknowledge a completed save.
pub fn mark_saved(&mut self, path: PathBuf, bytes: Vec) {
self.path = Some(path.clone());
self.save_path = Some(path);
@@ -1754,14 +1666,6 @@ impl WorkspaceUi {
self.pie_input.reset();
}
- pub fn view_pie_active(&self) -> bool {
- self.pie_input.view_active()
- }
-
- pub fn shading_pie_active(&self) -> bool {
- self.pie_input.shading_active()
- }
-
pub fn pie_owns_input(&self) -> bool {
self.pie_input.active() || self.pie_input.owns_frame
}
@@ -4525,10 +4429,12 @@ impl WorkspaceUi {
ui.colored_label(ui.visuals().error_fg_color, error);
}
ui.horizontal(|ui| {
- self.request_open_settings |= controls::button(ui, Control::SettingsJson)
- .on_hover_text(self.settings_location.as_deref()
- .unwrap_or("Edit global preferences as JSON. Changes apply to all N3 documents."))
- .clicked();
+ if self.host_capabilities.open_settings_file {
+ self.request_open_settings |= controls::button(ui, Control::SettingsJson)
+ .on_hover_text(self.settings_location.as_deref()
+ .unwrap_or("Edit global preferences as JSON. Changes apply to all N3 documents."))
+ .clicked();
+ }
self.request_reload_settings |= controls::button(ui, Control::ReloadSettings)
.on_hover_text("Reload global preferences, discarding unsaved preference changes. Your document is unchanged.")
.clicked();
diff --git a/src/ui/workspace_ui/inspection.rs b/src/ui/workspace_ui/inspection.rs
new file mode 100644
index 0000000..7ce3699
--- /dev/null
+++ b/src/ui/workspace_ui/inspection.rs
@@ -0,0 +1,136 @@
+//! Workspace fixture setup and observations for executable native tooling.
+//! Runtime hosts install resolved snapshots through `install_loaded_document`.
+use super::*;
+
+/// The parts of a workspace arrangement that can be reused without changing
+/// the document, camera, selection, or edit history. This is intentionally an
+/// in-memory snapshot; there is no file format or compatibility contract yet.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SavedLayout {
+ pub hierarchy_width: f32,
+ pub inspector_width: f32,
+}
+
+impl SavedLayout {
+ pub const DEFAULT: Self = Self {
+ hierarchy_width: DEFAULT_PANEL_WIDTH,
+ inspector_width: DEFAULT_PANEL_WIDTH,
+ };
+
+ /// A stable, spacious arrangement for the executable guide's HD captures.
+ pub const DOCUMENTATION: Self = Self::DEFAULT;
+
+ fn normalized(self) -> Self {
+ fn width(value: f32, min: f32, max: f32) -> f32 {
+ if value.is_finite() {
+ value.clamp(min, max)
+ } else {
+ DEFAULT_PANEL_WIDTH
+ }
+ }
+ Self {
+ hierarchy_width: width(self.hierarchy_width, HIERARCHY_MIN_WIDTH, 420.0),
+ inspector_width: width(self.inspector_width, INSPECTOR_MIN_WIDTH, 480.0),
+ }
+ }
+}
+
+impl Default for SavedLayout {
+ fn default() -> Self {
+ Self::DEFAULT
+ }
+}
+
+impl WorkspaceUi {
+ /// Capture the current panel arrangement from egui's live resize state.
+ /// The snapshot is deliberately ephemeral and contains no editor state.
+ pub fn save_layout(&self, ctx: &egui::Context) -> SavedLayout {
+ use egui::containers::panel::PanelState;
+
+ SavedLayout {
+ hierarchy_width: PanelState::load(ctx, egui::Id::new("hierarchy"))
+ .map_or(DEFAULT_PANEL_WIDTH, |state| state.outer_rect.width()),
+ inspector_width: PanelState::load(ctx, egui::Id::new("inspector"))
+ .map_or(DEFAULT_PANEL_WIDTH, |state| state.outer_rect.width()),
+ }
+ .normalized()
+ }
+
+ /// Apply a layout before the next UI pass, including panels that egui has
+ /// previously resized. The normal resize handles keep working afterward.
+ pub fn apply_saved_layout(&mut self, ctx: &egui::Context, layout: SavedLayout) {
+ use egui::containers::panel::PanelState;
+
+ let layout = layout.normalized();
+ ctx.data_mut(|data| {
+ for (id, width) in [
+ ("hierarchy", layout.hierarchy_width),
+ ("inspector", layout.inspector_width),
+ ] {
+ // Panel reads only the previous rect's width, then writes
+ // the actual on-screen rect during its next layout pass.
+ data.insert_persisted(
+ egui::Id::new(id),
+ PanelState {
+ outer_rect: egui::Rect::from_min_size(
+ egui::Pos2::ZERO,
+ egui::vec2(width, 0.0),
+ ),
+ },
+ );
+ }
+ });
+ ctx.request_repaint();
+ }
+
+ /// Fixture convenience: native I/O callers pass the loader's exact snapshot.
+ pub fn install_document(&mut self, path: PathBuf, document: Document) -> Result<(), String> {
+ let loaded = if crate::document_io::is_native_path(&path) {
+ let loaded = crate::asset_io::load(&path)?;
+ if loaded.document != document {
+ return Err("The file changed while opening. Open it again.".into());
+ }
+ loaded
+ } else {
+ crate::asset_io::LoadedDocument {
+ document,
+ assets: BTreeMap::new(),
+ diagnostics: Vec::new(),
+ saved_bytes: None,
+ }
+ };
+ self.install_loaded_document(path, loaded)
+ }
+
+ /// Current visible row bounds, keyed by document identity rather than order.
+ pub fn layer_row_rect(&self, id: u64) -> Option {
+ if !self
+ .editor
+ .document
+ .objects
+ .iter()
+ .any(|object| object.id == id)
+ {
+ return None;
+ }
+ self.layer_rows
+ .iter()
+ .find(|row| row.id == id)
+ .map(|row| row.rect)
+ }
+
+ pub(crate) fn layer_row_icon(&self, id: u64) -> Option {
+ self.layer_rows
+ .iter()
+ .find(|row| row.id == id)
+ .map(|row| row.icon)
+ }
+
+ pub fn view_pie_active(&self) -> bool {
+ self.pie_input.view_active()
+ }
+
+ pub fn shading_pie_active(&self) -> bool {
+ self.pie_input.shading_active()
+ }
+}
diff --git a/src/web.rs b/src/web.rs
new file mode 100644
index 0000000..ac9babf
--- /dev/null
+++ b/src/web.rs
@@ -0,0 +1,782 @@
+//! Browser host: canvas lifecycle and browser effects around the ordinary editor.
+//! The JS bridge is an experimental application adapter, not a public modeling kernel.
+use crate::{
+ asset_io::LoadedDocument,
+ input::{actions::ActionId, bindings},
+ keyboard_input::{NumberKey, NumberKeyInput},
+ navigation_events,
+ render::workspace::{FrameTarget, WorkspaceRenderer},
+ settings::{ResolvedTheme, SettingsStore},
+ shortcuts::{Command, HostEffect, ShortcutFrame},
+ workspace_ui::{WorkspaceUi, configure_context},
+};
+use std::{cell::RefCell, path::PathBuf, rc::Rc, sync::Arc};
+use wasm_bindgen::prelude::*;
+use web_time::{Duration, Instant};
+use winit::{
+ application::ApplicationHandler,
+ dpi::LogicalSize,
+ event::{ElementState, WindowEvent},
+ event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy},
+ platform::web::{EventLoopExtWebSys, WindowAttributesExtWebSys, WindowExtWebSys},
+ window::{Window, WindowId},
+};
+
+mod gestures;
+mod settings;
+mod settings_events;
+mod status;
+
+const SETTINGS_KEY: &str = "n3.settings.v1";
+struct BrowserSettingsStore;
+impl SettingsStore for BrowserSettingsStore {
+ fn read(&mut self) -> Result