Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c1090339 | |||
| 2715c1ce2c | |||
| d29a1be329 | |||
| 723e41706b | |||
| caea8c62a2 | |||
| 0b1394e407 | |||
| 1fedd21d3c | |||
| 534f6e03c1 | |||
| 4c3fc8b5dd | |||
| 741977c3fb | |||
| 32d5da5528 | |||
| 15fc01542c | |||
| ad2c01bc29 | |||
| 040b779f5f | |||
| 32d7ada3ab | |||
| 525097a0b2 | |||
| dff84f2b27 | |||
| 7007835541 | |||
| 82f9256a84 | |||
| e0eff7755f |
@@ -1,8 +0,0 @@
|
||||
.git
|
||||
**/.git
|
||||
target
|
||||
**/target
|
||||
**/AGENTS.md
|
||||
**/work
|
||||
**/ci-output
|
||||
.env
|
||||
@@ -1,26 +0,0 @@
|
||||
# Tessera GUI automation
|
||||
|
||||
[workflows](workflows/README.md) contains repository workflows.
|
||||
|
||||
## CI source checkout
|
||||
|
||||
All workflows use `runs-on: ces-control`; no shared Runner configuration is changed.
|
||||
`actions/checkout@v4` reads source over HTTPS using Gitea's automatically supplied
|
||||
`GITEA_TOKEN`. No additional SSH key or known_hosts secrets are required, and
|
||||
`persist-credentials: false` prevents credentials from being retained for later
|
||||
build steps.
|
||||
|
||||
Consumer workflows read the exact Aster commit from the checked-out project's
|
||||
`vendor/aster` gitlink, then use another standard checkout into that directory and
|
||||
verify the resulting HEAD. They do not follow Aster main automatically. Aster is
|
||||
currently a public repository, so reading it does not require private cross-repo
|
||||
authorization. If that visibility changes, diagnose the job token's read access
|
||||
instead of assuming an SSH key must be distributed.
|
||||
|
||||
The SSH URL in `.gitmodules` is retained for developer Git operations, which
|
||||
continue to use the dedicated codex-bot identity. CI's built-in job token is
|
||||
separate from local developer credentials.
|
||||
|
||||
Workflow definitions do not prove CI passed. CI/deployment state is not queried
|
||||
automatically. Local runtime checks are not performed for Aster, Lyra or Hestia;
|
||||
Tessera permits local native builds/window runs, with automated tests in CI.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Tessera GUI workflows
|
||||
|
||||
[ci.yml](ci.yml) verifies this project on main pushes and manual dispatch from main. It runs on ces-control, with a 60-minute timeout. HTTPS checkout and job token details are in [the parent README](../README.md).
|
||||
|
||||
There is no production service deployment in this workflow.
|
||||
|
||||
The container includes the Endor examples used by the live guide and tests. It
|
||||
validates the actual Aster-backed source-to-UI path, snapshot lifetime, layout and
|
||||
execution budgets, failed-update preservation and stale-result invalidation, in
|
||||
addition to the existing rendering checks and Clippy. Cargo uses official sources.
|
||||
The primary example now executes top-level let statements and ui.page().render
|
||||
through Aster's Interactive interface. Function-entry compatibility, original
|
||||
source positions, page methods and page budgets are also checked. The workflow
|
||||
checks the macOS packaging script's syntax without running it on Linux.
|
||||
Docker is used only in this remote workflow; local compilation is native.
|
||||
|
||||
The test image installs DejaVu Sans/Mono and Noto CJK for font metrics and Chinese
|
||||
fallback checks. Text regression tests inspect glyph selection, measured advances,
|
||||
fractional scale, multiline bounds, Unicode truncation and antialiased opacity.
|
||||
|
||||
## Incremental verification
|
||||
|
||||
Gitea 1.26 concurrency groups cancel superseded runs on the same ref. Markdown-only
|
||||
pushes to main skip verification; pull requests do not trigger this workflow; executable Endor examples and Rust
|
||||
files (including embedded guide strings) still trigger checks. Manual dispatch
|
||||
runs verification only when main is selected. Existing queued runs created from older workflow
|
||||
revisions retain their old behavior.
|
||||
|
||||
The workflow invokes [verify-ci.sh](../../scripts/verify-ci.sh). Docker builds the
|
||||
Rust 1.91.1/system/font toolchain from Dockerfile.ci over stdin, without sending a
|
||||
source build context. The checkout is mounted read-only at /build. Named volumes
|
||||
persist Cargo registry/git downloads and /cargo-target across container removal,
|
||||
including failed tests and cancelled runs. Main push and manual runs use the main cache scope. Changing Rust/toolchain compatibility requires updating
|
||||
the image and cache prefix in the script. Cargo lockfiles and fingerprints decide
|
||||
which dependencies/artifacts are reusable; caches do not bypass checks.
|
||||
|
||||
A host flock serializes each scope's writable target cache. Signal handling
|
||||
removes only the current run's named container; it never prunes shared Docker
|
||||
resources or deletes cache volumes. The first run populates caches; later runs
|
||||
still execute fmt, all tests and Clippy. Cargo compilation remains single-job to
|
||||
limit memory pressure on the shared host. No CPU-time guarantee is implied.
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Verify Tessera GUI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ["**/*.md"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: tessera-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
DOCKER_BUILDKIT: "0"
|
||||
steps:
|
||||
- name: Checkout project
|
||||
id: checkout_project
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: ${{ gitea.server_url }}
|
||||
persist-credentials: false
|
||||
- name: Read pinned Aster revision
|
||||
id: aster_revision
|
||||
run: |
|
||||
set -eu
|
||||
aster_sha=$(git ls-tree HEAD -- vendor/aster | awk '$1 == "160000" && $2 == "commit" && $4 == "vendor/aster" { print $3 }')
|
||||
case "$aster_sha" in *[!0-9a-f]*|'') echo "Invalid Aster gitlink" >&2; exit 1;; esac
|
||||
test "${#aster_sha}" -eq 40
|
||||
printf 'sha=%s\n' "$aster_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout pinned Aster
|
||||
id: checkout_aster
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: ${{ gitea.server_url }}
|
||||
repository: Zayden-Jung/endorphin-aster-vm
|
||||
ref: ${{ steps.aster_revision.outputs.sha }}
|
||||
path: vendor/aster
|
||||
persist-credentials: false
|
||||
- name: Verify Aster revision
|
||||
env:
|
||||
EXPECTED_ASTER_SHA: ${{ steps.aster_revision.outputs.sha }}
|
||||
run: test "$(git -C vendor/aster rev-parse HEAD)" = "$EXPECTED_ASTER_SHA"
|
||||
|
||||
- name: Verify portable GUI code with persistent Cargo caches
|
||||
env:
|
||||
CI_CACHE_SCOPE: ${{ gitea.event_name == 'pull_request' && format('pr-{0}', gitea.event.pull_request.number) || 'main' }}
|
||||
run: sh scripts/verify-ci.sh
|
||||
@@ -18,6 +18,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -92,6 +98,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -308,6 +320,15 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -434,12 +455,32 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.11.3"
|
||||
@@ -779,6 +820,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -809,6 +870,45 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
@@ -1104,6 +1204,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "objects"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
@@ -1201,6 +1306,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -1466,6 +1584,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -1655,10 +1779,12 @@ dependencies = [
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.2.2",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"softbuffer",
|
||||
"tiny_http",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
"winit",
|
||||
]
|
||||
@@ -2325,6 +2451,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -10,9 +10,11 @@ license = "MIT OR Apache-2.0"
|
||||
anyhow = "1.0"
|
||||
compiler = { path = "vendor/aster/crates/compiler" }
|
||||
cosmic-text = "0.19"
|
||||
png = "0.18"
|
||||
unicode-linebreak = "0.1.5"
|
||||
endor_core = { package = "core", path = "vendor/aster/crates/core" }
|
||||
frontend = { path = "vendor/aster/crates/frontend" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
softbuffer = "0.4.8"
|
||||
tiny_http = "0.12"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM rust:1.91.1-bookworm
|
||||
WORKDIR /build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libx11-dev libxkbcommon-dev libwayland-dev libudev-dev libegl1-mesa-dev libgbm-dev fonts-dejavu-core fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rustup component add rustfmt clippy
|
||||
ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0
|
||||
ENV CARGO_TARGET_DIR=/cargo-target
|
||||
CMD ["sh", "-eu", "-c", "cargo fmt --check && cargo test --locked && cargo clippy --all-targets --locked -- -D warnings"]
|
||||
@@ -22,6 +22,14 @@ experimental `namespace` / `export scene` / `render` parser has been removed.
|
||||
Host UI integration lives in Tessera; Aster needs no Tessera-specific changes.
|
||||
Lyra CLI and Hestia Server are independent hosts and are not application dependencies.
|
||||
|
||||
## Endor manuscript example
|
||||
|
||||
[illustrated-manuscript.endor](examples/illustrated-manuscript.endor) implements
|
||||
cursor-following dragon animation, small directional fire and text exclusion in
|
||||
ordinary Endor. It uses generic graphics resources, source paint, pointer events
|
||||
and Page-owned Tasks; no native demo mode is required. See [run instructions,
|
||||
script walkthrough and validation](docs/illustrated-manuscript.md).
|
||||
|
||||
## Native build and start
|
||||
|
||||
Use Rust 1.91 or newer. Initialize the source submodule following the dedicated
|
||||
@@ -37,8 +45,9 @@ Cargo compiles the required Aster libraries together with Tessera. No separate
|
||||
Aster build, service or deployment is required. Local builds download dependencies
|
||||
from official crates.io by default. A temporary dependency mirror may be used
|
||||
when the official source is unavailable, without changing global configuration.
|
||||
Docker and automated tests
|
||||
are not used locally. The command above produces a native executable. To create a
|
||||
Builds and validation run locally using the native Rust toolchain; no CI/CD
|
||||
workflow or Docker toolchain is configured. The command above produces a native
|
||||
executable. To create a
|
||||
Finder-launchable development app on macOS, run:
|
||||
|
||||
```bash
|
||||
@@ -127,7 +136,7 @@ is 14 logical pixels with a 20-pixel line height; size 2 is 28/40. Row/Column la
|
||||
uses measured text advances and multiline height. Device scale remains fractional,
|
||||
and glyphs are rasterized at the actual display resolution rather than enlarged.
|
||||
The application does not bundle proprietary OS fonts. Linux installations need
|
||||
fonts such as DejaVu Sans/Mono and Noto CJK; the remote test image installs these.
|
||||
fonts such as DejaVu Sans/Mono and Noto CJK installed locally.
|
||||
|
||||
## Candidate execution and publication
|
||||
|
||||
@@ -170,7 +179,7 @@ candidate and returns 500, retaining the previous document.
|
||||
|
||||
This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker.
|
||||
Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences.
|
||||
Managed tab chrome and multiple windows are available. Typed capability contracts
|
||||
Sidebar page navigation and multiple windows are available. Typed capability contracts
|
||||
are installed for host protocols; the complete language/type/effect surface and
|
||||
process isolation remain incomplete. Worker threads,
|
||||
source budgets and VM instruction limits are not a complete untrusted-code sandbox.
|
||||
@@ -234,13 +243,13 @@ The solver detects a previously visited nonconsecutive state as a cycle. It has
|
||||
|
||||
Mount.bounds() rejects reads during measurement, including old geometry on an existing Mount. The current occurrence's allocated Bounds become available while its arrange method runs. Structural contexts are prepared before layout, geometry is committed after successful layout, and onMount/onUnmount notifications then run before paint/publication. Both layout phases share one read-only VM call and instruction budget; temporary results can be modified, existing business state cannot. Read versions are captured before hooks, so a hook changing a measured dependency schedules another pass before publication. Dependencies are currently tracked per object. Source measurement results are cached by Mount identity, method, child sizes, constraints and actual object reads, including decoding an existing returned MeasureResult. Cache entries contain borrowed identities and versions, adding no GC roots. Mount-reading measurements currently bypass the cache conservatively because object-level tracking cannot distinguish layoutVersion/geometry from stable structure. Global dictionary writes or changes to another field on a read object can also cause conservative misses. Native measurements keep their shape cache, and arrangement still visits each active occurrence. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses; diagnostic statistics are rejected inside read-only rendering because allocation/count observations are not stable layout inputs.
|
||||
|
||||
`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Dragging a Tab away from its strip invokes that same Page method. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor).
|
||||
`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor).
|
||||
|
||||
`container.insert(position, block)` inserts at a zero-based position from 0 through the current child count and returns a new stable slot. Existing slots and attached Mount identities survive insertion. Invalid positions and cyclic child graphs fail before replacing the child list. `page.moveTab(position)` moves the same Page to a final zero-based position in its owning window; a code reorder does not implicitly activate it.
|
||||
|
||||
Native dragging starts after 12 logical pixels and shows a blue insertion marker. Same-window drops call the actual Page `moveTab` and `activate`; cross-window drops first call `moveToWindow`. Source overrides participate. An override that declines or redirects the window move prevents the remaining destination reorder. Page identity and Page-owned tasks survive movement, while a cross-window move replaces Mounts and cancels old Mount tasks. Invalid source/target generations, layout versions or unpublished tab orders are rejected before callbacks. Callback failure stops the domain; earlier writes do not roll back. Escape cancels a drag. Overflow drops use visible gaps; drag auto-scroll remains pending. See [tab-placement.endor](examples/tab-placement.endor).
|
||||
The version-checked tab-drop transport remains available for managed Page movement. Same-window requests call moveTab/activate and cross-window requests call moveToWindow first. Source overrides participate; generation/layout mismatches reject before callbacks. The sidebar currently supports selection/closure, with pointer dragging deferred. See [tab-placement.endor](examples/tab-placement.endor).
|
||||
|
||||
`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. A detached tab uses the release point and saved grab offset to place its new window. The window manager may adjust placement. Global coordinates are required for cross-window drops; unsupported native placement reports a diagnostic. This host tracks its own focus order when resolving overlapping windows; external-app occlusion and mixed-DPI dragging have not been fully verified.
|
||||
`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. The window manager can adjust placement; unsupported native placement reports a diagnostic. Sidebar cross-window pointer dragging and mixed-DPI gestures remain unverified.
|
||||
|
||||
`window.close()` and a secondary native close button call the same managed method. Closing first removes Page ownership, then commits unmounts and removes that context from the runtime registry; the native resource disappears after successful publication. Retained Pages can be opened in another live Window; otherwise Aster reclaims them normally. Window resource objects may remain referenced after closing, but reject resource operations; id/pages remain queryable. A retained leaf Mount uses a weak context link and cannot keep a closed window alive through its query methods. The primary shell still defines the application's lifetime: closing its OS window exits Tessera. Full application-exit notifications remain follow-up work.
|
||||
|
||||
@@ -248,9 +257,9 @@ A secondary window can also be closed after a callback/rendering failure or Cons
|
||||
|
||||
Each window has its own Presentation, layout/Mount/focus state, caches and native viewport. A single VM change batch fans out to all views, all affected frames settle before publication, and shared business references are never copied. Frames and native inputs carry window/session/layout identities; stale requests are rejected. Existing event clients default to window 1, exposed by X-Tessera-Window. Secondary native resize feeds the same worker and updates only its window constraints. Console reset preserves all old pictures with input disabled; replacement sessions discard the old native resources before projecting new resources with the same numeric IDs.
|
||||
|
||||
Open Pages appear in the native shell tab bar. Set `page.title` to label a tab; omitted titles use their open-order number. Clicking a tab or its close control calls the actual Page's `activate()` or `close()` method in the existing VM; source overrides participate. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, and Cmd/Ctrl+W closes the active page when the page has keyboard focus. Overflow arrows expose additional pages. Closed Pages follow managed references and GC; a retained Page can be reopened. The tab projection contains opaque session-local IDs and text, with weak reverse lookup in the runtime, and adds no Page roots. Requests carry the published layout version; an old tab event is rejected. Switching creates new presentation Mounts while preserving the business objects. See [tabs.endor](examples/tabs.endor).
|
||||
Open Pages appear in the left sidebar. Set `page.title` to label a page; clicking its row or close control calls the actual Page `activate()` or `close()` method in the same VM. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, Cmd/Ctrl+T creates one and Cmd/Ctrl+W closes the active page. Sidebar rows scroll when needed. Source overrides, managed references, GC and stale-event rejection remain in use. See [tabs.endor](examples/tabs.endor).
|
||||
|
||||
The macOS title strip occupies 44 logical pixels and shares its row with the native window controls. The left rail and title strip form one neutral chrome surface. Content is inset by a 56-pixel rail, an 8-pixel top gap, and 8 pixels at the right and bottom. Page constraints use the remaining height; published Mount geometry and event coordinates remain relative to the content area. Window viewport snapshots continue to report the full native inner size. The host translates drawing and native pointer coordinates at this boundary. Console reset preserves the last picture with input disabled until a new live frame is published.
|
||||
The host has one collapsible left sidebar, with page navigation and Console/Sources/Network/Settings. Cmd/Ctrl+B toggles it. Collapsed, the host draws only its expand button alongside the three real macOS window controls; content fills the native inner window with no top strip or surrounding inset. Only this small control area intercepts page input. `ui.viewport()` / `window.viewport()` and the API viewport headers report the available page area, including the width recovered when the sidebar collapses. Page drawings and hit tests share one camera transform. See [page surfaces](docs/page-surfaces.md).
|
||||
|
||||
`window.resize(width, height)`, `setTitle(text)` and `focus()` project requests to the GUI thread. `id()` returns an opaque runtime resource ID and `viewport()` reports actual logical dimensions. Requests are asynchronous and coalesced by kind; queried dimensions change when the native window reports them, and platform bounds can adjust requested sizes. This host accepts sizes from 1 to 4096 logical pixels per axis, with a native minimum of 720 by 480. Titles are UTF-8 text up to 4096 bytes with no control characters. See [window.endor](examples/window.endor).
|
||||
|
||||
@@ -258,7 +267,9 @@ Native creation and commands remain local until the input/candidate succeeds. Fa
|
||||
|
||||
The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame.
|
||||
|
||||
The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; TextBlock wraps to its available width; source layout now settles constraints over repeated measurement/arrangement passes as described below. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source.
|
||||
Row, Column and Overlay can nest with main/cross alignment, per-child alignment, margin, asymmetric border/padding, fixed/auto/fill sizes and min/max constraints. Page owns the root; scrolling is a finite ScrollView around ordinary layout. Scene2D owns world positions and CanvasViewport provides a finite UI box with an explicit Camera2D. Multiple views can share a scene while retaining independent cameras; assigning the same camera explicitly shares navigation. The old `Page.mode` API remains for compatibility. See [scene architecture](docs/scene-architecture.md), [box layout](examples/box-layout.endor) and [scene viewports](examples/scene-viewports.endor).
|
||||
|
||||
Scene3D, Group3D, mesh geometry, lights and programmable perspective/orthographic cameras render through finite Viewport3D boxes. OrbitController handles rotation, pan and zoom and can be replaced by an Endor controller. Ray picking and world-space Panel3D UI use the same transforms and depth visibility. A panel can contain ordinary UI, a 2D canvas or another 3D viewport. Rendering currently uses the existing CPU compositor with bounded depth/texture surfaces; this is not a GPU renderer. See [3D scenes](examples/scene3d.endor) and [world UI](examples/world-ui.endor).
|
||||
|
||||
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
|
||||
|
||||
@@ -274,25 +285,11 @@ A source subclass can override `paint(self, mount, geometry): DisplayList`. The
|
||||
|
||||
Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame.
|
||||
|
||||
The application starts with one managed New tab. The + button and Cmd/Ctrl+T
|
||||
create additional managed Pages; Cmd/Ctrl+W closes the active Page. New tabs expand
|
||||
from left to right while existing tabs interpolate to their new bounds when space
|
||||
becomes limited. Hover and selection use the same capsule geometry, and animated
|
||||
pointer hits use the rendered bounds. Source and page identity remain in the VM;
|
||||
chrome animation retains only projected scalar IDs and rectangles. Tabs are capped at
|
||||
208 logical pixels wide, with a 40-pixel strip and equal 4-pixel vertical insets.
|
||||
The leading page symbol and close control have matching slots and edge spacing.
|
||||
|
||||
The default page is implemented in `src/bridge/ui_bridge/start_page.endor`, compiled once
|
||||
per VM into a private namespace. Its buttons create a Page, open a working shared
|
||||
counter, or show composed Blocks. `ui.newTab()` constructs the same ordinary Page
|
||||
used by native + and the keyboard shortcut. Layout, callbacks, state and bindings
|
||||
are Endor objects; the host does not paint an introduction over the guest page.
|
||||
The application starts with an Endor Page containing a Scene2D and CanvasViewport. Its four actions demonstrate a window-sized layout, vertically scrolling Column, horizontally scrolling Row and independent canvas. The default page is implemented in [start_page.endor](src/bridge/ui_bridge/start_page.endor), compiled once per VM in a private namespace. `ui.newTab()` and the sidebar New page action use the same factory. Cards, copy, layout, actions and the shared-state example are Endor objects. The host owns the sidebar and native window controls.
|
||||
|
||||
Console, Sources, Network and Settings are independent managed Pages with separate
|
||||
view modules and window-local input/focus/scroll/selection state. They are not
|
||||
opened by default and have no permanent rail/menu launchers. Their future
|
||||
discovery UI is intentionally deferred. Existing integrations can call
|
||||
opened by default; the left sidebar provides their launchers. Existing integrations can call
|
||||
`ui.developerTools("Console")`, `"Sources"`, `"Network"` or `"Settings"`.
|
||||
F12, Cmd-Option-I and Cmd-Shift-I on macOS, or Ctrl-Shift-I elsewhere,
|
||||
open/focus Console. Page closure, placement and GC use the ordinary Page methods.
|
||||
@@ -410,17 +407,24 @@ UTF-8 even when launched from Finder, and decoding errors are surfaced.
|
||||
|
||||
## Validation
|
||||
|
||||
Local native builds and window runs are allowed; do not run local automated tests
|
||||
or Docker. [Remote CI](.gitea/README.md) on ces-control runs the existing renderer
|
||||
checks and the new Aster script/module execution, page rendering, UI layout, source-range, lifetime, budget,
|
||||
failed-update and deletion-invalidation regressions, plus Clippy.
|
||||
The API regressions use actual HTTP requests, including DELETE while the worker
|
||||
is occupied and a queued PUT is superseded.
|
||||
Linux CI does not verify macOS window presentation. Aster has no standalone CI/CD; Lyra CI
|
||||
explicitly validates the pinned engine itself. CI uses official Cargo sources
|
||||
and standard HTTPS checkout without custom SSH secrets. Font checks cover
|
||||
proportional/monospace advances, fractional scaling, multiline height, Chinese
|
||||
fallback, grapheme-safe truncation, antialiasing and alpha blending.
|
||||
Run checks from the repository root after initializing the pinned Aster submodule:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --all-targets --locked -- -D warnings
|
||||
sh -n scripts/build-macos-app.sh
|
||||
```
|
||||
|
||||
The existing tests cover rendering, Aster execution, page layout, source ranges,
|
||||
lifetime, budgets, failed-update preservation and deletion invalidation. API tests
|
||||
use loopback HTTP, so allow local socket access. Font checks use installed system
|
||||
fonts. Automated checks do not replace native window verification: launch Tessera
|
||||
and check the start page, shared-object example, tab creation/closure and developer
|
||||
tools. Build the macOS bundle separately with `sh scripts/build-macos-app.sh`.
|
||||
|
||||
There is no repository CI/CD workflow. Commits and pull requests do not trigger
|
||||
builds or deployment; record the actual local check results with each delivery.
|
||||
|
||||
|
||||
### Source function types
|
||||
@@ -599,7 +603,7 @@ Inside a function run by ui.spawn, `value = await task` waits for any timer/file
|
||||
A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model.
|
||||
|
||||
|
||||
While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below.
|
||||
The former horizontal tab-strip gestures and animation have been replaced by sidebar page navigation. Page.moveTab, moveToWindow and detachToWindow remain available through Endor/API; sidebar pointer drag/reorder and cross-window drag are not implemented in this revision.
|
||||
|
||||
|
||||
Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work.
|
||||
|
||||
@@ -7,7 +7,7 @@ Tessera/
|
||||
├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果
|
||||
├── src/
|
||||
│ ├── main.rs 组合入口、事件循环和共享发布状态
|
||||
│ ├── shell/ 原生窗口、标签、开发工具、管理控件
|
||||
│ ├── shell/ 原生窗口、可收起侧栏、开发工具、管理控件
|
||||
│ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务
|
||||
│ ├── model/ 托管对象、挂载、绑定、依赖和发布数据
|
||||
│ ├── render/ 布局、绘制、命中、字体与软件光栅化
|
||||
@@ -16,7 +16,7 @@ Tessera/
|
||||
├── docs/
|
||||
│ ├── architecture/ 本套架构说明和 UML 源文件
|
||||
│ └── media/ 已有建模视频与字幕
|
||||
├── scripts/ 原生打包及已有远程检查入口
|
||||
├── scripts/ 本地原生打包及 UML 文档渲染
|
||||
└── vendor/aster/ 独立 Git 子模块:语言、编译器、VM
|
||||
```
|
||||
|
||||
@@ -36,7 +36,7 @@ Tessera/
|
||||
|
||||
| 执行位置 | 核心所有物 | 交互方式 |
|
||||
| --- | --- | --- |
|
||||
| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、标签动画、工具交互 | 读已发布快照;发 viewport、输入和窗口命令 |
|
||||
| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、侧栏、页面视角交互 | 读已发布快照;发 viewport、输入和窗口命令 |
|
||||
| `tessera-agent-api` | tiny_http 接收循环、请求观察 | 对写入做校验并进入最多 8 条的构建队列;读取当前发布状态 |
|
||||
| `tessera-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 |
|
||||
| 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM |
|
||||
|
||||
@@ -26,7 +26,17 @@
|
||||
4. [建模视频](../media/endor-campus-modeling.mp4) 与 [中文字幕](../media/endor-campus-modeling.zh.srt):已有实际窗口操作记录,静音、采样帧率有说明。
|
||||
5. `GET /agent`:运行中的实时 API 指南,优先于长期静态样例推断接口。
|
||||
|
||||
## 本轮验证方式
|
||||
## 当前本地开发方式(2026-11-10)
|
||||
|
||||
页面/宿主设计更新:支持 window、vertical、horizontal、canvas 四种空间;顶部标签条由可收起左侧栏替代。实现、测试与原生验收边界见 [页面空间说明](../page-surfaces.md)。下文 150 项测试及原窗口结构属于前一次移除 CI 的交付记录。
|
||||
|
||||
项目已移除 CI/CD 工作流、Docker 工具链定义及远程验证脚本。开发、构建、现有测试与原生窗口验收改在本机执行,命令见 [项目验证说明](../../README.md#validation)。以下保留 2026-10-25 整理时的验收记录,不代表本次检查结果。
|
||||
|
||||
本次使用 macOS / Rust 1.91.1:格式检查、150 项现有测试、all-target Clippy(拒绝 warnings)、打包脚本语法检查及 release app 构建均通过。应用已启动,`GET /agent` 与 `GET /code` 返回正常,窗口报告 1280 × 800 的逻辑 viewport;通过实际 REPL 创建/关闭临时标签页,页面数为 1 → 2 → 1。首次页面查询误用了不存在的 `ui.pages()`,返回 422;改为 `ui.window().pages().count` 后通过。原生 UI 检查工具启动失败,本次没有窗口外观、真实鼠标交互或跨屏验收证据。Aster 保持固定版本,未修改源码或 gitlink。
|
||||
|
||||
下一步优先补齐真实窗口的共享对象、标签拖放和退出清理验收,然后定义本地 `.endor` 文件的打开、保存及重启恢复方式。当前 Sources 的云文件 provider 默认断开,源码/设置尚不持久化。此外,在线 API 指南仍有旧的标签高度、overflow 箭头和增量布局状态说明,需要与现有实现及测试统一。
|
||||
|
||||
## 2026-10-25 整理验证方式
|
||||
|
||||
本轮是整理,不扩展业务功能。沿用工作约定:格式、`cargo clippy --all-targets --offline -- -D warnings`、原生 release 打包、真实窗口和少量人工 API 操作;不执行 cargo test、自动 smoke 脚本或 Docker。Clippy 会编译检查已有测试代码,但不代表测试已经执行。
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Tessera 中文源码注释与阅读索引
|
||||
|
||||
本轮只为 Tessera 主项目补充注释,Aster 子模块固定版本与内容均沿用主线。注释按模块职责、类型/函数入口、关键字段、执行阶段和失败/取消路径组织;没有改变接口、算法、常量或资源预算。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
1. [main](../src/main.rs):创建原生循环、共享文档与执行 worker。
|
||||
2. [原生应用](../src/shell/app.rs):理解窗口生命周期、可用视口、输入边界和最终 present。
|
||||
3. [API worker](../src/runtime/api/worker.rs) 与 [运行时](../src/runtime/endor_host.rs):理解候选/活跃 VM、输入串行化、分片刷新和发布。
|
||||
4. [桥接层](../src/bridge/ui_bridge.rs)、[Block](../src/model/block.rs) 与 [Mount](../src/model/mount.rs):区分共享业务对象、每次出现和展示事务。
|
||||
5. [快照](../src/model/snapshot.rs)、[源布局](../src/render/source_layout.rs) 和 [源绘制](../src/render/source_paint.rs):理解依赖缓存、只读执行及挂起工作。
|
||||
6. [二维视口](../src/render/viewport.rs)、[3D](../src/render/scene3d.rs) 和 [面板映射](../src/render/scene3d/panel.rs):理解布局/世界/视图的边界。
|
||||
7. [任务](../src/runtime/tasks.rs)、[provider](../src/runtime/io_tasks.rs) 和 [事件传播](../src/runtime/event_dispatch.rs):理解异步交付资格与生命周期。
|
||||
|
||||
## 阅读时需要保留的边界
|
||||
|
||||
- Block/Data 属于共享业务组成;Mount 属于一个展示位置,不能把一次出现的尺寸写回共享对象来代替排版。
|
||||
- ObjectRef/WeakObjectRef 的含义由真实托管拥有关系决定;派生缓存中的身份索引不能成为隐藏根。
|
||||
- 窗口线程消费完整发布值;VM、源回调和托管堆操作只在执行 worker 内推进。
|
||||
- submission 防止旧请求覆盖新输入;session 防止跨 VM 资源误用;layout_version 防止旧几何上的离散操作误触。
|
||||
- Pending Work 同时拥有续体与上下文;取消必须关闭原 VM 的只读执行并恢复未提交 Mount,不能只丢弃 Rust 值。
|
||||
- 场景世界不是无限尺寸的排版盒子;有限视口参加布局,摄像机负责投影,命中沿相同链逆变换。
|
||||
- 现有 CPU 绘制、缓存范围与输入队列限制仍按当前代码执行;说明性注释不代表性能优化已实现。
|
||||
|
||||
## Rust 逐文件说明
|
||||
|
||||
每行说明该文件本次新增注释解释的职责和目的。生产函数/方法均增加中文入口说明;复杂状态机另外补充阶段和不变量说明。集成测试文件增加整体阅读与检查点说明,测试程序文本和源码范围断言保持原样。
|
||||
|
||||
| 文件 | 注释解释的职责与目的 |
|
||||
| --- | --- |
|
||||
| [src/bridge/mod.rs](../src/bridge/mod.rs) | 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。 |
|
||||
| [src/bridge/ui_bridge.rs](../src/bridge/ui_bridge.rs) | Endor 与 Tessera 的展示事务协调层:维护每个窗口的 Page、Mount、布局缓存和发布版本。 |
|
||||
| [src/bridge/ui_bridge/bootstrap.rs](../src/bridge/ui_bridge/bootstrap.rs) | 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。 |
|
||||
| [src/bridge/ui_bridge/calls.rs](../src/bridge/ui_bridge/calls.rs) | 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。 |
|
||||
| [src/bridge/ui_bridge/developer_pages.rs](../src/bridge/ui_bridge/developer_pages.rs) | 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。 |
|
||||
| [src/bridge/ui_bridge/page_input.rs](../src/bridge/ui_bridge/page_input.rs) | 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。 |
|
||||
| [src/bridge/ui_bridge/signatures.rs](../src/bridge/ui_bridge/signatures.rs) | 声明宿主方法在 Endor 类型系统中的参数和返回契约。 |
|
||||
| [src/bridge/ui_bridge/tree.rs](../src/bridge/ui_bridge/tree.rs) | 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。 |
|
||||
| [src/main.rs](../src/main.rs) | 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。 |
|
||||
| [src/model/binding.rs](../src/model/binding.rs) | 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。 |
|
||||
| [src/model/block.rs](../src/model/block.rs) | Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。 |
|
||||
| [src/model/document.rs](../src/model/document.rs) | 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。 |
|
||||
| [src/model/mod.rs](../src/model/mod.rs) | 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。 |
|
||||
| [src/model/mount.rs](../src/model/mount.rs) | 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。 |
|
||||
| [src/model/object_list.rs](../src/model/object_list.rs) | 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。 |
|
||||
| [src/model/presentation.rs](../src/model/presentation.rs) | 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。 |
|
||||
| [src/model/protocols.rs](../src/model/protocols.rs) | 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。 |
|
||||
| [src/model/scene3d.rs](../src/model/scene3d.rs) | 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。 |
|
||||
| [src/model/snapshot.rs](../src/model/snapshot.rs) | 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。 |
|
||||
| [src/model/window.rs](../src/model/window.rs) | 托管 Window 的页面所有权、原生命令与关闭生命周期。 |
|
||||
| [src/render/box_model.rs](../src/render/box_model.rs) | 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。 |
|
||||
| [src/render/display_list.rs](../src/render/display_list.rs) | 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。 |
|
||||
| [src/render/geometry.rs](../src/render/geometry.rs) | 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。 |
|
||||
| [src/render/layout.rs](../src/render/layout.rs) | 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。 |
|
||||
| [src/render/mod.rs](../src/render/mod.rs) | 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。 |
|
||||
| [src/render/native_visual.rs](../src/render/native_visual.rs) | 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。 |
|
||||
| [src/render/page.rs](../src/render/page.rs) | 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。 |
|
||||
| [src/render/scene.rs](../src/render/scene.rs) | 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。 |
|
||||
| [src/render/scene3d.rs](../src/render/scene3d.rs) | 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。 |
|
||||
| [src/render/scene3d/math.rs](../src/render/scene3d/math.rs) | 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。 |
|
||||
| [src/render/scene3d/panel.rs](../src/render/scene3d/panel.rs) | 连接 3D 平面与普通 UI 的坐标和绘制桥梁。 |
|
||||
| [src/render/scene3d/spatial.rs](../src/render/scene3d/spatial.rs) | 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。 |
|
||||
| [src/render/scroll.rs](../src/render/scroll.rs) | 逐 Mount 的滚动状态、裁剪和输入目标选择。 |
|
||||
| [src/render/software_renderer.rs](../src/render/software_renderer.rs) | 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。 |
|
||||
| [src/render/source_hit.rs](../src/render/source_hit.rs) | 通过真正的源/原生 hitTest 协议确定指针目标。 |
|
||||
| [src/render/source_layout.rs](../src/render/source_layout.rs) | 源 measure/arrange 协议的分片布局驱动器。自底向上的测量与自顶向下的安排共享一个只读 VM 调用域。 |
|
||||
| [src/render/source_paint.rs](../src/render/source_paint.rs) | 执行源 paint 覆盖并解码受检查的 PaintCommand。 |
|
||||
| [src/render/text.rs](../src/render/text.rs) | 统一字体发现、文字整形、测量与抗锯齿字形绘制。 |
|
||||
| [src/render/viewport.rs](../src/render/viewport.rs) | 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。 |
|
||||
| [src/render/viewport/spatial.rs](../src/render/viewport/spatial.rs) | 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。 |
|
||||
| [src/runtime/api.rs](../src/runtime/api.rs) | 本机 HTTP API 入口:路由请求、限制输入大小、观察活动,并将执行工作送入串行 worker。 |
|
||||
| [src/runtime/api/guide.rs](../src/runtime/api/guide.rs) | 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 |
|
||||
| [src/runtime/api/tests.rs](../src/runtime/api/tests.rs) | API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 |
|
||||
| [src/runtime/api/worker.rs](../src/runtime/api/worker.rs) | 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。 |
|
||||
| [src/runtime/endor_host.rs](../src/runtime/endor_host.rs) | 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。 |
|
||||
| [src/runtime/endor_host/tests.rs](../src/runtime/endor_host/tests.rs) | 运行时集成回归与示例验收。测试经真实 Aster 前端、编译器、VM 和展示协议执行,不是手工伪造布局结果。 |
|
||||
| [src/runtime/endor_script.rs](../src/runtime/endor_script.rs) | 区分完整 Module 与顶层 Script,并保留输入源码位置。 |
|
||||
| [src/runtime/event_dispatch.rs](../src/runtime/event_dispatch.rs) | 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 |
|
||||
| [src/runtime/events.rs](../src/runtime/events.rs) | 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 |
|
||||
| [src/runtime/io_tasks.rs](../src/runtime/io_tasks.rs) | 后台文件/网络 provider。线程池只携带纯数据、取消控制与弱 Task 身份,不持有 VM。 |
|
||||
| [src/runtime/mod.rs](../src/runtime/mod.rs) | 运行时模块入口。VM 生命周期、API、源程序组织、事件与异步任务在这里组合。 |
|
||||
| [src/runtime/tasks.rs](../src/runtime/tasks.rs) | owner/key 管理的异步任务与协作式源函数调度。弱索引用于查找,回调、等待对象和协程帧是托管图中的真实边。 |
|
||||
| [src/runtime/vm_sequence.rs](../src/runtime/vm_sequence.rs) | 构造共享 VM 调用序列与同帧循环驱动的字节码。 |
|
||||
| [src/shell/activity.rs](../src/shell/activity.rs) | 有界 HTTP 活动记录。保存请求/响应的可显示预览供 Network 工具读取,不负责执行请求。 |
|
||||
| [src/shell/app.rs](../src/shell/app.rs) | winit 原生应用生命周期和窗口事件入口。这里创建窗口、发布逻辑视口、转发输入,并把不可变文档画到原生表面。 |
|
||||
| [src/shell/clipboard.rs](../src/shell/clipboard.rs) | 把 UTF-8 文本读写适配到当前平台的系统剪贴板命令。 |
|
||||
| [src/shell/developer_tools.rs](../src/shell/developer_tools.rs) | Console/Sources/Network/Settings 的宿主交互协调与绘制状态。 |
|
||||
| [src/shell/developer_tools/console.rs](../src/shell/developer_tools/console.rs) | 绘制 Console 输入、结果历史和对应命中区域。 |
|
||||
| [src/shell/developer_tools/network.rs](../src/shell/developer_tools/network.rs) | 绘制已观察到的 HTTP 活动、请求列表与详情。 |
|
||||
| [src/shell/developer_tools/painter.rs](../src/shell/developer_tools/painter.rs) | 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。 |
|
||||
| [src/shell/developer_tools/selection.rs](../src/shell/developer_tools/selection.rs) | 多行文本选择和光标导航。选择端点映射到合法字符边界,避免把 UTF-8 字节中间当成一个字符。 |
|
||||
| [src/shell/developer_tools/settings.rs](../src/shell/developer_tools/settings.rs) | 绘制宿主设置页面和主题选择区域。 |
|
||||
| [src/shell/developer_tools/sources.rs](../src/shell/developer_tools/sources.rs) | 绘制 AST、当前源码快照和云文件视图。 |
|
||||
| [src/shell/mod.rs](../src/shell/mod.rs) | 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。 |
|
||||
| [src/shell/native_chrome.rs](../src/shell/native_chrome.rs) | macOS 原生标题栏适配。定位系统窗口按钮并沿用用户的标题栏双击设置。 |
|
||||
| [src/shell/sidebar.rs](../src/shell/sidebar.rs) | 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。 |
|
||||
| [src/shell/sources.rs](../src/shell/sources.rs) | 云文件浏览的 provider 抽象与工作区状态。默认 provider 未连接,不假装已有外部文件。 |
|
||||
| [src/shell/system_ui/interaction.rs](../src/shell/system_ui/interaction.rs) | 即时绘制控件的跨帧交互状态:稳定 WidgetId、悬停、按下捕获、键盘焦点和释放激活。 |
|
||||
| [src/shell/system_ui/mod.rs](../src/shell/system_ui/mod.rs) | 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。 |
|
||||
| [src/shell/system_ui/theme.rs](../src/shell/system_ui/theme.rs) | 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。 |
|
||||
| [src/shell/system_ui/widgets.rs](../src/shell/system_ui/widgets.rs) | 用统一主题绘制宿主管理控件,并同步注册交互目标。 |
|
||||
| [src/shell/tabs.rs](../src/shell/tabs.rs) | 发布页面标签的数据结构。保留稳定页面身份、标题、选中状态和开发工具类别;页面的托管所有权由模型层管理。 |
|
||||
|
||||
## 其他文件
|
||||
|
||||
| 文件 | 本次改动及目的 |
|
||||
| --- | --- |
|
||||
| [src/bridge/ui_bridge/start_page.endor](../src/bridge/ui_bridge/start_page.endor) | 解释卡片源测量/背景、页面工厂、纵横布局与滚动组合、共享计数和场景位置,便于从真实 Endor 页面理解宿主边界。 |
|
||||
| [scripts/build-macos-app.sh](../scripts/build-macos-app.sh) | 说明本地构建前提、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 |
|
||||
| [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 |
|
||||
| [src/README.md](../src/README.md) | 增加本注释索引入口,便于从源码目录开始阅读。 |
|
||||
| [docs/code-comments.md](code-comments.md) | 本逐文件说明与阅读顺序,覆盖本轮提交的文件及共同边界。 |
|
||||
|
||||
## 核对方式
|
||||
|
||||
检查差异仅新增独立注释行和注释相关空白,并与主线去除注释后的文本对比;同时运行格式、完整 GUI 回归、Clippy 和包含私有项的 rustdoc。脚本使用语法检查,不执行文档渲染或应用打包。具体检查结果在 PR 中记录。
|
||||
@@ -0,0 +1,111 @@
|
||||
# Endor 脚本:龙与文字实时绕排
|
||||
|
||||
完整程序:[examples/illustrated-manuscript.endor](../examples/illustrated-manuscript.endor)。它通过普通 Page、源 measure/paint、托管事件及 owner-scoped Task 工作。Tessera 没有 Dragon / Flame / Manuscript 专用 Rust 类型、专用启动参数或动画循环;Rust 只补齐可被其他脚本复用的图形与文字能力。
|
||||
|
||||
## 运行
|
||||
|
||||
在仓库根目录启动普通应用:
|
||||
|
||||
```sh
|
||||
cargo run --release --locked
|
||||
```
|
||||
|
||||
另开终端,在同一个仓库根目录提交原始 Endor 文件:
|
||||
|
||||
```sh
|
||||
curl --request PUT --header 'Content-Type: text/plain; charset=utf-8' \
|
||||
--data-binary @examples/illustrated-manuscript.endor \
|
||||
http://127.0.0.1:4737/v1/namespaces/app.main/code
|
||||
```
|
||||
|
||||
这是普通源码提交,没有预编译场景或原生替身。资源路径目前相对于应用工作目录;通过以上根目录启动方式使用 `examples/assets/manuscript/`。从其他目录启动时,修改脚本的 `assets` 路径为绝对路径。图片、字体、正文都在示例目录,不编入宿主。素材与字体许可见 [NOTICE](../examples/assets/manuscript/NOTICE.md)。
|
||||
|
||||
移动鼠标让龙跟随,按住左键喷火,松开停止。离开窗口、取消指针或失焦也会停止喷火。空闲两秒回到卷曲姿势。原生窗口缩放触发普通布局和 paint;窄窗口切换到脚本预备的小字号。关闭 Page 会取消其定时任务,不留下应用级动画循环。
|
||||
|
||||
## 脚本对象分工
|
||||
|
||||
| 对象 | 状态与责任 |
|
||||
| --- | --- |
|
||||
| `Segment` | 一节身体的位置、角度及共享图片引用 |
|
||||
| `FireArt` | 一张火焰图及其原始轴角;这些校准值是示例素材数据 |
|
||||
| `Flame` | 一个粒子的位置、速度、尺寸、年龄及 FireArt 引用 |
|
||||
| `Dragon` | 身体链、火焰集合、目标点、输入状态、时钟样本和随机种子;实现追随、恢复、生成/更新/删除粒子及绘图描述 |
|
||||
| `Manuscript : RectBlock` | 普通视觉子类;组合预处理文字、首字母图片、龙和 Page,覆盖 measure/paint,通过 onMount/onUnmount 管理 Task |
|
||||
| `Page` / `Task` / `Event` / `Mount` | 沿用 Tessera 已有托管体系:Page 是任务 owner,Task 调用脚本 tick,Event 带局部指针位置,Mount 提供当前出现的几何 |
|
||||
|
||||
这些是同一 VM 中的普通对象与引用。身体并不是多个重量远程 actor;图片被多个出现/粒子共享,临时 PaintCommand 只描述本帧绘制。
|
||||
|
||||
## 从输入到一帧
|
||||
|
||||
1. `view.on("pointermove", dragon.pointer)` 显式订阅连续输入。宿主把原生指针转换成 Event,再交给普通传播链;脚本读取 `event.mount.bounds()`,将目标点换算到自己的 700 单位画布。按下/松开只改变脚本 `pressed`。
|
||||
2. `onMount` 用 `ui.after(page,"animation",80,self.tick)` 创建 Page 所有的任务。`tick` 调用脚本 `Dragon.step()`,再安排下一个任务。80 ms 是脚本参数;宿主不知道这是龙的动画。
|
||||
3. `step` 先采样通用单调时钟。头向目标移动,其余 19 节依次跟随上一节,保持间距 30,并把相邻转角差限制在 ±0.25 弧度。空闲时由脚本计算卷曲目标并插值回去。
|
||||
4. 粒子在脚本中更新位置、速度与年龄,倒序删除超过 12 步的粒子。每步最多产生 1 个、总数最多 12 个,最长边仅 12–20 个画布单位。随机变化由脚本自己的确定性种子和数学函数产生。
|
||||
5. 普通属性变更使源 paint 的已观察依赖失效。`paint` 从当前几何生成图像绘制命令,再由脚本选择哪些图片形成避让区,交给通用文字流布局。
|
||||
6. 正常文字使用缓存整词形状;喷火时脚本请求字素粒度,并在 `heat` 中按粒子距离修改临时文字命令的位置、角度、透明度和颜色。没有把热力规则藏进宿主。
|
||||
7. 返回的 DisplayList 经过现有只读 paint 校验、Frame 发布和 CPU 栅格化,在普通窗口中呈现。VM 对象不进入窗口线程。
|
||||
|
||||
paint 内创建/修改的是临时返回对象,不能写已有龙状态或读取文件/时钟。资源加载、时钟采样和状态更新发生在初始化、事件或任务中。
|
||||
|
||||
## 绕排与预处理
|
||||
|
||||
脚本初始化时用 `prepareText` 准备两套正文和提示文字。字体/字号/行高/颜色/设备分辨率属于准备参数;每帧只传新几何与避让区,复用字形形状和透明图像。
|
||||
|
||||
`Dragon.artwork` 返回普通图像命令。脚本循环调用通用 `imageExclusion(command,padding)`,由图片 alpha 轮廓产生保守凸包,并给首字母添加矩形避让。`flowText` 对整个行带求交、合并遮挡区间,再依次填充剩余的左右片段;同一个文字 cursor 跨片段继续,不复制或跳过词。单词装不下所有可用片段时才退回字素边界。
|
||||
|
||||
当前提供面向本示例的从左到右文字流,不声称完整支持所有文字系统的双向段落。字素绘制会改变部分字偶距;这是喷火效果的取舍。正文超出当前页的部分留在准备数据中,当前示例保持单页,未增加滚动文档业务。
|
||||
|
||||
## 火焰角度与尺寸
|
||||
|
||||
火焰 PNG 自身方向各不相同;把它们一律当作朝右图片会错位。脚本在 `FireArt.axis` 中记录每张图片原始轴角,绘制时使用:
|
||||
|
||||
```endor
|
||||
image = p.art.image
|
||||
imageScale = p.size * scale / math.max(image.width, image.height)
|
||||
angle = math.atan2(p.vy, p.vx) - p.art.axis
|
||||
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
|
||||
```
|
||||
|
||||
角度以弧度表示,在屏幕坐标中正方向为顺时针;原始素材校准值与 PNG 一起属于应用数据。尺寸按最长边归一化,窄高的 PNG 不会因宽度较小而放大。舌头同样通过局部 anchor 定位,使其偏移随头部一起旋转。
|
||||
|
||||
## 可复用宿主接口
|
||||
|
||||
入口是 `g = ui.graphics`,数学入口是 `g.math`。宿主实现位于 `src/bridge/graphics.rs`、`src/render/image.rs` 和 `src/render/text_flow.rs`。
|
||||
|
||||
| 方法 | 行为 |
|
||||
| --- | --- |
|
||||
| `loadImage(path)` | 读取有界普通 PNG 文件,返回共享不可变资源及 width/height |
|
||||
| `loadFont(path,family)` | 注册开放字体,验证指定 family;相同资源复用,冲突不替换既有字体 |
|
||||
| `readText(path)` / `textSlice(text,start,end)` / `string(value)` | 初始化用有界 UTF-8 资源读取、按字符切片及标量文本转换 |
|
||||
| `prepareText(text,font,size,lineHeight,color,rasterScale)` | 生成与障碍无关的缓存文字资源 |
|
||||
| `sprite(image,x,y,width,height,angle,anchorX,anchorY,opacity)` | 返回普通 PaintCommand;旋转围绕原点,anchor 决定图片局部偏移 |
|
||||
| `imageExclusion(command,padding)` | 图像实际 alpha 轮廓的保守凸包,应用该命令的平移、缩放与旋转 |
|
||||
| `circleExclusion(x,y,r)` / `rectExclusion(x,y,w,h)` | 通用几何避让区 |
|
||||
| `flowText(prepared,x,y,w,h,exclusions,letterMode)` | 生成可检查/修改的普通文字图像命令列表 |
|
||||
| `math.float/sin/cos/sqrt/atan2/abs/min/max/clamp/floor/now` | 通用实数与图形数学;float 是显式二进制浮点入口,now 仅在事件/任务中采样 |
|
||||
| `ObjectList.append/remove/extend/added` | 通用托管集合编辑/批量追加/持久添加;paint 只能编辑本次创建的临时集合 |
|
||||
|
||||
PNG 单张最多 4M 像素,文件最多 4 MiB;准备文字最多 16 KiB、缓存最多 8M 像素;最多 512 个避让区、1024 行和 10000 条绘图命令。资源读取不允许在 paint/layout 中执行。图形边界显式窄化数字,源语言的精确数值表示不会被改写。
|
||||
|
||||
原生连续指针输入需显式订阅。生产端在有界队列中只合并相同窗口/session/目标的相邻绝对移动采样,按下/松开/取消及目标变化都是顺序边界。后台 relay 直接把内部 Input envelope 送进同一 VM worker 队列并等待完成,窗口线程不等待;不绕行 HTTP。worker 等既有刷新结束后才重验 Mount/session 并更新连续事件版本,避免动画发布新帧时丢掉移动或松开。外部 HTTP 无法创建内部 envelope,继续严格校验帧版本。
|
||||
|
||||
## Aster 语言扩展
|
||||
|
||||
为使上述逻辑留在源码中,独立 Aster 分支实现了真实四则运算、非字面量负号、六种比较、if/else 与 while,并同步 ASDL、AST 生成产物、名称分析、bytecode 验证与 VM。整数/十进制/有理数保持精确;有 BinaryFloat 操作数才选择浮点算术,混合数值比较避免大整数丢精度。循环沿用同一累计指令限制与协作暂停机制。不是在 Tessera 中解释另一个小语言。
|
||||
|
||||
## 验证与范围
|
||||
|
||||
```sh
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
TESSERA_MANUSCRIPT_SNAPSHOTS=/absolute/output/path \
|
||||
cargo test --release --locked manuscript_script_snapshots -- --nocapture
|
||||
```
|
||||
|
||||
快照测试加载实际 Endor 文件,经 VM、事件、源 paint 和软件渲染器输出 endor-rest/crossing/fire/narrow 等 PNG。覆盖持续喷火达到上限、角度校准、最长边尺寸、字形呈现、宽窄窗口,以及任务确实调用脚本和关闭 Page 后停止。另一项集成测试验证指针驱动头部移动、取消与粒子回收。不是用 Rust 动画或手工绘图替代源码执行。
|
||||
|
||||
当前采用既有 CPU 后端;无 GPU 实现,也未达到稳定 60 FPS。参考本机 release 样本,16 次持续喷火的 VM 更新与完整 Frame 刷新约 1.4–1.5 秒(不含 OS 呈现),不能把 80 ms 定时参数当作实际帧率。性能结果属于脚本通路而非之前已移除的原生原型。
|
||||
|
||||
|
||||
本次还补上了指针类型的 HTTP 白名单。最初只测 VM 分发遗漏了真实 worker 入口,随后通过真实 HTTP 回归复现并修复 `unsupported event kind`;动画期间的过期事件竞争则通过内部原生 envelope 解决。测试分别覆盖 HTTP 的坐标/类型/严格版本、原生 envelope 换帧后仍投递且旧会话不穿透,以及生产端合并不跨越按下/松开。最终运行窗口已由用户确认“持续正常,松开也正常”。系统自动事件投递/截图权限未开启,因此最终展示图来自实际脚本的 VM→Frame→软件渲染快照,原生交互验证来自用户操作。
|
||||
@@ -0,0 +1,59 @@
|
||||
# Endorphin 页面空间与窗口边界
|
||||
|
||||
> 下文的 Page.mode 是前一阶段的兼容接口。当前默认页已迁移到普通 Page、Row/Column、滚动容器和独立 Scene2D / CanvasViewport / Camera2D。新代码请使用 [统一场景架构](scene-architecture.md),不要再把横纵排版或相机作为 Page 的固有模式。
|
||||
|
||||
本次按参考图的“左侧栏展开/收起”两种状态调整原生宿主。窗口保留系统关闭、最小化和缩放按钮;宿主管理入口集中在左侧栏。收起后只保留展开按钮及系统三键,页面从窗口左上角铺满,不再保留顶部标签条、左侧空轨或内容卡片边距。
|
||||
|
||||
页面内容、卡片、文字和操作按钮由 Endor 源码渲染。默认 [开始页](../src/bridge/ui_bridge/start_page.endor) 是真实的画布 Page,其操作创建另外三种 Page;不会把示例的静态截图当作可操作界面。
|
||||
|
||||
## 四种页面模式
|
||||
|
||||
| `page.mode` | 布局 | 浏览方式 |
|
||||
| --- | --- | --- |
|
||||
| `window`(默认) | 根 Page 等于可用区域,子组件按纵向流组织 | 应用自行安排内部布局或 ScrollBlock |
|
||||
| `vertical` | 宽度跟随可用区域,高度至少一屏并随子组件增长 | 纵向滚动,偏移限制到内容范围 |
|
||||
| `horizontal` | 高度跟随可用区域,宽度至少一屏并随子组件增长 | 横向滚动;普通纵向滚轮也可推进横向页面 |
|
||||
| `canvas` | 直接子组件使用各自的 `x`、`y` 世界坐标 | 双向平移、缩放,视角不限制到内容边界 |
|
||||
|
||||
侧栏展开占用 256 个逻辑像素;收起归还全部宽度。`ui.viewport()`、`window.viewport()` 和 GET code 的 viewport headers 均报告实际可用页面区域。窗口变更重排同一个运行时,不重放源码。窗口模式保证根 Page 填满,但不会强迫每个子组件拉伸。
|
||||
|
||||
```endor
|
||||
page = Page()
|
||||
page.mode = "canvas"
|
||||
page.title = "我的画布"
|
||||
card = Button("一个真正的 Endor 组件")
|
||||
card.x = -120
|
||||
card.y = 90
|
||||
page.render(card)
|
||||
page.panX = -200
|
||||
page.panY = 0
|
||||
page.zoom = 1
|
||||
```
|
||||
|
||||
`page.render(root)` 替换内容;`page.append(child)` 继续添加。直接画布子组件的 `x/y` 决定位置,内部 Row/Column 仍使用原来的流式布局;纵向、横向和窗口页面不使用子组件的 `x/y` 排列。共享 Block 可以有多个 Mount,不会为画布复制业务对象。
|
||||
|
||||
## 交互与坐标
|
||||
|
||||
- 滚轮或触控板平移;嵌套 ScrollBlock 优先处理其可滚动方向。
|
||||
- 画布支持空格+左键拖动、鼠标中键拖动;已有按钮键盘焦点保留 Space 激活。
|
||||
- 画布支持捏合、Cmd/Ctrl+滚轮缩放,以指针位置为锚点。
|
||||
- Cmd/Ctrl+B 展开/收起侧栏;侧栏提供新建、切换和关闭页面及四个开发工具入口。
|
||||
- Cmd/Ctrl+T 新建,Cmd/Ctrl+W 关闭,Ctrl+Tab / Ctrl+Shift+Tab 切换页面。
|
||||
|
||||
`panX/panY` 是可视区域左上角的世界坐标,`zoom` 为 0.1–4。变换是 `screen = (world - pan) * zoom`;绘制使用正变换,原生点击/滚动使用逆变换。VM 中 Mount 始终使用世界坐标,worker 根据同一视角重新检查可见性。Page 自己保存视角,切换后仍保留;有限页面在内容缩短或窗口变大时限制偏移。
|
||||
|
||||
画布没有内容边界裁剪,但保留现有运行预算:坐标为有限数且绝对值不超过 1,000,000,维度、对象数和绘制命令数仍有上限。“无限”指可以向内容以外导航,不代表无限内存或数值范围。
|
||||
|
||||
原生相机事件 `page-pan/page-zoom` 经过真实 HTTP worker,再由所属 VM 更新 Page;它们使用 `mount=0` 和当前 window/session/layout_version。平移 delta 使用可视区域像素;缩放 delta 为 `[factor,0]`,point 是可视区域内的锚点。普通点击与嵌套滚动的 point 使用世界坐标。过期事件返回 409;不适用于当前模式或参数非法的相机事件在执行前拒绝。
|
||||
|
||||
## 实现位置与验证边界
|
||||
|
||||
- [page.rs](../src/render/page.rs):模式约束、视角、正逆坐标变换和有限页面偏移限制。
|
||||
- [sidebar.rs](../src/shell/sidebar.rs):唯一宿主管理栏及折叠后点击遮挡范围。
|
||||
- [page_input.rs](../src/bridge/ui_bridge/page_input.rs):串行 VM 内的视角变更。
|
||||
- [API 回归](../src/runtime/api/tests.rs):真实 HTTP 平移、指针锚定缩放、负世界坐标点击、过期/非法事件。
|
||||
- [宿主回归](../src/runtime/endor_host/tests.rs):四种模式、实时内容增长、共享对象坐标、视角恢复和默认页真实按钮。
|
||||
|
||||
本地格式、153 项测试和 all-target Clippy 均通过;release app 已构建并实际启动。运行中平移与缩放事件返回 200,并读回 Page.panX=80、Page.zoom=2,随后恢复初始视角。最初 HTTP worker 漏接相机事件导致 400,现由完整链路回归覆盖。
|
||||
|
||||
macOS 当前未授予本次工具模拟输入和窗口截图权限。模拟滚轮未生效,无法据此声称真实鼠标/触控板、捏合或窗口外观验收通过。侧栏指针拖放、跨窗口拖放尚未迁移;原有 `moveTab/moveToWindow/detachToWindow` 与版本校验的 API 保留。没有修改 Aster 或引入新的 GUI/语言依赖。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 场景交互性能研究
|
||||
|
||||
## 结论
|
||||
|
||||
性能问题有可验证的解决路径,但单改 Aster 字典不能让当前世界 UI 达到稳定 60 FPS。本 PR 交付可重复基准和研究记录;去除字符串键比较临时分配的改动仍在独立 Aster 分支,未随本 PR 集成;面板纹理缓存实验因当前示例收益不足而撤回。没有实现 GPU 后端或宣称完成性能目标。
|
||||
|
||||
## 方法
|
||||
|
||||
在 Apple M4 Pro、macOS 15.6 本机使用 release 构建,固定 1024×800 逻辑视口、1× / 2× 像素比例,分别加载 world-ui 和 scene3d 示例。每组执行 33 次真实 scene-orbit 运行时分发,前 3 次作为预热,统计后 30 次的均值、P50、P95。每次先测分发到文档更新,再测变化后的软件绘制,最后测相同显示列表的缓存绘制。没有运行 Console 编译来代替原生事件。
|
||||
|
||||
```sh
|
||||
cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
这是无窗口的运行时与软件渲染基准,包含回调、快照、布局和运行时收尾,但不包含本地 HTTP、OS 事件排队、窗口呈现或输入到显示延迟。阶段耗时不能简单当作实测 FPS。时间是本机样本,不是跨设备保证;基准默认 ignored,普通回归不以绝对耗时断言通过。
|
||||
|
||||
## 对照结果
|
||||
|
||||
世界 UI、2× 像素比例,单位 ms:
|
||||
|
||||
| 条件 | 更新均值 | 更新 P95 | 变化绘制均值 | 变化绘制 P95 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 原始 Aster | 8.456 | 8.843 | 18.704 | 20.742 |
|
||||
| 借用字符串键比较 | 6.842 | 7.226 | 18.598 | 20.665 |
|
||||
| 再加面板纹理缓存实验 | 6.823 | 7.053 | 18.410 | 20.482 |
|
||||
|
||||
去掉字符串键比较的分配后,本组更新均值减少约 19%,绘制基本不变。面板缓存的绘制收益约 1%,不足以证明值得在本次增加缓存与失效复杂度,因此撤回。
|
||||
|
||||
作为对照,普通 scene3d 示例在 2× 下更新约 0.94 ms、变化绘制约 6.15 ms;它的视口尺寸、覆盖像素和 UI 复杂度不同,不能据此把两个示例的全部差值都归给某一层。
|
||||
|
||||
## Aster 发现与保留的修改
|
||||
|
||||
`EndorDictObject` 用插入有序的数组存储,`dict_position` 逐项调用 `key_equal`。原实现每次比较都会把内联文本或托管 String 转成新的 String;查找名称时会重复分配和复制候选键。
|
||||
|
||||
本次只改为借用字符串切片比较。保留线性查找、原 key_equal 分支、对象检查、只读依赖观察、键的插入身份与顺序、写入验证和引用计数流程。新增测试覆盖空文本、普通文本、Unicode、托管 / 内联字符串等价替换、删除和引用释放。
|
||||
|
||||
没有把通用字典直接改成 HashMap,也没有绕过 heap.replace_payload 的事务性边验证。前者还需处理托管键相等 / 哈希与顺序语义;后者涉及 GC、失败原子性和只读保护。它们不属于本次已经证明安全的改动。
|
||||
|
||||
## 尚未解决的主要成本
|
||||
|
||||
1. 世界 UI 的摄像机变化后,当前统计仍为 measured=8、arranged=88;普通 3D 为 measured=0、arranged=14。数据证明相机更新没有彻底绕开排版。
|
||||
2. 3D 变化帧仍在窗口线程逐像素进行 CPU 绘制。当前世界 UI 的绘制本身已超过 60 Hz 的 16.7 ms 预算,其他 CPU 优化不能消除这个事实。
|
||||
3. 原生连续输入仍逐项经本地 HTTP 发送,有界队列不等于按显示帧合并;真实排队和呈现延迟需要另外测量。
|
||||
|
||||
## 后续实施顺序与条件
|
||||
|
||||
1. **相机更新专用路径。** 复用已有 Mount 几何、UI 排版和绘制结果,仅更新视图 / 投影、可见性和命中映射。必须检查源 measure / arrange / paint 的实际读取依赖;源函数若读取相机,应继续正确失效。不能无条件跳过所有布局或绘制。验收:不依赖相机的 UI 在旋转时零测量、零重新排版,依赖相机的源协议回归仍通过。
|
||||
2. **按帧收集连续输入。** 原生操作直接进入进程内通道,连续手势按捕获会话 / 目标与顺序合并;点击、页面切换和运行时替换保持边界。外部 HTTP API 保留严格版本校验。验收包括缩放锚点、非交换手势顺序、嵌套捕获与过期会话,不能只检查最终数值。
|
||||
3. **GPU 合成与独立渲染。** 现有不可变网格、变换、相机、深度和 UV 数据可以成为渲染输入;VM 对象留在运行时线程。GPU 应直接向窗口表面呈现,同时合成 2D / 3D 内容,避免每帧 GPU→CPU 回读后再交给 softbuffer。跨线程队列只保留最新可呈现快照;纹理、缓冲与缓存均需资源预算和正确失效。
|
||||
|
||||
建议验收目标:固定示例 2× 下相机更新 P95 ≤2 ms、GPU 绘制 P95 ≤8 ms,另外记录真实窗口的帧间隔和输入到呈现延迟。目标尚未达到,也没有用无窗口基准宣称原生 60 FPS。
|
||||
|
||||
## 检查说明
|
||||
|
||||
Aster 全工作区测试发现 compiler 的 classes 测试中两项解析失败(第三行 expected EOF),以及 core 的 managed_coroutine_hard_budget_accumulates_and_error_releases_frames 指令预算失败。将对象实现恢复为未优化的基线后,这三项失败均能复现,说明不是本次借用字符串比较引入;不把全工作区标为全绿。对象层测试通过,Aster 全工作区 Clippy / 格式通过;GUI 为 186 项通过、1 项默认忽略的基准,GUI Clippy / 格式通过。该基准已经另行在 release 模式实际执行。
|
||||
|
||||
本次没有修改用户指向的 builtins.rs 第 176 行(该处是函数对象定义)。实际热路径修改位于 Aster 的 store_builtins.rs。
|
||||
|
||||
## PR #6 主线兼容修复
|
||||
|
||||
2026-11-12:合入最新 GUI main,保留 main 的 Aster f3e1670,而非旧性能分支 fd247ac。旧引用缺少 GUI 目前使用的精确读取依赖接口,不能为性能实验回退。上文数值是原有实验记录,不代表当前主线已经采用借用键优化。本 PR 的最终差异仅包括本研究记录和默认 ignored 的基准;Aster 优化需单独更新其分支并审阅后再集成。PR 标题中的 WIP 将移除,交付范围为可运行基准,不承诺上述后续性能目标。
|
||||
|
||||
本次兼容修复检查:GUI `cargo test --locked` 为 186 passed / 1 ignored;Clippy(全部 target,警告视为错误)、格式和差异空白检查通过。最初沙箱内 9 项 HTTP 测试因不能监听本地端口失败;允许回环网络后全套重跑通过。本次未重跑历史 release 性能采样,也未修改 Aster。
|
||||
@@ -0,0 +1,109 @@
|
||||
# UI、场景、视口与摄像机
|
||||
|
||||
本次完整实现目标的持续记录。完成布局并不等于完成场景或原生交互验收。
|
||||
|
||||
## 边界
|
||||
|
||||
- Page 负责页面身份、生命周期和根内容。Row、Column、Overlay 负责有限盒子内的排版。
|
||||
- Scene2D、Scene3D 管理世界对象。世界不是一个无限宽高的布局盒子。
|
||||
- CanvasViewport、Viewport3D 是有限的 UI 盒子,可以加入 Row、Column、Overlay,也可以放进世界内的有限 UI 面板。
|
||||
- Camera2D、透视和正交摄像机是显式对象。视口保存自己的尺寸、裁剪和投影比例;共享场景不隐式共享摄像机。
|
||||
- 摄像机控制器独立于摄像机数据。移动相机只改变投影、可见性和命中坐标,不应重新排版无关内容。
|
||||
- 视口覆盖 UI 不随世界移动。世界内 UI 面板拥有有限大小,使用普通布局,再由对应的世界变换呈现。
|
||||
- 嵌套视口的输入逐级逆变换,尊重裁剪和捕获;同一手势不能同时驱动内外相机。
|
||||
- 无限导航受数值、可见对象、递归、内存和绘制预算约束;引用环必须在发布部分结果前拒绝。
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
| 阶段 | 状态 | 证据 / 剩余工作 |
|
||||
| --- | --- | --- |
|
||||
| 基线审查 | 完成 | 保留共享 Block / 独立 Mount、源协议和已合并工作区示例 |
|
||||
| 嵌套布局与盒模型 | 完成 | Row / Column / Overlay、双轴对齐、子项覆盖、外边距、非对称内边距和边框、尺寸策略、裁剪;见下面接口 |
|
||||
| 独立 2D 场景与视口 | 完成 | 独立场景/摄像机、多视口及嵌套、覆盖 UI、投影/命中、原生输入路由、真实 HTTP 与像素回归;旧 Page.mode 仅保留兼容 |
|
||||
| 3D 场景、视口和摄像机 | 完成 | 软件深度缓冲、网格/变换/灯光、透视/正交相机、可替换控制器、射线拾取、世界空间 UI 及 2D/3D 嵌套 |
|
||||
| 可见性与资源防护 | 完成 | 2D 屏幕索引、3D BVH / 视锥剔除、场景缓存、连续原生事件版本更新、字形 LOD 和嵌套资源预算 |
|
||||
| 原生综合验收 | 可取得检查完成 | release 构建与实际窗口截图,运行实例的按钮 / 旋转 / 缩放事件及过期拒绝均通过;系统不允许模拟鼠标,未冒充硬件验收 |
|
||||
| 完成交付 | 已提交可评审 PR | [PR #5](https://gitea.entroia.com/Zayden-Jung/endorphin-tessera-gui/pulls/5) 已移除 Draft,Gitea 返回 mergeable=true;等待用户手动合并 |
|
||||
|
||||
## 有限盒子的接口
|
||||
|
||||
现有 `Row(padding, gap, children...)`、`Column(padding, gap, children...)` 保留,新增 `Overlay(padding, gap, children...)`。Overlay 为构造协议兼容保留 gap 参数,但叠放不使用 gap;子项顺序决定绘制和命中顺序。
|
||||
|
||||
- `mainAlign`: `start`、`center`、`end`、`space-between`。Overlay 对应横向对齐,不使用 space-between 分散规则。
|
||||
- `crossAlign`: `start`、`center`、`end`、`stretch`。Row 对应纵向;Column 对应横向。Overlay 对应纵向。
|
||||
- `alignSelf`: `auto` 继承所在父容器的 crossAlign,或以上交叉轴选项。属性在对象上,每个 Mount 根据自己的父容器解释。
|
||||
- `marginTop/Right/Bottom/Left`: 外边距参与父容器的测量、主轴分配和对齐。不折叠外边距,不支持负外边距。
|
||||
- `paddingTop/Right/Bottom/Left`: 数值覆盖对应边;`auto` 回退到构造参数或容器的 padding 属性。非容器回退为 0。
|
||||
- `borderTop/Right/Bottom/Left` 和 `borderColor`: 边框占据 border box 内侧,压缩内容空间。原生绘制产生实心直角边框;自定义 paint 可调用基类绘制,也可自行提供完整外观。
|
||||
- `boxWidth/boxHeight`: `auto`、非负数值或 `fill`。保留 RectBlock / ScrollBlock 原有 width/height 和源子类字段语义,通用盒尺寸使用独立名称。数值表示 border box,不包含外边距。
|
||||
- `minWidth/minHeight/maxWidth/maxHeight`: 盒尺寸边界,最低值不得超过最高值。父分配小于最低值时,外部有限约束仍然生效。
|
||||
- 主轴 `fill` 使用零基准分享剩余空间;未显式设置 flex 时权重为 1。最大值饱和后,剩余空间继续分给其他项目。交叉轴 fill 使用父容器提供的有限内容尺寸。没有有限父分配的 fill 报错。
|
||||
- `stretch` 仅拉伸交叉轴 auto 尺寸,遵守盒尺寸边界;数值固定尺寸保留。显式源 arrange 返回的布局始终优先。
|
||||
- `overflow`: `visible` 或 `clip`;后者同时裁剪后代绘制和指针命中。需要滚动时使用 ScrollBlock,滚动轴与内层 Row/Column 排版独立。
|
||||
|
||||
尺寸、边距和边框必须有限、非负,受现有 1000000 上限限制。根约束、每个 Mount 的偏好尺寸、分配、收敛判定及源协议缓存均保持独立。示例见 [box-layout.endor](../examples/box-layout.endor)。
|
||||
|
||||
布局阶段验证:本地 `cargo test --locked` 的 162 项回归全部通过,`cargo clippy --all-targets --locked -- -D warnings`、格式与差异空白检查通过。新增用例执行实际 Endor 脚本,覆盖共享对象在不同父容器中的对齐、实时边距变更、flex 分配、叠放、尺寸边界、文本换行、有限 fill、无界 fill 拒绝、源 arrange 优先、裁剪命中和可运行示例。本阶段尚未重新打包或进行原生指针验收。
|
||||
|
||||
## 2D 场景与视口
|
||||
|
||||
`Scene2D(children...)` 持有世界内容。直接子项的 x/y 是世界位置;Row/Column/Overlay 仍然只解释自己的有限内容布局。给世界内卡片设置 boxWidth,即可在其内部换行和嵌套排版。
|
||||
|
||||
`CanvasViewport(width, height, scene)` 是可参加普通排版的有限盒子,世界范围不参与它的本征尺寸。视口可使用通用盒模型尺寸、边框、内边距和 flex。每个新建视口拥有自己的 `Camera2D()`;显式赋值 `second.camera = first.camera` 才会共享视角。摄像机 x/y 是可视内容左上角的世界位置,zoom 范围 0.01–100;也可以 `Camera2D(x, y, zoom)` 创建。嵌套投影还受合成变换预算限制。
|
||||
|
||||
视口的有限内容框裁剪世界绘制和输入,Overlay 中的兄弟 UI 保持固定。场景内可以放另一个 CanvasViewport:布局坐标不变,投影逐层合成,指针逆变换至目标组件坐标。相机改变时复用不变的测量结果。`view.mounts().at(index).fitView()` 使用指定 Mount 的有限尺寸及场景直接子项的有限边界适配摄像机,不把无限世界当作排版边界。
|
||||
|
||||
滚轮优先交给命中的可滚动 ScrollView;其余平移交给最内层画布。空格加左键或中键拖动锁定开始时的视口,不因指针跨到另一个视口而切换相机;捏合及 Cmd/Ctrl+滚轮围绕指针缩放。`viewport-pan` / `viewport-zoom` 使用真实 Mount 身份及帧版本,通过 HTTP worker 串行修改所属 VM 的摄像机。非法缩放锚点在进入 VM 前拒绝,过期帧返回 409。
|
||||
|
||||
`ScrollView` 是现有 ScrollBlock 的名义子类型别名,滚动轴和其内容 Row/Column 排版独立。默认开始页已使用普通 Page + 独立场景/视口,纵横示例使用 ScrollView 对应的滚动容器组合;不再设置 Page.mode。原有 Page.mode/panX/panY/zoom 暂保留兼容旧脚本,不用于新架构示例。
|
||||
|
||||
示例见 [scene-viewports.endor](../examples/scene-viewports.endor)。回归包含两个视口共享同一业务对象、显式共享相机、嵌套变换、真实点击回调、相机修改时测量数为 0、真实 HTTP 链路及软件渲染像素中的缩放/裁剪。它们不能替代原生鼠标和触控板验收。
|
||||
|
||||
2D 阶段最终验证:169 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过,编辑器未报告错误。默认页的真实按钮事件也已迁移验证。未将原生窗口验收记为完成。
|
||||
|
||||
## 3D 基础接口与当前边界
|
||||
|
||||
- `Scene3D(nodes...)` 管理世界对象;`Group3D(nodes...)` 组合变换。`children` 是 ObjectList,可显式替换;共享节点可以出现多次,路径循环会拒绝。
|
||||
- `Box3D(width,height,depth,color)` 创建网格;`Geometry3D(ObjectList(Vec3(...),...), ObjectList(Triangle3D(a,b,c),...))` 创建不可变顶点/索引缓冲,再用 `Mesh3D(geometry,color)` 实例化。可替换 mesh.geometry;修改原输入 Vec3 不会改写已建立的不可变几何。
|
||||
- 网格/组有 x/y/z、rotationX/Y/Z(角度)、scaleX/Y/Z;局部缩放、X/Y/Z 旋转、平移依序应用,再继承父组变换。`translate(dx,dy,dz)`、`rotate(dx,dy,dz)` 可在 Endor 中直接调用。
|
||||
- `DirectionalLight(dx,dy,dz,color,intensity)` 使用传播方向,Scene3D.ambient 为基础照明。当前材质是完全不透明的平面着色。
|
||||
- `Viewport3D(width,height,scene)` 是普通有限 UI 盒子,可以放入 Row、Column、Overlay 或 Scene2D 中。背景与 3D 内容在独立表面上渲染,再交给现有原生软件合成器;采用实际深度缓冲、六面视锥裁剪,没有用静态图替代三维场景。
|
||||
- 每个新 Viewport3D 有独立 PerspectiveCamera;可用 `view.camera = OrthographicCamera()` 替换,或显式共享同一个 camera。摄像机字段为 x/y/z、targetX/Y/Z、upX/Y/Z、near/far、透视 fov(角度)、正交 height。宽高比由每次视口投影提供,不写回共享摄像机。`camera.translate(...)` 同时平移位置与观察目标。
|
||||
- `OrbitController()` 独立于摄像机数据,默认绑定到新视口。左键拖动旋转,中键/空格拖动平移,滚轮/捏合缩放。view.controller 可替换为提供 `update(camera, gesture)` 的 Endor 对象,或设为字符串 `none` 禁用。CameraGesture 提供 kind、dx/dy、factor、width/height、pointX/pointY。透视缩放改变观察距离,正交缩放改变 height。
|
||||
- `view.mounts().at(index).pick3D(x,y)` 接受投影前页面空间中的指针坐标,返回 RayHit3D 或空值;结果包含实际托管 mesh、世界 point/normal、distance、triangle 和 barycentric。原生指针点击对应视口时,标准 click Event.hit 提供相同结果;点击空白时为空。拾取与绘制共享摄像机和变换,遵守 near/far。
|
||||
|
||||
固定 Aster 版本不支持数值算术表达式,不能用 `x = x + dx` 编译控制器。因此示例使用可运行的原生 translate/rotate 操作;自定义控制器可以调用这些方法或直接设置摄像机字段。未修改 Aster 源码或 gitlink。
|
||||
|
||||
3D 表面最长边按需降采样至 2048,单表面不超过 4194304 像素;缓存最多 8 个不可变表面、总计 8388608 像素。三角形、顶点、场景节点、灯光、光栅工作和摄像机范围均有检查;超过光栅预算返回错误,不发布截断的半幅结果。降采样保留原始视口宽高比,使绘制与拾取使用相同投影。
|
||||
|
||||
示例见 [scene3d.endor](../examples/scene3d.endor)。测试覆盖真正的 Endor 构造/修改、最终渲染像素、深度顺序、透视/正交独立视图、近面裁剪、射线回调、可替换控制器的真实 HTTP 路由以及循环/非法几何拒绝。原生硬件交互尚未验收。
|
||||
|
||||
3D 基础阶段检查:176 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过。后续世界空间 UI 与嵌套资源/可见性工作尚未完成,因此整体目标及 PR 仍处于进行中。
|
||||
|
||||
## 世界空间 UI 与组合
|
||||
|
||||
`Panel3D(worldWidth, worldHeight, content)` 把普通 Endor Block 放进 3D 场景。面板有独立的 uiWidth/uiHeight(默认 512×320,整数 1–2048)和不透明 background;本次 UI 表面不实现透明材质。世界尺寸控制平面几何,UI 尺寸提供有限排版约束,两者互不代替。面板与组/网格一样支持位置、旋转、缩放及 translate/rotate。
|
||||
|
||||
内容仍是同一个托管对象树,正常执行 measure/arrange/paint/hitTest;每次出现有自己的 Mount、有限尺寸、滚动状态和纹理。一个组件可以放进多个不同尺寸的面板,不复制其业务状态。3D 摄像机移动不改变 UI 的排版尺寸。UI 绘制指令按面板收集,透视裁剪时保留 UV,并按深度进行透视校正插值;普通网格会正确遮挡面板。
|
||||
|
||||
指针先沿视口摄像机发射射线,再沿可见面板的逆世界变换映射到 UI 像素,然后继续通过内部 CanvasViewport 的逆变换。被其他网格挡住的 UI 无法收到指针命中。拖动捕获使用平面的连续逆投影,因此指针移出面板后不会把同一手势交给另一个摄像机。Mount.toView(x,y) 将组件本地坐标转到页面视图坐标;toLocal(x,y) 执行反向转换,无法命中可见面板时返回空值。pick3D 另提供实际 node/mesh 和实例编号。
|
||||
|
||||
面板 UI 可以继续嵌入 CanvasViewport 或 Viewport3D,后者也可以再包含 Panel3D;普通 Overlay 的兄弟 UI 仍固定在其 UI 空间。引用环在派生布局前拒绝;世界 UI 最多嵌套 8 层,光栅/纹理像素采用统一总预算。滚动视口与普通画布的世界内容不会撑大外层有限视口或外层滚动范围。
|
||||
|
||||
示例见 [world-ui.endor](../examples/world-ui.endor)。世界 UI 阶段的 181 项完整本地回归和 all-target Clippy 均通过;新增回归覆盖真正的像素、托管回调、材质遮挡、多面板独立尺寸、再次嵌入 3D、真实 HTTP 射线/UV 摄像机输入、循环和递归预算。
|
||||
|
||||
## 可见性、连续输入与资源
|
||||
|
||||
3D 场景快照构建世界 AABB 的 BVH,摄像机变化复用相同的不可变场景和索引。视锥查询剔除不可见网格,射线查询按近到远缩小候选;序列化不携带缓存,反序列化后可重建。几何或变换变化会生成新场景和新索引。
|
||||
|
||||
普通 2D 交互和相机目标使用 128px 屏幕网格。每个对象最多进入 256 格,索引总计最多 65536 项;巨大矩形和项目化的 3D 面板 UI 进入精确命中的后备集合。后备集合仍遵循原有裁剪、投影和反向绘制顺序,不因近面投影或索引预算遗漏对象。源 hitTest 仍逐一执行其真实协议,未更改源覆盖行为。
|
||||
|
||||
原生输入使用有界 64 项队列。同一个运行时的连续 viewport / scene / scroll 事件在出队时更新到当前帧版本,保留原窗口、会话和捕获 Mount;目标消失、控制器禁用、运行时更换或失败时丢弃。所有窗口共用同一事件客户端和权威文档。离散点击及外部 HTTP 请求仍严格拒绝过时版本,不自动重放业务动作。队列满时仍可拒绝新输入,不保证任意输入速率下无损。
|
||||
|
||||
文字先裁剪再遍历可见字形像素;字体超过 512 设备像素时使用有界字形位图采样,避免高倍率缩放产生巨大单字形分配。字形缓存逐字检查 4096 项 / 32 MiB 阈值,整形缓存继续限制字符串数量与源字节数。极端放大时清晰度受字形 LOD 限制。面板派生槽位限制为非负 i64,避免哈希高位造成偶发排版错误;重复标识拒绝发布。
|
||||
|
||||
## 原生验证记录
|
||||
|
||||
2026-11-11:本地完整回归、格式、all-target Clippy 和 release 应用打包;实际启动 Tessera.app,通过本机 API 加载 world-ui 示例,并取得原生窗口截图,确认立方体、倾斜面板和内部 Canvas 的显示。系统 `CGPreflightScreenCaptureAccess` 为 true,`CGPreflightPostEventAccess` 为 false;集成的原生自动操作工具还因 OpenSSL 配置读取权限启动失败。因此 API / VM 事件与像素测试不宣称为硬件鼠标或触控板验收。最终检查数量及交付提交在 PR 中记录。
|
||||
|
||||
最终完整回归为 186 项通过;运行中的原生实例验证世界 UI 按钮将计数从 0 改为 1、scene-orbit 改变摄像机、scene-zoom 改变观察距离、重复旧帧请求返回 HTTP 409,空闲连续采样的版本保持一致。保留应用窗口供用户直接操作。
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| [world-ui.endor](world-ui.endor) | Interactive Endor UI on a transformed 3D plane, with shared counter state, nested canvas, programmable camera and fixed UI controls. |
|
||||
| [scene3d.endor](scene3d.endor) | Shared 3D meshes, transforms and light with independent perspective/orthographic cameras, orbit controls and Endor buttons. |
|
||||
| [scene-viewports.endor](scene-viewports.endor) | One Scene2D in two CanvasViewports with independent Camera2D, a nested viewport, shared state and fixed overlay UI. |
|
||||
| [box-layout.endor](box-layout.endor) | Nested Row/Column/Overlay, shared text, finite border-box sizing, margins, borders, alignment and clipping. |
|
||||
| [gc-lifecycle.endor](gc-lifecycle.endor) | A Page/Block/Task/await-frame cycle: hidden retained Blocks keep running, then releasing the last references permits collection despite rendering caches. |
|
||||
| [scroll-axis.endor](scroll-axis.endor) | One shared TextBlock in vertical and horizontal viewports, live axis changes and independent offsets. |
|
||||
| [windows.endor](windows.endor) | Two real native windows in one Aster VM, live shared counter state and explicit Page ownership. |
|
||||
@@ -26,8 +30,14 @@
|
||||
Submit a file as the UTF-8 body of a namespace PUT request. No namespace,
|
||||
scene, render block or imports belong in the source.
|
||||
|
||||
The live `/agent` guide embeds page.endor exactly. Remote CI executes the examples through
|
||||
Aster and checks the resulting coordinates and source ranges after VM cleanup.
|
||||
The live `/agent` guide embeds page.endor exactly. Local tests execute examples through
|
||||
Aster and check the resulting coordinates and source ranges after VM cleanup.
|
||||
For Page window/vertical/horizontal/canvas modes, see [page surfaces](../docs/page-surfaces.md)
|
||||
and the running start page's four working actions.
|
||||
The file is client-side sample material; Tessera's API stores source by namespace.
|
||||
|
||||
Return to the [project guide](../README.md).
|
||||
|
||||
## 龙与文字绕排
|
||||
|
||||
`illustrated-manuscript.endor` 通过通用图形资源、源 paint、指针事件与 Page 任务实现。龙、火焰和文字受热逻辑均在 Endor 中。资源在 `assets/manuscript/`;从仓库根目录启动应用并提交源码,详见 [运行和脚本解释](../docs/illustrated-manuscript.md)。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Illustrated Manuscript assets and attribution
|
||||
|
||||
Dragon sprites (head, tongue, front/back wings, body-1 through body-19), fire
|
||||
sprites (Layer 2 through Layer 11), dropcap.png and story.txt are from
|
||||
https://github.com/dengshu2/illustrated-manuscript (main, retrieved 2026-11-12).
|
||||
The upstream package.json declares `ISC`; upstream has no separate LICENSE file.
|
||||
Its complete README is retained as UPSTREAM.md. It credits the original demo to
|
||||
River Marchand (@Riyvir), Neither/Nor: https://neithernor.studio/.
|
||||
Reference live demo: https://illustrated-manuscript.vercel.app/.
|
||||
The native chain animation, exclusion and fire algorithms are adapted from that
|
||||
project's src/dragon.js and src/main.js; this port contains no JavaScript runtime.
|
||||
|
||||
IMFellEnglish-Regular.ttf is the unmodified IMFeENrm28P.ttf from
|
||||
https://github.com/google/fonts/tree/main/ofl/imfellenglish.
|
||||
Copyright (c) 2010, Igino Marini. SIL Open Font License 1.1, retained in OFL.txt.
|
||||
The face is embedded for offline, reproducible manuscript typography.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Illustrated Manuscript
|
||||
|
||||
An interactive illuminated manuscript page featuring an animated dragon that follows your cursor, breathes fire on click, and causes text to flow around it in real-time.
|
||||
|
||||
**[→ Live Demo](https://illustrated-manuscript.vercel.app/)**
|
||||
|
||||
## Overview
|
||||
|
||||
This project renders a medieval manuscript page on Canvas, featuring a hand-painted dragon made of sprite segments. The dragon tracks the mouse cursor with chain-physics (20 body segments with angle constraints), and clicking makes it breathe fire. Text reflows around the dragon body and fire particles each frame using [Pretext](https://github.com/chenglou/pretext)'s `layoutNextLine` API — no DOM measurements, no CSS reflow.
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time text exclusion** — text wraps around arbitrary moving shapes (dragon body + fire particles), re-laid out every frame via Pretext
|
||||
- **Chain-physics dragon** — 20-segment inverse-kinematics body, hand-painted sprites, wing flapping, idle rest pose
|
||||
- **Fire-breathing** — click to spawn particle bursts from the dragon's mouth; particles push and distort nearby text characters
|
||||
- **Illuminated drop cap** — hand-drawn initial letter in manuscript tradition
|
||||
- **Medieval aesthetic** — IM Fell English font, parchment tones, hand-painted sprites throughout
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **[Pretext](https://github.com/chenglou/pretext)** — Pure JS/TS text measurement & layout engine by [Cheng Lou](https://x.com/_chenglou). Enables laying out text without DOM reflow, line by line with variable widths
|
||||
- **WebGL2 + 2D Canvas** — offscreen 2D Canvas compositing, uploaded to WebGL2 texture for display
|
||||
- **Vite** — build tool
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
config.js — constants, responsive layout calculations
|
||||
dragon.js — dragon physics, sprite rendering, fire particle system
|
||||
story.js — manuscript story text
|
||||
main.js — entry point: WebGL pipeline, input, text layout, render loop
|
||||
public/
|
||||
dragon-sprites/ — 23 hand-painted dragon body part PNGs
|
||||
fire-sprites/ — 10 fire particle PNGs
|
||||
img/dropcap.png — illuminated drop cap
|
||||
(IM Fell English loaded via Google Fonts)
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open in browser. Desktop recommended — mobile support is limited.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Text preparation**: story text is segmented and measured once via `prepareWithSegments()`
|
||||
2. **Each frame**: dragon segments and fire particles generate exclusion rectangles
|
||||
3. **Text layout**: for each line, available width ranges are computed by subtracting exclusion zones, then `layoutNextLine()` fills each range
|
||||
4. **Fire text effect**: characters near fire particles are displaced, rotated, and color-shifted based on proximity
|
||||
5. **Rendering**: everything composites on an offscreen 2D Canvas, then uploads to a WebGL2 fullscreen quad
|
||||
|
||||
## Credits
|
||||
|
||||
Created by [River Marchand (@Riyvir)](https://x.com/Riyvir), Creative Director at [Neither/Nor](https://neithernor.studio/).
|
||||
|
||||
Built with [Pretext](https://github.com/chenglou/pretext) by [Cheng Lou](https://x.com/_chenglou) — a pure JavaScript/TypeScript library for multiline text measurement & layout, enabling web UIs to bypass DOM measurements and reflow entirely.
|
||||
|
||||
> *"This little illuminated dragon is very happy about Pretext."* — [@Riyvir](https://x.com/Riyvir/status/2038093450139279426)
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,47 @@
|
||||
It was the third candle the abbess had confiscated, and Sable had nodded gravely each time and stolen another from the chapel stores. She kept reading by its unsteady light, hunched beneath her wool blanket in the scriptorium where the novices slept on straw pallets between the copying desks.
|
||||
|
||||
It was the bestiary that held her. Not the psalter, not the gospels, not the lives of saints with their wooden sufferings. The bestiary. Some brother at Lindisfarne had painted it two centuries ago, and his creatures had a quality she could not name, a weight to them, as if they had been observed from life rather than copied from pattern books. His lions looked hungry. His basilisks looked bored.
|
||||
|
||||
And his dragon, coiled in the lower margin of the forty-seventh leaf, looked like it was breathing.
|
||||
|
||||
She had watched it for six nights before she was certain. The movement was slight, a swelling of the ribs, a settling, barely more than the flicker the candlelight threw across the page. On the seventh night she held her breath and placed a finger beside its painted flank, and the parchment was warm.
|
||||
|
||||
\u201CYou could simply ask,\u201D the dragon said.
|
||||
|
||||
Sable jerked her hand back. The dragon opened one eye, yellow as egg tempera, and regarded her with an expression she recognized from the abbey cats: mild contempt for a creature too slow to be interesting but too close to ignore.
|
||||
|
||||
\u201CI am called Voss,\u201D it said. \u201CThough the brother who painted me called me several other things, mostly under his breath, mostly because I would not hold still.\u201D
|
||||
|
||||
\u201CYou are paint,\u201D Sable said. It came out less firmly than she intended.
|
||||
|
||||
\u201CI am gall ink and oak soot and gum arabic,\u201D Voss replied. \u201CAlso rabbit skin glue, which I find personally offensive. But I am not paint. Paint does not get stiff from two centuries of holding the same pose.\u201D He uncurled one leg and stretched it across the margin rule, his claws dimpling the parchment. \u201CYou, on the other hand, are trespassing. This page is not assigned reading for novices.\u201D
|
||||
|
||||
\u201CI am not a novice. I clean the scriptorium.\u201D
|
||||
|
||||
\u201CEven worse. A servant reading. The brothers would be scandalized.\u201D
|
||||
|
||||
\u201CThe brothers are asleep.\u201D
|
||||
|
||||
\u201CThe brothers,\u201D Voss said, \u201Care always asleep. That is the principal advantage of monasteries.\u201D He yawned, and his breath smelled of lamp-black and something older, something mineral, like a cave after rain. A thin red tongue uncurled and tasted the air above the page.
|
||||
|
||||
Sable leaned closer. In the candlelight she could see the crosshatching of his scales, each one laid down with a single stroke of the brush. The brother who painted him had not scrimped. Most bestiary dragons were outlines stuffed with vermillion, flat and heraldic. This one had shadow under its jaw. This one had a nick in its left ear.
|
||||
|
||||
\u201CWhat do you eat?\u201D she asked.
|
||||
|
||||
\u201CSilverfish, mostly. They get into the binding paste. Occasionally a bookworm, if I am feeling athletic.\u201D He cocked his head. \u201CWhy are you not frightened?\u201D
|
||||
|
||||
\u201CShould I be?\u201D
|
||||
|
||||
\u201CConvention demands it. A maiden, a dragon. There are expectations.\u201D
|
||||
|
||||
\u201CI scrub floors and empty chamber pots. I am not anyone\u2019s idea of a maiden.\u201D
|
||||
|
||||
Voss made a sound that might have been a laugh. It was dry and papery, like a sheet of vellum being folded. \u201CGood,\u201D he said. \u201CMaidens are tedious. They always scream at exactly the wrong moment. You, at least, have the sense to ask questions first.\u201D
|
||||
|
||||
He settled back into the margin, tucking his tail around the descender of a large Q. \u201CCome back tomorrow night,\u201D he said. \u201CBring a candle that does not gutter so badly. And if you can manage it, a bit of cheese. I have not tasted cheese in two hundred years, but I believe I would still recognize it.\u201D
|
||||
|
||||
\u201CCan you eat cheese? You are made of ink.\u201D
|
||||
|
||||
\u201CI am made of ink that remembers being alive,\u201D Voss said. \u201CThere is a considerable difference.\u201D He closed his eyes. The warmth under Sable\u2019s fingertips faded, and the dragon on the page was still again, just pigment and binder on stretched calfskin, exactly as lifeless as it should have been.
|
||||
|
||||
Sable sat in the dark for a long time after the candle burned out. Then she went to the larder and stole a piece of cheese.\u2026
|
||||
@@ -0,0 +1,38 @@
|
||||
// Containers compose independently of Page and of any camera.
|
||||
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
|
||||
shared.boxWidth = "fill"
|
||||
shared.marginBottom = 8
|
||||
|
||||
left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索"))
|
||||
left.boxWidth = "fill"
|
||||
left.minWidth = 180
|
||||
left.maxWidth = 360
|
||||
left.borderLeft = 2
|
||||
left.borderTop = 2
|
||||
left.borderRight = 2
|
||||
left.borderBottom = 2
|
||||
left.borderColor = "#CBD5E1"
|
||||
left.paddingLeft = 24
|
||||
left.crossAlign = "center"
|
||||
|
||||
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
|
||||
right.boxWidth = "fill"
|
||||
right.maxWidth = 240
|
||||
right.crossAlign = "end"
|
||||
|
||||
content = Row(24, 24, left, right)
|
||||
content.boxWidth = 760
|
||||
content.crossAlign = "start"
|
||||
badge = TextBlock("Endorphin · layout")
|
||||
badge.marginRight = 12
|
||||
badge.marginBottom = 12
|
||||
badge.alignSelf = "end"
|
||||
surface = Overlay(0, 0, content, badge)
|
||||
surface.boxWidth = 800
|
||||
surface.boxHeight = 400
|
||||
surface.mainAlign = "end"
|
||||
surface.overflow = "clip"
|
||||
|
||||
page = Page()
|
||||
page.title = "嵌套盒模型"
|
||||
page.render(surface)
|
||||
@@ -0,0 +1,297 @@
|
||||
// All application state, motion, fire, hit reactions and scheduling live here.
|
||||
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
|
||||
g = ui.graphics
|
||||
math = g.math
|
||||
assets = "examples/assets/manuscript/"
|
||||
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
|
||||
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
|
||||
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
|
||||
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
|
||||
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
|
||||
|
||||
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
|
||||
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
|
||||
}
|
||||
function wrappedAngle(angle: Number): Number {
|
||||
result = angle
|
||||
while result > math.pi { result = result - math.pi * 2 }
|
||||
while result < -math.pi { result = result + math.pi * 2 }
|
||||
return result
|
||||
}
|
||||
class Segment {
|
||||
x: Number
|
||||
y: Number
|
||||
angle: Number
|
||||
image: Object
|
||||
}
|
||||
// Asset orientation is application data: angles are radians, clockwise in
|
||||
// screen coordinates. Each PNG is calibrated against its own long axis.
|
||||
class FireArt {
|
||||
image: Object
|
||||
axis: Number
|
||||
}
|
||||
class Direction { angle: Number }
|
||||
class Flame {
|
||||
x: Number
|
||||
y: Number
|
||||
vx: Number
|
||||
vy: Number
|
||||
size: Number
|
||||
age: Number
|
||||
art: FireArt
|
||||
}
|
||||
class Dragon {
|
||||
segments: Object
|
||||
flames: Object
|
||||
fireImages: Object
|
||||
tongue: Object
|
||||
frontWing: Object
|
||||
backWing: Object
|
||||
targetX: Number
|
||||
targetY: Number
|
||||
pressed: Number
|
||||
time: Number
|
||||
lastMove: Number
|
||||
seed: Number
|
||||
function random(self: Dragon): Number {
|
||||
self.seed = self.seed + 1
|
||||
value = math.sin(self.seed * 12.9898) * 43758.5453
|
||||
return value - math.floor(value)
|
||||
}
|
||||
function pointer(self: Dragon, event: Event): Unit {
|
||||
bounds = event.mount.bounds()
|
||||
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||||
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
|
||||
self.targetY = (event.y - 30) / scale
|
||||
self.lastMove = math.now()
|
||||
}
|
||||
function down(self: Dragon, event: Event): Unit {
|
||||
self.pointer(event)
|
||||
self.pressed = 1
|
||||
}
|
||||
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
|
||||
function leave(self: Dragon, event: Event): Unit {
|
||||
self.pressed = 0
|
||||
self.lastMove = -10
|
||||
}
|
||||
function step(self: Dragon): Unit {
|
||||
self.time = math.now()
|
||||
if self.time - self.lastMove > 2 {
|
||||
x = math.float(150)
|
||||
y = math.float(110)
|
||||
i = 0
|
||||
while i < self.segments.count {
|
||||
s = self.segments.at(i)
|
||||
angle = -i / 19 * math.pi * 0.7
|
||||
if i > 0 {
|
||||
x = x - math.cos(angle) * 30
|
||||
y = y - math.sin(angle) * 30
|
||||
}
|
||||
s.x = s.x + (x - s.x) * 0.12
|
||||
s.y = s.y + (y - s.y) * 0.12
|
||||
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
|
||||
i = i + 1
|
||||
}
|
||||
} else {
|
||||
head = self.segments.at(0)
|
||||
dx = self.targetX - head.x
|
||||
dy = self.targetY - head.y
|
||||
distance = math.sqrt(dx * dx + dy * dy)
|
||||
if distance > 4 {
|
||||
speed = math.min(distance, math.max(12, distance * 0.15))
|
||||
head.x = head.x + dx / distance * speed
|
||||
head.y = head.y + dy / distance * speed
|
||||
head.angle = math.atan2(dy, dx)
|
||||
}
|
||||
i = 1
|
||||
while i < self.segments.count {
|
||||
previous = self.segments.at(i - 1)
|
||||
s = self.segments.at(i)
|
||||
angle = math.atan2(previous.y - s.y, previous.x - s.x)
|
||||
s.angle = previous.angle + math.clamp(wrappedAngle(angle - previous.angle), -0.25, 0.25)
|
||||
s.x = previous.x - math.cos(s.angle) * 30
|
||||
s.y = previous.y - math.sin(s.angle) * 30
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
i = self.flames.count
|
||||
while i > 0 {
|
||||
i = i - 1
|
||||
p = self.flames.at(i)
|
||||
p.age = p.age + 1
|
||||
p.x = p.x + p.vx
|
||||
p.y = p.y + p.vy
|
||||
p.vx = p.vx * 0.95
|
||||
p.vy = p.vy * 0.95 - 0.6
|
||||
if p.age >= 12 { self.flames.remove(i) }
|
||||
}
|
||||
if self.pressed > 0 {
|
||||
head = self.segments.at(0)
|
||||
i = 0
|
||||
while i < 1 {
|
||||
if self.flames.count < 12 {
|
||||
angle = head.angle + (self.random() - 0.5) * 0.25
|
||||
speed = 20 + self.random() * 12
|
||||
size = 12 + self.random() * 8
|
||||
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
|
||||
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
|
||||
commands = ObjectList()
|
||||
wing = self.segments.at(5)
|
||||
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
|
||||
i = self.segments.count
|
||||
while i > 0 {
|
||||
i = i - 1
|
||||
s = self.segments.at(i)
|
||||
ax = math.float(0.5)
|
||||
if i == 0 {
|
||||
ax = 0.45
|
||||
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
|
||||
}
|
||||
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
|
||||
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
|
||||
}
|
||||
i = 0
|
||||
while i < self.flames.count {
|
||||
p = self.flames.at(i)
|
||||
image = p.art.image
|
||||
imageScale = p.size * scale / math.max(image.width, image.height)
|
||||
angle = math.atan2(p.vy, p.vx) - p.art.axis
|
||||
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
|
||||
drawing.opacity = 1 - p.age / 12
|
||||
if p.age > 4 { drawing.tint = "#E08A30" }
|
||||
if p.age > 8 { drawing.tint = "#F0C030" }
|
||||
commands.append(drawing)
|
||||
i = i + 1
|
||||
}
|
||||
return commands
|
||||
}
|
||||
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
|
||||
i = 0
|
||||
while i < commands.count {
|
||||
c = commands.at(i)
|
||||
j = 0
|
||||
while j < self.flames.count {
|
||||
p = self.flames.at(j)
|
||||
dx = c.x - ox - p.x * scale
|
||||
if math.abs(dx) < 60 * scale {
|
||||
dy = c.y - oy - p.y * scale
|
||||
if math.abs(dy) < 60 * scale {
|
||||
distance = math.sqrt(dx * dx + dy * dy)
|
||||
if distance > 0.1 {
|
||||
if distance < 60 * scale {
|
||||
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
|
||||
c.x = c.x + dx / distance * weight * 8 * scale
|
||||
c.y = c.y + dy / distance * weight * 8 * scale
|
||||
c.angle = c.angle + weight * 0.12
|
||||
c.opacity = math.max(0.3, c.opacity - weight * 0.2)
|
||||
c.tint = "#C4402A"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
j = j + 1
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
class Manuscript: RectBlock {
|
||||
dragon: Dragon
|
||||
page: Object
|
||||
prepared: Object
|
||||
small: Object
|
||||
initial: Object
|
||||
timer: Object
|
||||
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
|
||||
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
|
||||
}
|
||||
function onMount(self: Manuscript, mount: Mount): Unit {
|
||||
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||||
}
|
||||
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
|
||||
function tick(self: Manuscript): Unit {
|
||||
self.dragon.step()
|
||||
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||||
}
|
||||
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
|
||||
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||||
ox = bounds.x + (bounds.width - 700 * scale) / 2
|
||||
oy = bounds.y + 30
|
||||
margin = 45 * scale
|
||||
text = self.prepared
|
||||
line = 34
|
||||
if bounds.width < 540 {
|
||||
text = self.small
|
||||
line = 26
|
||||
}
|
||||
initialHeight = line * 7
|
||||
initialWidth = self.initial.width / self.initial.height * initialHeight
|
||||
art = self.dragon.artwork(ox, oy, scale)
|
||||
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
|
||||
i = 0
|
||||
while i < art.count {
|
||||
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
|
||||
i = i + 1
|
||||
}
|
||||
letters = 0
|
||||
if self.dragon.flames.count > 0 { letters = 1 }
|
||||
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
|
||||
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
|
||||
commands = RectBlock.paint(self, mount, bounds).commands
|
||||
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
|
||||
commands.extend(drawing.commands)
|
||||
commands.extend(art)
|
||||
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
|
||||
commands.extend(footer.commands)
|
||||
return DisplayList(commands)
|
||||
}
|
||||
}
|
||||
|
||||
segments = ObjectList()
|
||||
headImage = g.loadImage(assets + "dragon-sprites/head.png")
|
||||
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
|
||||
function makeBody(): Unit {
|
||||
i = 1
|
||||
x = math.float(150)
|
||||
y = math.float(110)
|
||||
while i < 20 {
|
||||
angle = -i / 19 * math.pi * 0.7
|
||||
x = x - math.cos(angle) * 30
|
||||
y = y - math.sin(angle) * 30
|
||||
image = g.loadImage(assets + "dragon-sprites/body-" + g.string(i) + ".png")
|
||||
segments.append(Segment(x, y, angle, image))
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
makeBody()
|
||||
fireImages = ObjectList()
|
||||
// Layer 2..11: baseline directions differ; these values belong to this artwork.
|
||||
fireDirections = ObjectList(Direction(-0.50), Direction(-0.30), Direction(-0.55), Direction(0.40), Direction(-1.35), Direction(-1.57), Direction(-1.57), Direction(0.05), Direction(-1.40), Direction(1.12))
|
||||
function loadFire(): Unit {
|
||||
i = 2
|
||||
while i <= 11 {
|
||||
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
|
||||
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
loadFire()
|
||||
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
|
||||
page = Page()
|
||||
page.title = "Illustrated Manuscript · Endor"
|
||||
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
|
||||
view.flex = 1
|
||||
view.boxWidth = "fill"
|
||||
page.crossAlign = "stretch"
|
||||
moveConnection = view.on("pointermove", dragon.pointer)
|
||||
downConnection = view.on("pointerdown", dragon.down)
|
||||
upConnection = view.on("pointerup", dragon.up)
|
||||
leaveConnection = view.on("pointerleave", dragon.leave)
|
||||
cancelConnection = view.on("pointercancel", dragon.leave)
|
||||
page.render(view)
|
||||
@@ -0,0 +1,28 @@
|
||||
// The scene has world positions; each finite viewport has its own camera.
|
||||
count = ui.state(0)
|
||||
increment = Button("共享计数 +1")
|
||||
increment.onClick(count.increment)
|
||||
card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment)
|
||||
card.boxWidth = 220
|
||||
card.x = -60
|
||||
card.y = 40
|
||||
card.borderLeft = 2
|
||||
card.borderColor = "#8CA5B8"
|
||||
|
||||
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
|
||||
inner.x = 260
|
||||
inner.y = 40
|
||||
world = Scene2D(card, inner)
|
||||
left = CanvasViewport(460, 320, world)
|
||||
left.camera.x = -100
|
||||
right = CanvasViewport(320, 320, world)
|
||||
right.camera.x = -80
|
||||
right.camera.zoom = 0.65
|
||||
// Set right.camera = left.camera to share a view explicitly.
|
||||
|
||||
views = Row(0, 20, left, right)
|
||||
hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放"))
|
||||
hud.alignSelf = "end"
|
||||
page = Page()
|
||||
page.title = "场景与视口"
|
||||
page.render(Overlay(12, 0, views, hud))
|
||||
@@ -0,0 +1,46 @@
|
||||
class SceneActions {
|
||||
view: Viewport3D
|
||||
cube: Mesh3D
|
||||
function front(self: SceneActions): Unit {
|
||||
self.view.camera.x = 0
|
||||
self.view.camera.y = 0
|
||||
self.view.camera.z = 6
|
||||
}
|
||||
function side(self: SceneActions): Unit {
|
||||
self.view.camera.x = 6
|
||||
self.view.camera.y = 2
|
||||
self.view.camera.z = 0
|
||||
}
|
||||
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
|
||||
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
|
||||
}
|
||||
cube = Box3D(1.6, 1.6, 1.6, "#6687AB")
|
||||
cube.rotationY = 25
|
||||
cube.rotationX = 15
|
||||
small = Box3D(0.7, 0.7, 0.7, "#DBA36E")
|
||||
small.x = 1.8
|
||||
small.z = -0.8
|
||||
group = Group3D(cube, small)
|
||||
floor = Box3D(6, 0.08, 4, "#D8DEE6")
|
||||
floor.y = -1.1
|
||||
light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9)
|
||||
scene = Scene3D(group, floor, light)
|
||||
scene.ambient = 0.35
|
||||
perspective = Viewport3D(480, 360, scene)
|
||||
perspective.camera.z = 6
|
||||
orthographic = Viewport3D(320, 360, scene)
|
||||
orthographic.camera = OrthographicCamera()
|
||||
orthographic.camera.height = 5
|
||||
orthographic.camera.x = 4
|
||||
orthographic.camera.y = 2
|
||||
orthographic.camera.z = 5
|
||||
actions = SceneActions(perspective, cube)
|
||||
front = Button("正面")
|
||||
front.onClick(actions.front)
|
||||
side = Button("侧面")
|
||||
side.onClick(actions.side)
|
||||
rotate = Button("物体旋转 +15°")
|
||||
rotate.onClick(actions.rotate)
|
||||
page = Page()
|
||||
page.title = "场景、视口和摄像机"
|
||||
page.render(Column(24, 16, TextBlock("同一个 3D 场景,两个摄像机", 2), Row(0, 16, perspective, orthographic), Row(0, 12, front, side, rotate), TextBlock("左键拖动旋转 · 中键或空格拖动平移 · 滚轮缩放\n摄像机和控制器都是可编程对象;2D 控件由 Endor 自己排版。")))
|
||||
@@ -0,0 +1,56 @@
|
||||
// A normal Endor UI tree rendered on a transformed plane in the 3D world.
|
||||
class WorldActions {
|
||||
view: Viewport3D
|
||||
panel: Panel3D
|
||||
canvas: CanvasViewport
|
||||
function reset(self: WorldActions): Unit {
|
||||
self.view.camera.x = 0
|
||||
self.view.camera.y = 0.5
|
||||
self.view.camera.z = 7
|
||||
self.view.camera.targetX = 0
|
||||
self.view.camera.targetY = 0
|
||||
self.view.camera.targetZ = 0
|
||||
}
|
||||
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
|
||||
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
|
||||
}
|
||||
count = ui.state(0)
|
||||
increment = Button("点击:世界里的真实按钮")
|
||||
increment.onClick(count.increment)
|
||||
card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8"))
|
||||
card.boxWidth = 240
|
||||
card.x = 10
|
||||
card.y = 4
|
||||
canvas = CanvasViewport(280, 80, Scene2D(card))
|
||||
ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas)
|
||||
ui_root.crossAlign = "stretch"
|
||||
panel = Panel3D(4, 3, ui_root)
|
||||
panel.uiWidth = 320
|
||||
panel.uiHeight = 240
|
||||
panel.x = -1.2
|
||||
panel.rotationY = 15
|
||||
cube = Box3D(1.4, 1.4, 1.4, "#7B9CB7")
|
||||
cube.x = 2
|
||||
cube.rotationY = 25
|
||||
floor = Box3D(10, 0.08, 5, "#D8DEE6")
|
||||
floor.y = -1.8
|
||||
scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9))
|
||||
scene.ambient = 0.35
|
||||
view = Viewport3D(960, 520, scene)
|
||||
view.flex = 1
|
||||
view.camera.y = 0.5
|
||||
view.camera.z = 7
|
||||
actions = WorldActions(view, panel, canvas)
|
||||
reset = Button("重置摄像机")
|
||||
reset.onClick(actions.reset)
|
||||
turn = Button("转动 UI 面板")
|
||||
turn.onClick(actions.turn)
|
||||
fit = Button("适配面板内的画布")
|
||||
fit.onClick(actions.fit)
|
||||
body = Column(24, 16, TextBlock("UI → 3D 世界 → UI → Canvas", 2), view, Row(0, 12, reset, turn, fit), TextBlock("空白处左键拖动旋转 3D · 中键/空格平移 · 滚轮缩放\n面板里的按钮、文本、布局和画布都由同一份 Endor 对象驱动。"))
|
||||
body.flex = 1
|
||||
body.crossAlign = "stretch"
|
||||
page = Page()
|
||||
page.title = "世界空间 UI"
|
||||
page.crossAlign = "stretch"
|
||||
page.render(body)
|
||||
@@ -9,19 +9,16 @@ when official crates.io is unavailable. Defaults remain the official source.
|
||||
|
||||
The local bundle has identifier `com.entroia.tessera` and can be opened from Finder.
|
||||
It is a development app, without a distribution signature or notarization.
|
||||
The CI workflow only checks this script's shell syntax on Linux; native bundle
|
||||
construction and window presentation are verified on macOS.
|
||||
Check shell syntax with `sh -n scripts/build-macos-app.sh`; native bundle
|
||||
construction and window presentation are verified locally on macOS.
|
||||
|
||||
Return to the [project guide](../README.md).
|
||||
|
||||
## Remote verification
|
||||
## Local verification
|
||||
|
||||
[verify-ci.sh](verify-ci.sh) runs only on the Linux ces-control host. It builds the
|
||||
source-independent [toolchain image](../Dockerfile.ci), mounts the checkout
|
||||
read-only and retains Cargo downloads/build output in project-specific Docker
|
||||
volumes. CI_CACHE_SCOPE selects main/manual or a PR cache namespace. It acquires
|
||||
a host lock and removes its own container on interruption; cached volumes survive.
|
||||
See [workflow details](../.gitea/workflows/README.md) for invalidation and triggers.
|
||||
Build and validate with the native Rust toolchain. The project has no CI/CD
|
||||
workflow or Docker build image. See the [validation commands](../README.md#validation)
|
||||
for formatting, tests and Clippy; window behavior is checked in the running app.
|
||||
|
||||
|
||||
## UML 文档渲染
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Native macOS build and app bundle. No Docker, mirror configuration, or tests.
|
||||
set -eu
|
||||
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
|
||||
if [ "$(uname -s)" != Darwin ]; then
|
||||
echo "Build this app bundle on macOS." >&2
|
||||
exit 1
|
||||
fi
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
|
||||
cargo build --release --locked "$@"
|
||||
version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml)
|
||||
test -n "$version"
|
||||
bundle="$(pwd)/target/release/Tessera.app"
|
||||
mkdir -p "$bundle/Contents/MacOS"
|
||||
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
|
||||
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
|
||||
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
|
||||
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
|
||||
cat > "$bundle/Contents/Info.plist.new" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const tools = process.env.UML_RENDER_TOOLS;
|
||||
// 工具依赖放在独立目录,避免给应用运行依赖增加文档渲染包。
|
||||
if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
|
||||
const require = createRequire(path.join(path.resolve(tools), 'package.json'));
|
||||
const { createCanvas } = require('@napi-rs/canvas');
|
||||
@@ -23,6 +24,7 @@ const document = new DOMImplementation().createDocument(null, 'root', null);
|
||||
const element = document.createElement.bind(document);
|
||||
const elementNS = document.createElementNS.bind(document);
|
||||
function measurementCanvas() {
|
||||
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
|
||||
const canvas = createCanvas(1, 1);
|
||||
const context = canvas.getContext('2d');
|
||||
const measure = context.measureText.bind(context);
|
||||
@@ -41,6 +43,7 @@ function measurementCanvas() {
|
||||
}
|
||||
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
|
||||
document.createElementNS = (namespace, tag) => {
|
||||
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
|
||||
const node = elementNS(namespace, tag);
|
||||
node.style = {};
|
||||
node.getBBox = () => {
|
||||
@@ -65,6 +68,7 @@ const source = path.join(root, 'docs/architecture/uml');
|
||||
const output = path.join(source, 'rendered');
|
||||
await mkdir(output, { recursive: true });
|
||||
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
|
||||
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
|
||||
const text = await readFile(path.join(source, filename), 'utf8');
|
||||
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
|
||||
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
|
||||
@@ -77,6 +81,7 @@ for (const filename of (await readdir(source)).filter(name => name.endsWith('.pu
|
||||
for (const [key, value] of Object.entries({ x: '0', y: '0', width: '100%', height: '100%', fill: '#FFFFFF' })) paper.setAttribute(key, value);
|
||||
xml.documentElement.insertBefore(paper, xml.documentElement.firstChild);
|
||||
const svg = new XMLSerializer().serializeToString(xml);
|
||||
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
|
||||
const stem = filename.slice(0, -5);
|
||||
await writeFile(path.join(output, `${stem}.svg`), svg);
|
||||
// High-resolution PNG is the universally visible Markdown preview; SVG keeps zoomable text.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run only on the Linux CI host with Docker; never bake source into cache images.
|
||||
set -eu
|
||||
scope=${CI_CACHE_SCOPE:-manual}
|
||||
case "$scope" in *[!a-zA-Z0-9_-]*|'') echo "Invalid cache scope" >&2; exit 1;; esac
|
||||
root=$(pwd -P)
|
||||
image=endorphin-tessera-gui-ci:rust-1.91.1
|
||||
prefix=tessera-ci-rust-1-91-1-$scope
|
||||
# Serialize access to each target directory, including manually started checks.
|
||||
exec 9>"/tmp/$prefix.lock"
|
||||
flock 9
|
||||
sh -n scripts/build-macos-app.sh
|
||||
# Empty context: only the toolchain and OS dependencies become image layers.
|
||||
docker build -t "$image" - < Dockerfile.ci
|
||||
name="$prefix-${GITHUB_RUN_ID:-$$}"
|
||||
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
docker run --name "$name" --rm --init \
|
||||
--mount "type=bind,src=$root,dst=/build,readonly" \
|
||||
--mount "type=volume,src=$prefix-target,dst=/cargo-target" \
|
||||
--mount "type=volume,src=$prefix-registry,dst=/usr/local/cargo/registry" \
|
||||
--mount "type=volume,src=$prefix-git,dst=/usr/local/cargo/git" \
|
||||
"$image" &
|
||||
run_pid=$!
|
||||
wait "$run_pid"
|
||||
@@ -1,5 +1,7 @@
|
||||
# Tessera 源码导航
|
||||
|
||||
本体代码已补充中文模块、类型、函数与关键流程注释;建议按[注释阅读索引和逐文件说明](../docs/code-comments.md)从启动、运行时发布和视觉协议依次阅读。
|
||||
|
||||
整体设计和当前进展从 [架构导览](../docs/architecture/README.md) 开始;本轮每个文件的迁移对应见 [逐文件清单](../docs/architecture/file-map.md)。
|
||||
|
||||
`main.rs` 只负责组合启动,并保留 crate 私有模块路径别名。五组目录仍在同一个 binary crate 中,不是严格单向分层。
|
||||
@@ -23,7 +25,8 @@
|
||||
| [native_chrome](shell/native_chrome.rs) | macOS 原生按钮对齐和工具栏双击系统动作 |
|
||||
| [sources](shell/sources.rs) | 默认断开的云文件 provider 接口及工作区 |
|
||||
| [system_ui](shell/system_ui/mod.rs) | 独立宿主管理控件、交互和主题 |
|
||||
| [tabs](shell/tabs.rs) | 共享标签绘制/命中、密集布局、滚动、拖放及动画 |
|
||||
| [tabs](shell/tabs.rs) | 页面注册表的不可变 ID、标题和当前选择投影 |
|
||||
| [sidebar](shell/sidebar.rs) | 页面/开发工具入口、收起状态与最小宿主控制区 |
|
||||
|
||||
## runtime
|
||||
|
||||
@@ -60,7 +63,8 @@
|
||||
| [geometry](render/geometry.rs) | 点和矩形值 |
|
||||
| [layout](render/layout.rs) | 原生测量、排列、出现几何及预算 |
|
||||
| [native_visual](render/native_visual.rs) | 托管原生视觉协议、值转换和签名检查 |
|
||||
| [scene](render/scene.rs) | 页面、标签、插入标记和开发工具画面合成 |
|
||||
| [scene](render/scene.rs) | 页面视角、可见裁剪、侧栏和开发工具画面合成 |
|
||||
| [page](render/page.rs) | 四种页面空间、约束、平移缩放及正逆坐标变换 |
|
||||
| [scroll](render/scroll.rs) | 逐 Mount 偏移、嵌套滚动和剪裁投影 |
|
||||
| [software_renderer](render/software_renderer.rs) | 软件光栅化、抗锯齿覆盖和嵌套剪裁 |
|
||||
| [source_hit](render/source_hit.rs) | 源/原生命中协议与匹配目标 |
|
||||
@@ -79,8 +83,9 @@
|
||||
- HTTP 路由:`runtime/api.rs`;worker:`runtime/api/worker.rs`;在线指南:`runtime/api/guide.rs`。
|
||||
- Bridge 安装:`bridge/ui_bridge/bootstrap.rs`;Block 调用:`calls.rs`;签名:`signatures.rs`;不可变树:`tree.rs`。
|
||||
- 默认页:`bridge/ui_bridge/start_page.endor`;Console/Sources/Network/Settings:`shell/developer_tools/`。
|
||||
- 页面模式与宿主边界见 [页面空间说明](../docs/page-surfaces.md);相机输入:`bridge/ui_bridge/page_input.rs`。
|
||||
- 现有 API 和宿主测试:`runtime/api/tests.rs`、`runtime/endor_host/tests.rs`;其他模块中也保留原单元测试。
|
||||
|
||||
VM 和托管根只由执行 worker 操作。原生窗口收到已发布值,不收到 ObjectRef。完整 PUT 成功才接替活跃程序;Console 保留实际 VM;失败和清理语义见架构文档。
|
||||
|
||||
本机遵循既有检查约定:格式、离线 Clippy、原生 release 构建和人工运行检查;不执行 cargo test、自动 smoke 脚本或 Docker。已有远程检查脚本保留原位置,此次不运行或追逐 CI。
|
||||
当前开发与验证在本机使用原生 Rust 工具链完成:格式、现有测试、Clippy、原生构建和人工运行检查。CI/CD 工作流与 Docker 检查入口已移除;具体命令见 [项目验证说明](../README.md#validation)。历史验收记录只代表当时实际执行的检查。
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
//! General retained graphics resources for source paint: images, fonts, prepared
|
||||
//! text, geometric exclusions and numerical functions. No application behaviour.
|
||||
use crate::{
|
||||
document::{RenderCommand, parse_color},
|
||||
geometry::{Point, Rect},
|
||||
render::{
|
||||
image::Image,
|
||||
text_flow::{Cursor, Prepared},
|
||||
},
|
||||
text::{TextFamily, TextStyle},
|
||||
};
|
||||
use endor_core::{
|
||||
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
|
||||
ObjectStore, Value, Vm, VmError,
|
||||
};
|
||||
use std::{any::Any, sync::Arc};
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
fn object(v: &Value) -> Result<ObjectRef, VmError> {
|
||||
if let Value::Object(o) = v {
|
||||
Ok(*o)
|
||||
} else {
|
||||
Err(invalid("expected Object"))
|
||||
}
|
||||
}
|
||||
fn number(v: &Value) -> Result<f32, VmError> {
|
||||
let Value::Number(v) = v else {
|
||||
return Err(invalid("expected Number"));
|
||||
};
|
||||
let n = v.to_f64()?;
|
||||
if n.abs() > 1_000_000. {
|
||||
return Err(invalid("graphics number exceeds bounds"));
|
||||
}
|
||||
Ok(n as f32)
|
||||
}
|
||||
fn text(v: &Value) -> Result<&str, VmError> {
|
||||
if let Value::Text(s) = v {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(invalid("expected String"))
|
||||
}
|
||||
}
|
||||
fn n(v: f32) -> Value {
|
||||
Value::Number((v as f64).into())
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ImageResource(pub Arc<Image>);
|
||||
#[derive(Debug)]
|
||||
struct FontResource(TextFamily);
|
||||
#[derive(Debug)]
|
||||
struct TextResource {
|
||||
prepared: Arc<Prepared>,
|
||||
line: f32,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Exclusion {
|
||||
circle: Option<(Point, f32)>,
|
||||
polygon: Vec<Point>,
|
||||
padding: f32,
|
||||
}
|
||||
macro_rules! resource {
|
||||
($t:ty,$name:literal) => {
|
||||
impl ObjectPayload for $t {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
$name
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
resource!(ImageResource, "image-resource");
|
||||
resource!(FontResource, "font-resource");
|
||||
resource!(TextResource, "prepared-text");
|
||||
resource!(Exclusion, "layout-exclusion");
|
||||
fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> {
|
||||
crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop)
|
||||
}
|
||||
fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
|
||||
use std::io::Read;
|
||||
if path.len() > 8192 {
|
||||
return Err(invalid("resource path too long"));
|
||||
}
|
||||
let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?;
|
||||
let meta = f
|
||||
.metadata()
|
||||
.map_err(|_| invalid("resource metadata unavailable"))?;
|
||||
if !meta.is_file() || meta.len() > limit {
|
||||
return Err(invalid("resource must be a bounded regular file"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
f.take(limit + 1)
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|_| invalid("resource read failed"))?;
|
||||
if data.len() as u64 > limit {
|
||||
return Err(invalid("resource exceeds byte limit"));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result<f32, VmError> {
|
||||
number(&s.get_attr(o, key)?)
|
||||
}
|
||||
pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderCommand, VmError> {
|
||||
let resource = object(&s.get_attr(c, "image")?)?;
|
||||
let image = s
|
||||
.payload::<ImageResource>(resource, "ImageResource")?
|
||||
.0
|
||||
.clone();
|
||||
let tint = match s.get_attr(c, "tint") {
|
||||
Ok(Value::Text(v)) => Some(parse_color(&v).map_err(|_| invalid("invalid image tint"))?),
|
||||
Ok(Value::Nil) | Err(VmError::AttributeNotFound(_)) => None,
|
||||
_ => return Err(invalid("invalid image tint")),
|
||||
};
|
||||
let c = RenderCommand::Image {
|
||||
x: field(s, c, "x")?,
|
||||
y: field(s, c, "y")?,
|
||||
width: field(s, c, "width")?,
|
||||
height: field(s, c, "height")?,
|
||||
offset_x: field(s, c, "offsetX")?,
|
||||
offset_y: field(s, c, "offsetY")?,
|
||||
angle: field(s, c, "angle")?,
|
||||
opacity: field(s, c, "opacity")?,
|
||||
tint,
|
||||
image,
|
||||
};
|
||||
c.validate()
|
||||
.map_err(|_| invalid("invalid image geometry"))?;
|
||||
Ok(c)
|
||||
}
|
||||
fn command(
|
||||
cx: &mut NativeContext<'_>,
|
||||
desktop: ObjectRef,
|
||||
image: ObjectRef,
|
||||
origin: Point,
|
||||
rect: Rect,
|
||||
angle: f32,
|
||||
opacity: f32,
|
||||
) -> Result<ObjectRef, VmError> {
|
||||
let c = crate::native_visual::record(
|
||||
cx,
|
||||
desktop,
|
||||
"PaintCommand",
|
||||
vec![
|
||||
("kind", Value::Text("image".into())),
|
||||
("image", Value::Object(image)),
|
||||
("x", n(origin.x)),
|
||||
("y", n(origin.y)),
|
||||
("width", n(rect.width)),
|
||||
("height", n(rect.height)),
|
||||
("offsetX", n(rect.x)),
|
||||
("offsetY", n(rect.y)),
|
||||
("angle", n(angle)),
|
||||
("opacity", n(opacity)),
|
||||
],
|
||||
)?;
|
||||
Ok(c)
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Op {
|
||||
LoadImage,
|
||||
LoadFont,
|
||||
ReadText,
|
||||
Slice,
|
||||
Stringify,
|
||||
Floor,
|
||||
Now,
|
||||
Prepare,
|
||||
Sprite,
|
||||
ImageExclusion,
|
||||
Circle,
|
||||
Rectangle,
|
||||
Flow,
|
||||
Float,
|
||||
Sin,
|
||||
Cos,
|
||||
Sqrt,
|
||||
Atan2,
|
||||
Abs,
|
||||
Min,
|
||||
Max,
|
||||
Clamp,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Function {
|
||||
op: Op,
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Function {
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
use Op::*;
|
||||
Some(endor_core::CallArity::exact(match self.op {
|
||||
Now => 0,
|
||||
LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1,
|
||||
LoadFont | Atan2 | Min | Max | ImageExclusion => 2,
|
||||
Circle | Clamp | Slice => 3,
|
||||
Rectangle => 4,
|
||||
Prepare => 6,
|
||||
Flow => 7,
|
||||
Sprite => 9,
|
||||
}))
|
||||
}
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
use Op::*;
|
||||
let desktop = self.desktop;
|
||||
let result = match self.op {
|
||||
Now => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid(
|
||||
"sample the clock in an event/task and store it in the model",
|
||||
));
|
||||
}
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
Value::Number(
|
||||
START
|
||||
.get_or_init(std::time::Instant::now)
|
||||
.elapsed()
|
||||
.as_secs_f64()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
LoadImage => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid("load resources outside paint/layout"));
|
||||
}
|
||||
let image = Image::png(&file(text(&args[0])?, 4_194_304)?)
|
||||
.map_err(|_| invalid("invalid or oversized PNG"))?;
|
||||
let (w, h) = (image.width, image.height);
|
||||
let resource = cx.allocate(ImageResource(image))?;
|
||||
attach(cx, resource, desktop)?;
|
||||
cx.set_attr(resource, "width", n(w as f32))?;
|
||||
cx.set_attr(resource, "height", n(h as f32))?;
|
||||
Value::Object(resource)
|
||||
}
|
||||
LoadFont => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid("load resources outside paint/layout"));
|
||||
}
|
||||
let family = text(&args[1])?;
|
||||
if family.len() > 128 {
|
||||
return Err(invalid("font family name too long"));
|
||||
}
|
||||
let font = crate::text::register_font(file(text(&args[0])?, 4_194_304)?, family)
|
||||
.map_err(|_| invalid("invalid font family or registry budget"))?;
|
||||
let resource = cx.allocate(FontResource(font))?;
|
||||
attach(cx, resource, desktop)?;
|
||||
Value::Object(resource)
|
||||
}
|
||||
ReadText => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid("load resources outside paint/layout"));
|
||||
}
|
||||
Value::Text(
|
||||
String::from_utf8(file(text(&args[0])?, 16_384)?)
|
||||
.map_err(|_| invalid("resource is not UTF-8"))?,
|
||||
)
|
||||
}
|
||||
Slice => {
|
||||
let value = text(&args[0])?;
|
||||
let start = number(&args[1])?;
|
||||
let end = number(&args[2])?;
|
||||
if start < 0. || end < start || start.fract() != 0. || end.fract() != 0. {
|
||||
return Err(invalid("invalid text slice"));
|
||||
}
|
||||
let lo = value
|
||||
.char_indices()
|
||||
.nth(start as usize)
|
||||
.map_or(value.len(), |(i, _)| i);
|
||||
let hi = value
|
||||
.char_indices()
|
||||
.nth(end as usize)
|
||||
.map_or(value.len(), |(i, _)| i);
|
||||
Value::Text(value[lo..hi].into())
|
||||
}
|
||||
Stringify => Value::Text(match &args[0] {
|
||||
Value::Text(s) => s.clone(),
|
||||
Value::Number(v) => v.to_string(),
|
||||
Value::Bool(v) => v.to_string(),
|
||||
Value::Nil => "nil".into(),
|
||||
_ => return Err(invalid("string expects scalar value")),
|
||||
}),
|
||||
Prepare => {
|
||||
let value = text(&args[0])?;
|
||||
if value.len() > 16_384 {
|
||||
return Err(invalid("prepared text exceeds 16 KiB"));
|
||||
}
|
||||
let family = cx
|
||||
.store()
|
||||
.payload::<FontResource>(object(&args[1])?, "FontResource")?
|
||||
.0;
|
||||
let size = number(&args[2])?;
|
||||
let line = number(&args[3])?;
|
||||
let scale = number(&args[5])?;
|
||||
if !(8. ..=96.).contains(&size)
|
||||
|| !(size..=192.).contains(&line)
|
||||
|| !(0.5..=4.).contains(&scale)
|
||||
{
|
||||
return Err(invalid("invalid text metrics"));
|
||||
}
|
||||
let color =
|
||||
parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?;
|
||||
let prepared = Prepared::new(
|
||||
value,
|
||||
TextStyle {
|
||||
font_size: size,
|
||||
line_height: line,
|
||||
family,
|
||||
},
|
||||
scale,
|
||||
color,
|
||||
);
|
||||
let prepared = prepared.map_err(invalid)?;
|
||||
let resource = cx.allocate(TextResource {
|
||||
prepared: Arc::new(prepared),
|
||||
line,
|
||||
})?;
|
||||
attach(cx, resource, desktop)?;
|
||||
Value::Object(resource)
|
||||
}
|
||||
Sprite => {
|
||||
let image = object(&args[0])?;
|
||||
cx.store()
|
||||
.payload::<ImageResource>(image, "ImageResource")?;
|
||||
let x = number(&args[1])?;
|
||||
let y = number(&args[2])?;
|
||||
let w = number(&args[3])?;
|
||||
let h = number(&args[4])?;
|
||||
let a = number(&args[5])?;
|
||||
let ax = number(&args[6])?;
|
||||
let ay = number(&args[7])?;
|
||||
let opacity = number(&args[8])?;
|
||||
let c = command(
|
||||
cx,
|
||||
desktop,
|
||||
image,
|
||||
Point::new(x, y),
|
||||
Rect::new(-w * ax, -h * ay, w, h),
|
||||
a,
|
||||
opacity,
|
||||
)?;
|
||||
decode_image(cx.store(), c)?;
|
||||
Value::Object(c)
|
||||
}
|
||||
ImageExclusion => {
|
||||
let RenderCommand::Image {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
offset_x,
|
||||
offset_y,
|
||||
angle,
|
||||
image,
|
||||
..
|
||||
} = decode_image(cx.store(), object(&args[0])?)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let padding = number(&args[1])?;
|
||||
if !(0. ..=1000.).contains(&padding) {
|
||||
return Err(invalid("invalid exclusion padding"));
|
||||
}
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let polygon = image
|
||||
.outline
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let px = offset_x + p.x / image.width as f32 * width;
|
||||
let py = offset_y + p.y / image.height as f32 * height;
|
||||
Point::new(x + cos * px - sin * py, y + sin * px + cos * py)
|
||||
})
|
||||
.collect();
|
||||
let e = cx.allocate(Exclusion {
|
||||
circle: None,
|
||||
polygon,
|
||||
padding,
|
||||
})?;
|
||||
attach(cx, e, desktop)?;
|
||||
Value::Object(e)
|
||||
}
|
||||
Circle | Rectangle => {
|
||||
let x = number(&args[0])?;
|
||||
let y = number(&args[1])?;
|
||||
let size = number(&args[2])?;
|
||||
if size < 0. {
|
||||
return Err(invalid("negative exclusion extent"));
|
||||
}
|
||||
let e = if matches!(self.op, Circle) {
|
||||
Exclusion {
|
||||
circle: Some((Point::new(x, y), size)),
|
||||
polygon: vec![],
|
||||
padding: 0.,
|
||||
}
|
||||
} else {
|
||||
let h = number(&args[3])?;
|
||||
if h < 0. {
|
||||
return Err(invalid("negative exclusion height"));
|
||||
}
|
||||
Exclusion {
|
||||
circle: None,
|
||||
polygon: vec![
|
||||
Point::new(x, y),
|
||||
Point::new(x + size, y),
|
||||
Point::new(x + size, y + h),
|
||||
Point::new(x, y + h),
|
||||
],
|
||||
padding: 0.,
|
||||
}
|
||||
};
|
||||
let e = cx.allocate(e)?;
|
||||
attach(cx, e, desktop)?;
|
||||
Value::Object(e)
|
||||
}
|
||||
Flow => flow(cx, desktop, args)?,
|
||||
_ => {
|
||||
let real = |v: &Value| -> Result<f64, VmError> {
|
||||
if let Value::Number(n) = v {
|
||||
n.to_f64()
|
||||
} else {
|
||||
Err(invalid("math expects Number"))
|
||||
}
|
||||
};
|
||||
let x = real(&args[0])?;
|
||||
if matches!(self.op, Floor) {
|
||||
if x.abs() > 1_000_000_000. {
|
||||
return Err(invalid("floor result exceeds integer conversion budget"));
|
||||
}
|
||||
return Ok(NativeResult::Return(Value::Number(
|
||||
(x.floor() as i64).into(),
|
||||
)));
|
||||
}
|
||||
let v = match self.op {
|
||||
Float => x,
|
||||
Sin => x.sin(),
|
||||
Cos => x.cos(),
|
||||
Sqrt => x.sqrt(),
|
||||
Atan2 => x.atan2(real(&args[1])?),
|
||||
Abs => x.abs(),
|
||||
Min => x.min(real(&args[1])?),
|
||||
Max => x.max(real(&args[1])?),
|
||||
Clamp => {
|
||||
let lo = real(&args[1])?;
|
||||
let hi = real(&args[2])?;
|
||||
if lo > hi {
|
||||
return Err(invalid("clamp minimum exceeds maximum"));
|
||||
}
|
||||
x.clamp(lo, hi)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !v.is_finite() {
|
||||
return Err(invalid("non-finite math result"));
|
||||
}
|
||||
Value::Number(v.into())
|
||||
}
|
||||
};
|
||||
Ok(NativeResult::Return(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> {
|
||||
let mut blocked = Vec::new();
|
||||
for e in exclusions {
|
||||
if let Some((p, r)) = e.circle {
|
||||
let gap = (y - p.y).max(p.y - y - line).max(0.);
|
||||
if gap < r {
|
||||
let dx = (r * r - gap * gap).sqrt();
|
||||
blocked.push((p.x - dx, p.x + dx));
|
||||
}
|
||||
}
|
||||
let (top, bottom) = (y - e.padding, y + line + e.padding);
|
||||
let mut xs = Vec::new();
|
||||
for i in 0..e.polygon.len() {
|
||||
let a = e.polygon[i];
|
||||
let b = e.polygon[(i + 1) % e.polygon.len()];
|
||||
if a.y >= top && a.y <= bottom {
|
||||
xs.push(a.x);
|
||||
}
|
||||
for edge in [top, bottom] {
|
||||
if (a.y < edge && b.y > edge) || (b.y < edge && a.y > edge) {
|
||||
xs.push(a.x + (b.x - a.x) * (edge - a.y) / (b.y - a.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !xs.is_empty() {
|
||||
blocked.push((
|
||||
xs.iter().copied().fold(f32::INFINITY, f32::min) - e.padding,
|
||||
xs.iter().copied().fold(f32::NEG_INFINITY, f32::max) + e.padding,
|
||||
));
|
||||
}
|
||||
}
|
||||
blocked.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut x = left;
|
||||
let mut ranges = Vec::new();
|
||||
for (lo, hi) in blocked {
|
||||
if hi <= left || lo >= right {
|
||||
continue;
|
||||
}
|
||||
if lo > x {
|
||||
ranges.push((x, lo.min(right)));
|
||||
}
|
||||
x = x.max(hi).min(right);
|
||||
}
|
||||
if x < right {
|
||||
ranges.push((x, right));
|
||||
}
|
||||
ranges.into_iter().filter(|(l, r)| r - l >= 8.).collect()
|
||||
}
|
||||
fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result<Value, VmError> {
|
||||
let resource = object(&args[0])?;
|
||||
let text = cx
|
||||
.store()
|
||||
.payload::<TextResource>(resource, "PreparedText")?;
|
||||
let prepared = text.prepared.clone();
|
||||
let line = text.line;
|
||||
let x = number(&args[1])?;
|
||||
let y = number(&args[2])?;
|
||||
let width = number(&args[3])?;
|
||||
let height = number(&args[4])?;
|
||||
let letters = number(&args[6])? != 0.;
|
||||
if width < 0. || height < 0. || height / line > 1024. {
|
||||
return Err(invalid("text flow bounds exceed budget"));
|
||||
}
|
||||
let items = crate::object_list::items(cx.store(), object(&args[5])?)?;
|
||||
if items.len() > 512 {
|
||||
return Err(invalid("text flow exceeds 512 exclusions"));
|
||||
}
|
||||
let exclusions = items
|
||||
.iter()
|
||||
.map(|&o| cx.store().payload::<Exclusion>(o, "Exclusion"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut placements = Vec::new();
|
||||
let mut cursor = Cursor::default();
|
||||
let mut top = y;
|
||||
while top + line <= y + height && cursor.word < prepared.words.len() {
|
||||
let intervals = ranges(x, x + width, top, line, &exclusions);
|
||||
prepared.row(&mut cursor, &intervals, top, &mut placements);
|
||||
top += line;
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
for p in placements {
|
||||
let word = &prepared.words[p.word];
|
||||
let inks = if let Some(i) = p.letter {
|
||||
std::slice::from_ref(&word.letters[i])
|
||||
} else if letters {
|
||||
&word.letters[..]
|
||||
} else {
|
||||
std::slice::from_ref(&word.ink)
|
||||
};
|
||||
let mut px = p.x;
|
||||
for ink in inks {
|
||||
let image = cx.allocate(ImageResource(ink.image.clone()))?;
|
||||
attach(cx, image, desktop)?;
|
||||
commands.push(command(
|
||||
cx,
|
||||
desktop,
|
||||
image,
|
||||
Point::new(px, p.y),
|
||||
ink.rect,
|
||||
0.,
|
||||
1.,
|
||||
)?);
|
||||
px += ink.width;
|
||||
if commands.len() > 10_000 {
|
||||
return Err(invalid("text flow command budget exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let commands = crate::object_list::collection(cx, commands, desktop)?;
|
||||
Ok(Value::Object(crate::native_visual::record(
|
||||
cx,
|
||||
desktop,
|
||||
"DisplayList",
|
||||
vec![("commands", Value::Object(commands))],
|
||||
)?))
|
||||
}
|
||||
pub(crate) fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
ui: ObjectRef,
|
||||
desktop: ObjectRef,
|
||||
) -> Result<(), VmError> {
|
||||
let graphics = vm.new_object()?;
|
||||
roots.push(graphics);
|
||||
attach_vm(vm, graphics, "Object", desktop)?;
|
||||
let math = vm.new_object()?;
|
||||
roots.push(math);
|
||||
attach_vm(vm, math, "Object", desktop)?;
|
||||
for (owner, name, op) in [
|
||||
(graphics, "loadImage", Op::LoadImage),
|
||||
(graphics, "loadFont", Op::LoadFont),
|
||||
(graphics, "readText", Op::ReadText),
|
||||
(graphics, "textSlice", Op::Slice),
|
||||
(graphics, "string", Op::Stringify),
|
||||
(math, "floor", Op::Floor),
|
||||
(math, "now", Op::Now),
|
||||
(graphics, "prepareText", Op::Prepare),
|
||||
(graphics, "sprite", Op::Sprite),
|
||||
(graphics, "imageExclusion", Op::ImageExclusion),
|
||||
(graphics, "circleExclusion", Op::Circle),
|
||||
(graphics, "rectExclusion", Op::Rectangle),
|
||||
(graphics, "flowText", Op::Flow),
|
||||
(math, "float", Op::Float),
|
||||
(math, "sin", Op::Sin),
|
||||
(math, "cos", Op::Cos),
|
||||
(math, "sqrt", Op::Sqrt),
|
||||
(math, "atan2", Op::Atan2),
|
||||
(math, "abs", Op::Abs),
|
||||
(math, "min", Op::Min),
|
||||
(math, "max", Op::Max),
|
||||
(math, "clamp", Op::Clamp),
|
||||
] {
|
||||
let f = vm.allocate(ContextNativeFunction::new(
|
||||
name,
|
||||
Function { op, desktop },
|
||||
vec![desktop],
|
||||
))?;
|
||||
roots.push(f);
|
||||
attach_vm(vm, f, "Function", desktop)?;
|
||||
vm.set_attr(owner, name, Value::Object(f))?;
|
||||
}
|
||||
vm.set_attr(math, "pi", Value::Number(std::f64::consts::PI.into()))?;
|
||||
vm.set_attr(graphics, "math", Value::Object(math))?;
|
||||
vm.set_attr(ui, "graphics", Value::Object(graphics))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> {
|
||||
let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?;
|
||||
vm.attach_type(o, t)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PointerDispatch {
|
||||
pub owner: ObjectRef,
|
||||
pub mount: ObjectRef,
|
||||
pub desktop: ObjectRef,
|
||||
pub kind: String,
|
||||
pub point: Point,
|
||||
}
|
||||
impl NativeCallable for PointerDispatch {
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let data = cx
|
||||
.store()
|
||||
.payload::<crate::mount::Mount>(self.mount, "Mount")?;
|
||||
let g = &data.geometry;
|
||||
if !data.mounted || !g.pointer_events {
|
||||
return Err(invalid(
|
||||
"pointer target is detached or has no pointer subscriptions",
|
||||
));
|
||||
}
|
||||
let local = g
|
||||
.view_point(self.point, false)
|
||||
.map(|p| g.view.unwrap_or_default().inverse(p))
|
||||
.ok_or(invalid("pointer cannot be projected"))?;
|
||||
let (x, y) = (local.x - g.x, local.y - g.y);
|
||||
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
|
||||
let event = crate::event_dispatch::new_event(cx, definition, &self.kind)?;
|
||||
cx.set_attr(event, "x", n(x))?;
|
||||
cx.set_attr(event, "y", n(y))?;
|
||||
crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod ui_bridge;
|
||||
|
||||
pub(crate) mod graphics;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! Endor 与 Tessera 的展示事务协调层:维护每个窗口的 Page、Mount、布局缓存和发布版本。
|
||||
//! Session 保存托管对象的展示上下文;跨线程发布的是不可变 UiNode/Frame,而不是可以在窗口线程执行的 VM。
|
||||
//! 刷新按 prepare → layout → paint → commit 推进。挂起工作必须恢复或显式取消;失败不能把未完成的几何和生命周期状态留作已发布结果。
|
||||
//!
|
||||
//! Tessera's native visual protocols over Aster's managed object graph.
|
||||
mod bootstrap;
|
||||
mod calls;
|
||||
mod developer_pages;
|
||||
mod page_input;
|
||||
mod signatures;
|
||||
mod tree;
|
||||
|
||||
@@ -37,11 +42,31 @@ pub const TYPE_NAMES: &[&str] = &[
|
||||
"Block",
|
||||
"ContainerBlock",
|
||||
"ScrollBlock",
|
||||
"ScrollView",
|
||||
"TextBlock",
|
||||
"RectBlock",
|
||||
"Spacer",
|
||||
"Row",
|
||||
"Column",
|
||||
"Overlay",
|
||||
"Scene2D",
|
||||
"CanvasViewport",
|
||||
"Camera2D",
|
||||
"RayHit3D",
|
||||
"Panel3D",
|
||||
"CameraGesture",
|
||||
"Vec3",
|
||||
"Triangle3D",
|
||||
"Geometry3D",
|
||||
"Mesh3D",
|
||||
"Box3D",
|
||||
"Group3D",
|
||||
"Scene3D",
|
||||
"DirectionalLight",
|
||||
"PerspectiveCamera",
|
||||
"OrthographicCamera",
|
||||
"OrbitController",
|
||||
"Viewport3D",
|
||||
"Button",
|
||||
"Page",
|
||||
"Window",
|
||||
@@ -69,6 +94,7 @@ pub const QUERY_NAMES: &[&str] = &[
|
||||
pub use tree::{UiKind, UiNode};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 整个 VM 的桥接会话。节点索引跨窗口共享,presentations 按窗口隔离派生状态;current 决定方法当前访问哪份展示上下文。
|
||||
struct Session {
|
||||
// Weak visual-object registry belongs to the VM, not a particular window.
|
||||
nodes: Vec<WeakObjectRef>,
|
||||
@@ -88,6 +114,7 @@ struct Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// 收集所有窗口的私有 desktop 身份,用于排除宿主记账读取造成的无关布局失效。
|
||||
fn host_desktops(&self) -> std::collections::HashSet<ObjectRef> {
|
||||
self.presentations
|
||||
.values()
|
||||
@@ -98,6 +125,7 @@ impl Session {
|
||||
|
||||
impl std::ops::Deref for Session {
|
||||
type Target = crate::presentation::Presentation;
|
||||
/// 将包装类型的只读访问转交给内部当前上下文。
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.presentations
|
||||
.get(&self.current)
|
||||
@@ -105,6 +133,7 @@ impl std::ops::Deref for Session {
|
||||
}
|
||||
}
|
||||
impl std::ops::DerefMut for Session {
|
||||
/// 将包装类型的可变访问转交给内部当前上下文。
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.presentations
|
||||
.get_mut(&self.current)
|
||||
@@ -114,6 +143,7 @@ impl std::ops::DerefMut for Session {
|
||||
type Shared = Arc<Mutex<Session>>;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// ui/组件宿主入口的操作枚举;统一由调用适配器分派,不直接代表已经发生的业务变更。
|
||||
enum Operation {
|
||||
IsInstance,
|
||||
Implement,
|
||||
@@ -142,6 +172,15 @@ enum Operation {
|
||||
Scroll,
|
||||
Row,
|
||||
Column,
|
||||
Overlay,
|
||||
Scene2D,
|
||||
CanvasViewport,
|
||||
Camera2D,
|
||||
SceneObject(crate::scene3d_model::Constructor),
|
||||
Viewport3D,
|
||||
CameraControl,
|
||||
Translate3D,
|
||||
Rotate3D,
|
||||
Button,
|
||||
Page,
|
||||
SetText,
|
||||
@@ -172,6 +211,7 @@ enum Operation {
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(self) -> endor_core::CallArity {
|
||||
use Operation::*;
|
||||
let (minimum, maximum) = match self {
|
||||
@@ -181,11 +221,18 @@ impl Operation {
|
||||
Increment => (0, 0),
|
||||
TextOf => (2, 4),
|
||||
Scroll => (3, 3),
|
||||
CanvasViewport => (3, 3),
|
||||
Viewport3D => (3, 3),
|
||||
SceneObject(kind) => kind.arity(),
|
||||
CameraControl => (2, 2),
|
||||
Translate3D | Rotate3D => (3, 3),
|
||||
Camera2D => (0, 3),
|
||||
Scene2D => (0, MAX_UI_NODES),
|
||||
Text => (1, 3),
|
||||
Rect => (2, 4),
|
||||
MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove
|
||||
| Render | Navigate | OnClick | Present => (1, 1),
|
||||
Row | Column => (2, MAX_UI_NODES + 2),
|
||||
Row | Column | Overlay => (2, MAX_UI_NODES + 2),
|
||||
Button => (1, 1),
|
||||
Insert | Replace | Move => (2, 2),
|
||||
Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow
|
||||
@@ -201,6 +248,7 @@ impl Operation {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 将一个宿主 Operation 及会话环境封装成 VM 可调用对象。
|
||||
struct Function {
|
||||
signature: Option<CallSignature>,
|
||||
operation: Operation,
|
||||
@@ -212,6 +260,7 @@ struct Function {
|
||||
/// One shared implementation on the nominal type. The VM binds receivers;
|
||||
/// individual Blocks no longer allocate owner-capturing method closures.
|
||||
#[derive(Debug)]
|
||||
/// 类型查询/能力方法的原生包装,保存调用所需的声明与会话。
|
||||
struct TypeMethod {
|
||||
signature: CallSignature,
|
||||
operation: Operation,
|
||||
@@ -219,6 +268,7 @@ struct TypeMethod {
|
||||
session: Shared,
|
||||
}
|
||||
|
||||
/// 判断方法是否仍为宿主提供的原生事件实现,区分它与用户源覆盖。
|
||||
pub(crate) fn is_intrinsic_event(
|
||||
store: &endor_core::ObjectStore,
|
||||
function: ObjectRef,
|
||||
@@ -237,9 +287,11 @@ pub(crate) fn is_intrinsic_event(
|
||||
}
|
||||
|
||||
impl NativeCallable for TypeMethod {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
let arity = self.operation.arity();
|
||||
Some(endor_core::CallArity::range(
|
||||
@@ -248,6 +300,7 @@ impl NativeCallable for TypeMethod {
|
||||
))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let owner = object(args.first())?;
|
||||
if !cx.store().is_instance(owner, self.expected)? {
|
||||
@@ -267,6 +320,7 @@ impl NativeCallable for TypeMethod {
|
||||
/// The Type dispatcher passes the requested subclass explicitly. Native
|
||||
/// constructor options precede the subclass's inherited/own field arguments.
|
||||
#[derive(Debug)]
|
||||
/// 为源代码调用原生类型构造器提供受检查的分配入口。
|
||||
struct NativeFactory {
|
||||
signature: Option<CallSignature>,
|
||||
options: Option<CallSignature>,
|
||||
@@ -275,9 +329,11 @@ struct NativeFactory {
|
||||
}
|
||||
|
||||
impl NativeCallable for NativeFactory {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
self.signature.clone()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::range(
|
||||
self.operation.arity().required + 1,
|
||||
@@ -285,6 +341,7 @@ impl NativeCallable for NativeFactory {
|
||||
))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let requested = object(args.first())?;
|
||||
let fields = cx
|
||||
@@ -330,21 +387,25 @@ impl NativeCallable for NativeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
|
||||
fn object(value: Option<&Value>) -> Result<ObjectRef, VmError> {
|
||||
match value {
|
||||
Some(Value::Object(value)) => Ok(*value),
|
||||
_ => Err(invalid("expected an object")),
|
||||
}
|
||||
}
|
||||
/// 把当前源值或文本选择解码成此调用需要的文本表示。
|
||||
fn text(value: Option<&Value>) -> Result<String, VmError> {
|
||||
match value {
|
||||
Some(Value::Text(value)) => Ok(value.clone()),
|
||||
_ => Err(invalid("expected text")),
|
||||
}
|
||||
}
|
||||
/// 读取整数参数,拒绝非整数或无法在目标范围表示的值。
|
||||
fn integer(value: Option<&Value>) -> Result<u64, VmError> {
|
||||
match value {
|
||||
Some(Value::Number(value)) => value
|
||||
@@ -354,6 +415,7 @@ fn integer(value: Option<&Value>) -> Result<u64, VmError> {
|
||||
_ => Err(invalid("expected a nonnegative integer")),
|
||||
}
|
||||
}
|
||||
/// 解析颜色值并检查其满足当前原生绘制约定。
|
||||
fn color(value: Option<&Value>, default: &str) -> Result<String, VmError> {
|
||||
let value = value
|
||||
.map(|v| text(Some(v)))
|
||||
@@ -362,6 +424,7 @@ fn color(value: Option<&Value>, default: &str) -> Result<String, VmError> {
|
||||
parse_color(&value).map_err(|_| invalid("color must use #RRGGBB or #RRGGBBAA"))?;
|
||||
Ok(value)
|
||||
}
|
||||
/// 读取布局尺寸参数,检查非负、有限和最大尺寸限制。
|
||||
fn dimension(value: Option<&Value>) -> Result<f32, VmError> {
|
||||
let Some(Value::Number(number)) = value else {
|
||||
return Err(invalid("expected a finite nonnegative dimension"));
|
||||
@@ -377,6 +440,7 @@ fn dimension(value: Option<&Value>) -> Result<f32, VmError> {
|
||||
Ok(value as f32)
|
||||
}
|
||||
|
||||
/// 为一个托管对象安装绑定到当前会话的原生方法,声明调用名与具体 Operation。
|
||||
fn bind(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -403,6 +467,7 @@ fn bind(
|
||||
cx.set_attr(owner, name, Value::Object(function))
|
||||
}
|
||||
|
||||
/// 使用默认原生类型创建 Block;需要源子类身份的构造走 new_typed_block。
|
||||
fn new_block(
|
||||
cx: &mut NativeContext<'_>,
|
||||
data: Data,
|
||||
@@ -411,6 +476,33 @@ fn new_block(
|
||||
new_typed_block(cx, data, session, None)
|
||||
}
|
||||
|
||||
/// 分配有名义类型和默认位置/缩放的独立相机,供新视口拥有自己的导航状态。
|
||||
fn new_camera2d(
|
||||
cx: &mut NativeContext<'_>,
|
||||
session: &Shared,
|
||||
requested: Option<ObjectRef>,
|
||||
args: &[Value],
|
||||
) -> Result<ObjectRef, VmError> {
|
||||
let desktop = session
|
||||
.lock()
|
||||
.map_err(|_| invalid("UI session poisoned"))?
|
||||
.desktop;
|
||||
let definition = requested.unwrap_or(desktop_type(cx.store(), "Camera2D", desktop)?);
|
||||
let camera = cx.allocate(crate::viewport::Camera2D)?;
|
||||
cx.attach_type(camera, definition)?;
|
||||
for (index, name, fallback) in [(0, "x", 0.0), (1, "y", 0.0), (2, "zoom", 1.0)] {
|
||||
let value = args
|
||||
.get(index)
|
||||
.map(crate::scroll::coordinate)
|
||||
.transpose()?
|
||||
.unwrap_or(fallback);
|
||||
cx.set_attr(camera, name, crate::native_visual::value(value))?;
|
||||
}
|
||||
crate::viewport::camera(cx.store(), camera)?;
|
||||
Ok(camera)
|
||||
}
|
||||
|
||||
/// 创建原生 Block 并附加请求的 Endor 类型;先验证继承关系,再登记到会话节点索引。
|
||||
fn new_typed_block(
|
||||
cx: &mut NativeContext<'_>,
|
||||
data: Data,
|
||||
@@ -424,6 +516,10 @@ fn new_typed_block(
|
||||
Kind::Scroll { .. } => "ScrollBlock",
|
||||
Kind::Row => "Row",
|
||||
Kind::Column => "Column",
|
||||
Kind::Overlay => "Overlay",
|
||||
Kind::Scene2D => "Scene2D",
|
||||
Kind::CanvasViewport { .. } => "CanvasViewport",
|
||||
Kind::Viewport3D { .. } => "Viewport3D",
|
||||
Kind::Button => "Button",
|
||||
Kind::Page => "Page",
|
||||
};
|
||||
@@ -462,6 +558,7 @@ fn new_typed_block(
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
/// 在当前 VM 中建立窗口、desktop 和独立 Presentation,实际 OS 窗口等命令发布后再创建。
|
||||
fn create_window(
|
||||
cx: &mut NativeContext<'_>,
|
||||
state: &mut Session,
|
||||
@@ -491,6 +588,7 @@ fn create_window(
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// 从页面登记信息取得所属窗口,保持 Page 的归属与原生资源身份一致。
|
||||
fn page_window(
|
||||
store: &endor_core::ObjectStore,
|
||||
state: &Session,
|
||||
@@ -504,6 +602,7 @@ fn page_window(
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
/// 撤销页面在展示会话中的登记,配合模型层处理活动页面与生命周期。
|
||||
fn remove_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
view: &mut crate::presentation::Presentation,
|
||||
@@ -522,6 +621,7 @@ fn remove_page(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 在指定窗口安排页面展示,并维护目标 Presentation 的活动/待发布状态。
|
||||
fn show_in(
|
||||
cx: &mut NativeContext<'_>,
|
||||
page: ObjectRef,
|
||||
@@ -572,20 +672,24 @@ fn show_in(
|
||||
view.pending = Some(Some(page));
|
||||
Ok(())
|
||||
}
|
||||
/// 在当前窗口安排页面展示,实际几何和画面由后续刷新提交。
|
||||
fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> {
|
||||
show_in(cx, page, session, None)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 接收源 present 结果并将其转换成宿主可展示内容的原生续接步骤。
|
||||
struct PresentResult {
|
||||
open: bool,
|
||||
session: Shared,
|
||||
}
|
||||
impl NativeCallable for PresentResult {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(root)] = args else {
|
||||
return Err(invalid("Presentable.present must return a Block"));
|
||||
@@ -610,6 +714,7 @@ impl NativeCallable for PresentResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 Presentable 能力把任意源值转换成可展示内容,避免只允许某个原生 Block 类型。
|
||||
fn present_value(
|
||||
cx: &mut NativeContext<'_>,
|
||||
value: &Value,
|
||||
@@ -652,12 +757,15 @@ fn present_value(
|
||||
}
|
||||
|
||||
#[must_use = "pending layout must be resumed on its owning VM"]
|
||||
/// 桥接布局的挂起/完成结果;PendingLayout 必须与其窗口上下文一起继续处理。
|
||||
pub enum LayoutProgress {
|
||||
Pending(Box<PendingLayout>),
|
||||
Complete(Box<crate::document::Frame>),
|
||||
}
|
||||
|
||||
/// 一次未提交布局的上下文:目标窗口、根、布局/缓存、延后钩子以及用于失败恢复的检查点。
|
||||
struct LayoutContext {
|
||||
surface: crate::page::Surface,
|
||||
root: Arc<UiNode>,
|
||||
tabs: Vec<crate::tabs::Tab>,
|
||||
full_viewport: Option<crate::layout::Size>,
|
||||
@@ -667,17 +775,20 @@ struct LayoutContext {
|
||||
checkpoint: crate::mount::Checkpoint,
|
||||
}
|
||||
|
||||
/// 挂起布局的拥有者。续体与上下文必须一起保留并最终恢复或取消。
|
||||
pub struct PendingLayout {
|
||||
work: crate::source_layout::Work,
|
||||
context: LayoutContext,
|
||||
}
|
||||
|
||||
/// 运行时持有的桥接能力句柄,通过共享 Session 协调托管对象与每窗口展示。
|
||||
pub struct UiBridge {
|
||||
pub ui: ObjectRef,
|
||||
session: Shared,
|
||||
tasks: crate::tasks::Scheduler,
|
||||
}
|
||||
|
||||
/// 解析对象的方法并在同一 VM 中调用,使源覆盖与原生成员共用方法查找规则。
|
||||
fn invoke_member(
|
||||
vm: &mut Vm,
|
||||
owner: ObjectRef,
|
||||
@@ -703,6 +814,7 @@ fn invoke_member(
|
||||
result
|
||||
}
|
||||
|
||||
/// 将托管页面注册表投影成稳定标签 ID、标题与活动状态,不把托管引用交给原生侧栏。
|
||||
fn project_tabs(
|
||||
vm: &Vm,
|
||||
state: &mut crate::presentation::Presentation,
|
||||
@@ -749,6 +861,7 @@ fn project_tabs(
|
||||
impl UiBridge {
|
||||
/// Detach all occurrences without running layout/paint. All windows are
|
||||
/// already closed before source unmount hooks run, so they cannot remount.
|
||||
/// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。
|
||||
pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> {
|
||||
let tasks = self.tasks.quiesce(vm)?;
|
||||
let mut drivers = Vec::new();
|
||||
@@ -818,16 +931,20 @@ impl UiBridge {
|
||||
result
|
||||
}
|
||||
|
||||
/// 查询任务系统是否仍有可推进的协作工作,供 worker 选择等待策略。
|
||||
pub fn has_cooperative_tasks(&self) -> bool {
|
||||
self.tasks.has_cooperative_work()
|
||||
}
|
||||
|
||||
/// 把当前 VM 交给任务调度器推进,限制一轮交付数量。
|
||||
pub fn poll_tasks(&self, vm: &mut Vm, limit: usize) -> Result<usize> {
|
||||
self.tasks.poll(vm, limit)
|
||||
}
|
||||
/// 取消当前域中的任务,撤销关闭/失败后不应再发生的交付。
|
||||
pub fn cancel_tasks(&self, vm: &mut Vm) -> Result<usize> {
|
||||
self.tasks.cancel_all(vm)
|
||||
}
|
||||
/// 返回当前仍有效的展示窗口身份,用于稳定遍历一次全窗口刷新。
|
||||
pub fn window_ids(&self) -> Result<Vec<u64>> {
|
||||
let state = self
|
||||
.session
|
||||
@@ -850,6 +967,7 @@ impl UiBridge {
|
||||
});
|
||||
Ok(ids)
|
||||
}
|
||||
/// 判断窗口是否仍登记且没有进入关闭状态。
|
||||
pub fn accepts_window(&self, id: u64) -> bool {
|
||||
self.session.lock().is_ok_and(|state| {
|
||||
state
|
||||
@@ -859,6 +977,7 @@ impl UiBridge {
|
||||
})
|
||||
}
|
||||
|
||||
/// 切换当前 Presentation 上下文;不会创建新窗口或复制 VM。
|
||||
pub fn select_window(&self, id: u64) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -870,6 +989,7 @@ impl UiBridge {
|
||||
state.current = id;
|
||||
Ok(())
|
||||
}
|
||||
/// 从托管展示域撤销窗口,退役 Mount 并清理该窗口的私有对象关系。
|
||||
pub fn retire_window(&self, vm: &mut Vm, id: u64) -> Result<()> {
|
||||
if id == crate::window::MAIN_WINDOW {
|
||||
bail!("the primary shell is closed by the OS window control");
|
||||
@@ -897,6 +1017,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 收尾已经关闭的展示资源,避免它们参与下一次窗口遍历。
|
||||
pub fn finish_closed(&self, vm: &mut Vm) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -921,6 +1042,7 @@ impl UiBridge {
|
||||
state.current = crate::window::MAIN_WINDOW;
|
||||
Ok(())
|
||||
}
|
||||
/// 从各托管窗口收集待发布命令;原生执行时还需校验 session。
|
||||
pub fn take_window_commands(&self, vm: &Vm) -> Result<Vec<crate::window::Command>> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -935,6 +1057,7 @@ impl UiBridge {
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
/// 校验并更新所属窗口的逻辑尺寸输入;尺寸不变时不制造新布局,改变时标记需要刷新。
|
||||
pub fn set_viewport(&self, vm: &mut Vm, viewport: Option<crate::layout::Size>) -> Result<bool> {
|
||||
if let Some(size) = viewport
|
||||
&& (!size.width.is_finite()
|
||||
@@ -970,6 +1093,7 @@ impl UiBridge {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 将 ui 入口与兼容名称安装到本次程序的全局字典,保持所有调用处在同一个 VM 域。
|
||||
pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> {
|
||||
vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?;
|
||||
// Compatibility spelling used by the original module examples.
|
||||
@@ -984,6 +1108,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 检查当前会话是否已经安排或拥有可展示页面。
|
||||
pub fn has_page(&self) -> Result<bool> {
|
||||
Ok(self
|
||||
.session
|
||||
@@ -993,6 +1118,7 @@ impl UiBridge {
|
||||
.values()
|
||||
.any(|view| view.pending.flatten().is_some() || view.active.is_some()))
|
||||
}
|
||||
/// 验证标签拖放仍指向有效页面和窗口关系,避免旧拖动操作跨域生效。
|
||||
pub fn accepts_tab_drop(&self, vm: &Vm, input: &crate::events::Input) -> bool {
|
||||
let Some(target) = &input.tab_drop else {
|
||||
return true;
|
||||
@@ -1030,12 +1156,14 @@ impl UiBridge {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// 通过展示协议打开源值,必要时创建或选择承载它的页面。
|
||||
pub fn open_value(&self, vm: &mut Vm, value: &Value) -> Result<()> {
|
||||
let function = object(Some(&vm.get_attr(self.ui, "open")?))?;
|
||||
let page = vm.call(function, std::slice::from_ref(value))?;
|
||||
vm.release_value(page)?;
|
||||
Ok(())
|
||||
}
|
||||
/// 处理页面激活、关闭、移动和拆分等标签操作,保持窗口所有权与展示状态一致。
|
||||
fn dispatch_tab(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
|
||||
let page = {
|
||||
let state = self
|
||||
@@ -1147,6 +1275,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在所属窗口的展示上下文中处理输入,区分页面管理、滚动、焦点、相机与普通事件。
|
||||
pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
|
||||
input
|
||||
.validate()
|
||||
@@ -1189,13 +1318,25 @@ impl UiBridge {
|
||||
vm.release_value(page)?;
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(input.kind.as_str(), "page-pan" | "page-zoom") {
|
||||
return self.dispatch_page_view(vm, input);
|
||||
}
|
||||
if matches!(input.kind.as_str(), "viewport-pan" | "viewport-zoom") {
|
||||
return self.dispatch_viewport_view(vm, input);
|
||||
}
|
||||
if matches!(
|
||||
input.kind.as_str(),
|
||||
"scene-pan" | "scene-orbit" | "scene-zoom"
|
||||
) {
|
||||
return self.dispatch_scene_view(vm, input);
|
||||
}
|
||||
if matches!(
|
||||
input.kind.as_str(),
|
||||
"tab-activate" | "tab-close" | "tab-detach" | "tab-drop"
|
||||
) {
|
||||
return self.dispatch_tab(vm, input);
|
||||
}
|
||||
let (weak, desktop, candidates, viewport) = {
|
||||
let (weak, desktop, candidates, viewport, surface) = {
|
||||
let state = self
|
||||
.session
|
||||
.lock()
|
||||
@@ -1211,13 +1352,8 @@ impl UiBridge {
|
||||
.ok_or_else(|| anyhow::anyhow!("Mount is no longer active"))?,
|
||||
state.desktop,
|
||||
state.mounts.ordered(),
|
||||
state.layout.viewport.map(|size| crate::layout::Size {
|
||||
width: (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0),
|
||||
height: (size.height
|
||||
- crate::tabs::INSET
|
||||
- crate::tabs::height(&state.last_tabs))
|
||||
.max(0.0),
|
||||
}),
|
||||
state.layout.viewport,
|
||||
state.surface.clone(),
|
||||
)
|
||||
};
|
||||
if input.kind == "scroll" {
|
||||
@@ -1231,8 +1367,7 @@ impl UiBridge {
|
||||
.ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?;
|
||||
if !data.mounted
|
||||
|| data.version != input.layout_version
|
||||
|| viewport
|
||||
.is_some_and(|size| point[0] >= size.width || point[1] >= size.height)
|
||||
|| !surface.contains(point, viewport)
|
||||
|| !data
|
||||
.geometry
|
||||
.contains(crate::geometry::Point::new(point[0], point[1]))
|
||||
@@ -1242,6 +1377,9 @@ impl UiBridge {
|
||||
let delta = input
|
||||
.delta
|
||||
.ok_or_else(|| anyhow::anyhow!("scroll has no delta"))?;
|
||||
let Some(delta) = data.geometry.logical_delta(input.point, delta) else {
|
||||
return Ok(());
|
||||
};
|
||||
let method = vm.bind_attr(mount, "scrollBy")?;
|
||||
let result = match method {
|
||||
Value::Object(function) => vm.call_with_limits(
|
||||
@@ -1265,11 +1403,10 @@ impl UiBridge {
|
||||
return result;
|
||||
}
|
||||
let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) {
|
||||
if viewport.is_some_and(|size| {
|
||||
[press, release]
|
||||
.iter()
|
||||
.any(|point| point[0] >= size.width || point[1] >= size.height)
|
||||
}) {
|
||||
if [press, release]
|
||||
.iter()
|
||||
.any(|point| !surface.contains(*point, viewport))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)?
|
||||
@@ -1283,12 +1420,64 @@ impl UiBridge {
|
||||
};
|
||||
let result = (|| {
|
||||
let data = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
|
||||
if data.geometry.scene3d_viewport
|
||||
&& input.kind == "click"
|
||||
&& let Some(point) = input.point
|
||||
{
|
||||
let function = vm.allocate(ContextNativeFunction::new(
|
||||
"3D pointer click",
|
||||
crate::scene3d_model::PointerPick {
|
||||
mount,
|
||||
desktop,
|
||||
point: crate::geometry::Point::new(point[0], point[1]),
|
||||
},
|
||||
vec![mount, desktop],
|
||||
))?;
|
||||
let result = vm.call_with_limits(
|
||||
function,
|
||||
&[],
|
||||
endor_core::ExecutionLimits {
|
||||
max_frames: 128,
|
||||
max_instructions: 1_000_000,
|
||||
},
|
||||
);
|
||||
vm.release(function)?;
|
||||
vm.release_value(result?)?;
|
||||
return Ok(());
|
||||
}
|
||||
if !data.mounted {
|
||||
bail!("event targets an obsolete presentation");
|
||||
}
|
||||
if !data.geometry.interactive {
|
||||
bail!("Mount does not accept input");
|
||||
}
|
||||
if input.kind.starts_with("pointer") {
|
||||
let p = input
|
||||
.point
|
||||
.ok_or_else(|| anyhow::anyhow!("pointer needs point"))?;
|
||||
let f = vm.allocate(ContextNativeFunction::new(
|
||||
"native pointer event",
|
||||
crate::bridge::graphics::PointerDispatch {
|
||||
owner: data.block,
|
||||
mount,
|
||||
desktop,
|
||||
kind: input.kind.clone(),
|
||||
point: crate::geometry::Point::new(p[0], p[1]),
|
||||
},
|
||||
vec![data.block, mount, desktop],
|
||||
))?;
|
||||
let result = vm.call_with_limits(
|
||||
f,
|
||||
&[],
|
||||
endor_core::ExecutionLimits {
|
||||
max_frames: 128,
|
||||
max_instructions: 1_000_000,
|
||||
},
|
||||
);
|
||||
vm.release(f)?;
|
||||
vm.release_value(result?)?;
|
||||
return Ok(());
|
||||
}
|
||||
vm.set_attr(
|
||||
desktop,
|
||||
"focus",
|
||||
@@ -1319,6 +1508,7 @@ impl UiBridge {
|
||||
vm.release(mount)?;
|
||||
result
|
||||
}
|
||||
/// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。
|
||||
pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec<ObjectRef>) -> Result<()> {
|
||||
vm.collect_cycles();
|
||||
let mut state = self
|
||||
@@ -1338,6 +1528,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 丢弃未成功发布的展示请求,恢复后续输入可以使用的会话状态。
|
||||
pub fn discard_pending(&self, vm: &mut Vm) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -1388,6 +1579,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 重新评估已失效的属性绑定,并把结果计入会话统计。
|
||||
pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> {
|
||||
let (nodes, desktops) = {
|
||||
let state = self
|
||||
@@ -1404,6 +1596,7 @@ impl UiBridge {
|
||||
state.binding_evaluations = state.binding_evaluations.saturating_add(evaluated);
|
||||
Ok(())
|
||||
}
|
||||
/// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。
|
||||
pub fn prepare_frame(&self, vm: &mut Vm) -> Result<Option<Option<Arc<UiNode>>>> {
|
||||
let changes = vm.take_changes();
|
||||
let mut state = self
|
||||
@@ -1448,6 +1641,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(changed.then_some(root))
|
||||
}
|
||||
/// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。
|
||||
pub fn commit_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1477,6 +1671,7 @@ impl UiBridge {
|
||||
state.force_frame = false;
|
||||
state.last_snapshot = snapshot;
|
||||
state.last_tabs = frame.tabs.clone();
|
||||
state.surface = frame.surface.clone();
|
||||
state.published_version = frame.layout_version;
|
||||
// Hooks may call Page/container APIs; never hold the session mutex
|
||||
// across user code. Publish only after the refresh loop settles.
|
||||
@@ -1502,6 +1697,7 @@ impl UiBridge {
|
||||
result
|
||||
}
|
||||
|
||||
/// 在已完成的几何上启动绘制协议,保留绘制任务所需的宿主上下文。
|
||||
pub fn begin_paint_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1532,6 +1728,7 @@ impl UiBridge {
|
||||
self.accept_paint_progress(progress)
|
||||
}
|
||||
|
||||
/// 恢复同一次源绘制调用,把完成的 Draft 交回发布流程。
|
||||
pub fn resume_paint_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1541,6 +1738,7 @@ impl UiBridge {
|
||||
self.accept_paint_progress(progress)
|
||||
}
|
||||
|
||||
/// 区分挂起与完成的绘制进度;完成时保存真实读取依赖并接回发布流程。
|
||||
fn accept_paint_progress(
|
||||
&self,
|
||||
progress: crate::source_paint::Progress,
|
||||
@@ -1565,6 +1763,7 @@ impl UiBridge {
|
||||
Ok(progress)
|
||||
}
|
||||
|
||||
/// 取消尚未完成的源绘制,释放其续体和未发布上下文。
|
||||
pub fn cancel_paint_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1583,6 +1782,7 @@ impl UiBridge {
|
||||
invalidated
|
||||
}
|
||||
|
||||
/// 为目标快照建立 Mount、检查点和布局上下文,再启动可挂起的只读布局求解。
|
||||
pub fn begin_layout_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1649,10 +1849,8 @@ impl UiBridge {
|
||||
project_tabs(vm, &mut state)?
|
||||
};
|
||||
let full_viewport = layout.viewport;
|
||||
if let Some(size) = &mut layout.viewport {
|
||||
size.width = (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0);
|
||||
size.height = (size.height - crate::tabs::height(&tabs) - crate::tabs::INSET).max(0.0);
|
||||
}
|
||||
let surface = root.surface.clone().unwrap_or_default();
|
||||
layout.page_mode = root.surface.as_ref().map(|surface| surface.mode);
|
||||
{
|
||||
let state = self
|
||||
.session
|
||||
@@ -1688,6 +1886,7 @@ impl UiBridge {
|
||||
(hooks, state.mounts.ordered(), checkpoint)
|
||||
};
|
||||
let context = LayoutContext {
|
||||
surface,
|
||||
root: root.clone(),
|
||||
tabs,
|
||||
full_viewport,
|
||||
@@ -1706,6 +1905,7 @@ impl UiBridge {
|
||||
self.accept_layout_progress(vm, context, progress)
|
||||
}
|
||||
|
||||
/// 记录本次刷新主动让出 worker 的次数,用于诊断分片执行。
|
||||
pub fn note_refresh_yield(&self) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -1715,6 +1915,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录视口目标替换导致的刷新重启,便于区分正常挂起与取消重试。
|
||||
pub fn note_refresh_restart(
|
||||
&self,
|
||||
cancelled_layout: bool,
|
||||
@@ -1734,8 +1935,10 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 撤销未完成布局所保留的窗口、挂载与生命周期上下文。
|
||||
fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> {
|
||||
let LayoutContext {
|
||||
surface: _,
|
||||
desktop,
|
||||
window,
|
||||
hooks,
|
||||
@@ -1765,6 +1968,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 取消源布局续体并清理对应上下文,保持已发布帧可用。
|
||||
pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
|
||||
let PendingLayout { work, context } = pending;
|
||||
let cancelled = work.cancel(vm);
|
||||
@@ -1773,10 +1977,12 @@ impl UiBridge {
|
||||
restored
|
||||
}
|
||||
|
||||
/// 关闭失败布局的挂起工作并恢复检查点;供错误或候选撤销路径使用。
|
||||
pub fn abort_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
|
||||
self.cancel_layout_frame(vm, pending)
|
||||
}
|
||||
|
||||
/// 继续同一次尚未发布的布局任务;完成时沿原上下文收尾,不能新建一轮来重放副作用。
|
||||
pub fn resume_layout_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1794,6 +2000,7 @@ impl UiBridge {
|
||||
self.accept_layout_progress(vm, context, progress)
|
||||
}
|
||||
|
||||
/// 接收布局的 Pending/Complete 结果;保留上下文或执行最终投影和提交。
|
||||
fn accept_layout_progress(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1817,6 +2024,7 @@ impl UiBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 依次投影滚动/视口、补页面元数据、保存读取版本、提交 Mount,并处理延后的生命周期调用。
|
||||
fn finish_layout_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1824,6 +2032,7 @@ impl UiBridge {
|
||||
draft: crate::source_layout::Draft,
|
||||
) -> Result<crate::document::Frame> {
|
||||
let LayoutContext {
|
||||
mut surface,
|
||||
root,
|
||||
tabs,
|
||||
full_viewport,
|
||||
@@ -1846,11 +2055,22 @@ impl UiBridge {
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
||||
crate::scroll::project(vm, &state.mounts, &root, &mut frame)?;
|
||||
crate::viewport::project(&root, &mut frame)?;
|
||||
}
|
||||
// Native layout may replace the structural Frame; attach chrome only
|
||||
// after either layout path has produced the final page geometry.
|
||||
frame.tabs = tabs;
|
||||
frame.window = window;
|
||||
if let Some(root) = frame.mounts.first() {
|
||||
surface.extent = crate::layout::Size {
|
||||
width: root.width,
|
||||
height: root.height,
|
||||
};
|
||||
}
|
||||
if let Some(viewport) = full_viewport {
|
||||
surface.clamp(viewport);
|
||||
}
|
||||
frame.surface = surface;
|
||||
// Capture versions before lifecycle hooks can change a dependency.
|
||||
let desktops = self
|
||||
.session
|
||||
@@ -1905,6 +2125,7 @@ impl UiBridge {
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// 读取当前源程序已经选择的展示内容,为编译入口生成最终文档提供根。
|
||||
pub fn rendered(&self, vm: &Vm) -> Result<Option<Arc<UiNode>>> {
|
||||
let state = self
|
||||
.session
|
||||
@@ -1919,6 +2140,7 @@ impl UiBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从会话的类型注册表取得指定类型,再给原生分配的对象附加托管类型身份。
|
||||
fn attach_named_type(
|
||||
cx: &mut NativeContext<'_>,
|
||||
object: ObjectRef,
|
||||
@@ -1932,6 +2154,7 @@ fn attach_named_type(
|
||||
attach_desktop_type(cx, object, name, desktop)
|
||||
}
|
||||
|
||||
/// 按给定 desktop 查找类型并附加,支持不同窗口使用自己的展示上下文。
|
||||
pub fn attach_desktop_type(
|
||||
cx: &mut NativeContext<'_>,
|
||||
instance: ObjectRef,
|
||||
@@ -1942,6 +2165,7 @@ pub fn attach_desktop_type(
|
||||
cx.attach_type(instance, definition)
|
||||
}
|
||||
|
||||
/// 读取 desktop 的托管类型注册表;缺失或类型错误时返回 VM 错误。
|
||||
pub(crate) fn desktop_type(
|
||||
store: &endor_core::ObjectStore,
|
||||
name: &str,
|
||||
@@ -1951,6 +2175,7 @@ pub(crate) fn desktop_type(
|
||||
object(store.dict_get(types, &Value::Text(name.into()))?.as_ref())
|
||||
}
|
||||
|
||||
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
|
||||
pub fn snapshot(vm: &Vm, value: &Value) -> Result<Arc<UiNode>> {
|
||||
let Value::Object(root) = value else {
|
||||
bail!("main must return a Block");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
|
||||
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
|
||||
//!
|
||||
//! Allocate and connect the managed desktop, type tables and host globals.
|
||||
use super::*;
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
let desktop = vm.new_object()?;
|
||||
roots.push(desktop);
|
||||
@@ -43,6 +47,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
("spacer", Operation::Spacer),
|
||||
("row", Operation::Row),
|
||||
("column", Operation::Column),
|
||||
("overlay", Operation::Overlay),
|
||||
("scene2D", Operation::Scene2D),
|
||||
("canvasViewport", Operation::CanvasViewport),
|
||||
("camera2D", Operation::Camera2D),
|
||||
("button", Operation::Button),
|
||||
("page", Operation::Page),
|
||||
("present", Operation::Present),
|
||||
@@ -84,8 +92,27 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
("RectBlock", "Block", Some("rect")),
|
||||
("Spacer", "Block", Some("spacer")),
|
||||
("ScrollBlock", "ContainerBlock", Some("scroll")),
|
||||
("ScrollView", "ScrollBlock", Some("scroll")),
|
||||
("Row", "ContainerBlock", Some("row")),
|
||||
("Column", "ContainerBlock", Some("column")),
|
||||
("Overlay", "ContainerBlock", Some("overlay")),
|
||||
("Scene2D", "ContainerBlock", Some("scene2D")),
|
||||
("CanvasViewport", "ContainerBlock", Some("canvasViewport")),
|
||||
("Camera2D", "Object", Some("camera2D")),
|
||||
("Vec3", "Object", Some("vec3")),
|
||||
("Triangle3D", "Object", Some("triangle3D")),
|
||||
("Geometry3D", "Object", Some("geometry3D")),
|
||||
("Mesh3D", "Object", Some("mesh3D")),
|
||||
("Box3D", "Mesh3D", Some("box3D")),
|
||||
("Group3D", "Object", Some("group3D")),
|
||||
("Scene3D", "Object", Some("scene3D")),
|
||||
("DirectionalLight", "Object", Some("directionalLight")),
|
||||
("PerspectiveCamera", "Object", Some("perspectiveCamera")),
|
||||
("OrthographicCamera", "Object", Some("orthographicCamera")),
|
||||
("OrbitController", "Object", Some("orbitController")),
|
||||
("Viewport3D", "Block", Some("viewport3D")),
|
||||
("Panel3D", "Object", Some("panel3D")),
|
||||
("RayHit3D", "Object", None),
|
||||
("Button", "ContainerBlock", Some("button")),
|
||||
("Page", "ContainerBlock", Some("page")),
|
||||
("Window", "Object", Some("window-create")),
|
||||
@@ -130,10 +157,17 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
"scroll" => Operation::Scroll,
|
||||
"row" => Operation::Row,
|
||||
"column" => Operation::Column,
|
||||
"overlay" => Operation::Overlay,
|
||||
"scene2D" => Operation::Scene2D,
|
||||
"canvasViewport" => Operation::CanvasViewport,
|
||||
"camera2D" => Operation::Camera2D,
|
||||
"viewport3D" => Operation::Viewport3D,
|
||||
"button" => Operation::Button,
|
||||
"page" => Operation::Page,
|
||||
"window-create" => Operation::NewWindow,
|
||||
_ => return Err(invalid("unknown native type allocator").into()),
|
||||
name => crate::scene3d_model::Constructor::from_function(name)
|
||||
.map(Operation::SceneObject)
|
||||
.ok_or(invalid("unknown native type allocator"))?,
|
||||
};
|
||||
let factory = vm.allocate(ContextNativeFunction::new(
|
||||
constructor_name,
|
||||
@@ -172,6 +206,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
("Arrangement", vec!["children"]),
|
||||
("DisplayList", vec!["commands"]),
|
||||
("PaintCommand", vec![]),
|
||||
("CameraGesture", vec![]),
|
||||
] {
|
||||
let fields = fields
|
||||
.into_iter()
|
||||
@@ -232,11 +267,20 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
"scroll" => Some(Operation::Scroll),
|
||||
"row" => Some(Operation::Row),
|
||||
"column" => Some(Operation::Column),
|
||||
"overlay" => Some(Operation::Overlay),
|
||||
"scene2D" => Some(Operation::Scene2D),
|
||||
"canvasViewport" => Some(Operation::CanvasViewport),
|
||||
"camera2D" => Some(Operation::Camera2D),
|
||||
"viewport3D" => Some(Operation::Viewport3D),
|
||||
"button" => Some(Operation::Button),
|
||||
"page" => Some(Operation::Page),
|
||||
"window-create" => Some(Operation::NewWindow),
|
||||
"event" | "object-list" => None,
|
||||
_ => return Err(invalid("unknown deferred factory").into()),
|
||||
name => Some(
|
||||
crate::scene3d_model::Constructor::from_function(name)
|
||||
.map(Operation::SceneObject)
|
||||
.ok_or(invalid("unknown deferred factory"))?,
|
||||
),
|
||||
};
|
||||
if let Some(operation) = operation {
|
||||
let options = operation_signature(vm.object_store(), desktop, operation)?;
|
||||
@@ -335,6 +379,15 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
}
|
||||
for (type_name, name, operation) in [
|
||||
("Block", "bind", Operation::Bind),
|
||||
("OrbitController", "update", Operation::CameraControl),
|
||||
("Mesh3D", "translate", Operation::Translate3D),
|
||||
("Group3D", "translate", Operation::Translate3D),
|
||||
("Panel3D", "translate", Operation::Translate3D),
|
||||
("PerspectiveCamera", "translate", Operation::Translate3D),
|
||||
("OrthographicCamera", "translate", Operation::Translate3D),
|
||||
("Mesh3D", "rotate", Operation::Rotate3D),
|
||||
("Group3D", "rotate", Operation::Rotate3D),
|
||||
("Panel3D", "rotate", Operation::Rotate3D),
|
||||
("Block", "mounts", Operation::Mounts),
|
||||
("Block", "invalidate", Operation::Invalidate),
|
||||
("Block", "onClick", Operation::OnClick),
|
||||
@@ -390,6 +443,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
|
||||
crate::protocols::install(vm, roots, desktop, ui)?;
|
||||
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
|
||||
crate::bridge::graphics::install(vm, roots, ui, desktop)?;
|
||||
developer_pages::install(vm, ui, &session)?;
|
||||
let bridge = UiBridge { ui, session, tasks };
|
||||
developer_pages::install_start_page(vm, &bridge)?;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
|
||||
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
|
||||
//!
|
||||
//! Native Block operations executed on the owning VM worker.
|
||||
use super::*;
|
||||
|
||||
impl NativeCallable for Function {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
self.signature.clone()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(self.operation.arity())
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
use Operation::*;
|
||||
let arity = self.operation.arity();
|
||||
@@ -16,6 +22,42 @@ impl NativeCallable for Function {
|
||||
if !(minimum..=maximum).contains(&args.len()) {
|
||||
return Err(invalid("incorrect UI method argument count"));
|
||||
}
|
||||
if matches!(self.operation, Camera2D) {
|
||||
let camera = new_camera2d(cx, &self.session, self.requested_type, args)?;
|
||||
return Ok(NativeResult::Return(Value::Object(camera)));
|
||||
}
|
||||
if let SceneObject(kind) = self.operation {
|
||||
let desktop = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| invalid("UI session poisoned"))?
|
||||
.desktop;
|
||||
let definition =
|
||||
self.requested_type
|
||||
.unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?);
|
||||
let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?;
|
||||
return Ok(NativeResult::Return(Value::Object(object)));
|
||||
}
|
||||
if matches!(self.operation, CameraControl) {
|
||||
crate::scene3d_model::control(
|
||||
cx,
|
||||
self.owner
|
||||
.ok_or(invalid("controller requires a receiver"))?,
|
||||
object(args.first())?,
|
||||
object(args.get(1))?,
|
||||
)?;
|
||||
return Ok(NativeResult::Return(Value::Nil));
|
||||
}
|
||||
if matches!(self.operation, Translate3D | Rotate3D) {
|
||||
crate::scene3d_model::transform(
|
||||
cx,
|
||||
self.owner
|
||||
.ok_or(invalid("3D transform requires a receiver"))?,
|
||||
args,
|
||||
matches!(self.operation, Rotate3D),
|
||||
)?;
|
||||
return Ok(NativeResult::Return(Value::Nil));
|
||||
}
|
||||
if matches!(self.operation, Implement | Implements | Supports) {
|
||||
let capability = object(args.get(1))?;
|
||||
let value = match self.operation {
|
||||
@@ -346,9 +388,11 @@ impl NativeCallable for Function {
|
||||
0.0,
|
||||
0.0,
|
||||
)),
|
||||
Row | Column => Some(Data::new(
|
||||
Row | Column | Overlay => Some(Data::new(
|
||||
if matches!(self.operation, Row) {
|
||||
Kind::Row
|
||||
} else if matches!(self.operation, Overlay) {
|
||||
Kind::Overlay
|
||||
} else {
|
||||
Kind::Column
|
||||
},
|
||||
@@ -360,6 +404,62 @@ impl NativeCallable for Function {
|
||||
dimension(args.get(1))?,
|
||||
)),
|
||||
Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)),
|
||||
Scene2D => Some(Data::new(
|
||||
Kind::Scene2D,
|
||||
args.iter()
|
||||
.map(|value| object(Some(value)))
|
||||
.collect::<Result<_, _>>()?,
|
||||
0.0,
|
||||
0.0,
|
||||
)),
|
||||
CanvasViewport => {
|
||||
let mut data = Data::new(
|
||||
Kind::CanvasViewport {
|
||||
width: dimension(args.first())?,
|
||||
height: dimension(args.get(1))?,
|
||||
},
|
||||
vec![object(args.get(2))?],
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
data.camera = Some(new_camera2d(cx, &self.session, None, &[])?);
|
||||
Some(data)
|
||||
}
|
||||
Viewport3D => {
|
||||
let desktop = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| invalid("UI session poisoned"))?
|
||||
.desktop;
|
||||
let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?;
|
||||
let camera = crate::scene3d_model::allocate(
|
||||
cx,
|
||||
desktop,
|
||||
definition,
|
||||
crate::scene3d_model::Constructor::Perspective,
|
||||
&[],
|
||||
)?;
|
||||
let mut data = Data::new(
|
||||
Kind::Viewport3D {
|
||||
width: dimension(args.first())?,
|
||||
height: dimension(args.get(1))?,
|
||||
},
|
||||
vec![],
|
||||
0.,
|
||||
0.,
|
||||
);
|
||||
data.camera = Some(camera);
|
||||
data.scene3d = Some(object(args.get(2))?);
|
||||
let definition = desktop_type(cx.store(), "OrbitController", desktop)?;
|
||||
data.controller = Some(crate::scene3d_model::allocate(
|
||||
cx,
|
||||
desktop,
|
||||
definition,
|
||||
crate::scene3d_model::Constructor::Orbit,
|
||||
&[],
|
||||
)?);
|
||||
Some(data)
|
||||
}
|
||||
Button => {
|
||||
let label = new_block(
|
||||
cx,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//! 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。
|
||||
//!
|
||||
//! Developer workspaces are managed Pages, using the same roots and placement API.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 工具打开能力,保存所属会话和 ui 对象;调用时校验工具名,再在该 VM 中展示页面。
|
||||
struct OpenTool {
|
||||
session: Shared,
|
||||
ui: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for OpenTool {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Text(name)] = args else {
|
||||
return Err(invalid("developerTools requires a tool name"));
|
||||
@@ -63,6 +68,7 @@ impl NativeCallable for OpenTool {
|
||||
Ok(NativeResult::Return(Value::Object(page)))
|
||||
}
|
||||
}
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> {
|
||||
let function = vm.allocate(ContextNativeFunction::new(
|
||||
"developerTools",
|
||||
@@ -79,6 +85,7 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
|
||||
|
||||
/// Compile the bundled Endor page once per VM, in a private module namespace.
|
||||
/// Its functions/classes and callbacks remain ordinary managed VM objects.
|
||||
/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。
|
||||
pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
|
||||
let source = frontend::EndorReader::from_text(
|
||||
"tessera:start-page.endor",
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。
|
||||
//! 输入携带具体出现的 Mount;嵌套世界 UI 先做逆投影,避免把屏幕位移直接当成场景坐标。
|
||||
//!
|
||||
//! Camera mutations run on the owning VM worker, just like other page input.
|
||||
use super::*;
|
||||
|
||||
impl UiBridge {
|
||||
/// 解码指定 3D 视口的旋转/平移/缩放输入,再调用其托管相机控制器。
|
||||
pub(super) fn dispatch_scene_view(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
input: &crate::events::Input,
|
||||
) -> Result<()> {
|
||||
let (weak, desktop) = {
|
||||
let state = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
||||
anyhow::ensure!(
|
||||
state.published_version == input.layout_version,
|
||||
"obsolete 3D camera view"
|
||||
);
|
||||
(
|
||||
*state
|
||||
.mounts
|
||||
.active
|
||||
.get(&input.mount)
|
||||
.ok_or_else(|| anyhow::anyhow!("3D viewport unavailable"))?,
|
||||
state.desktop,
|
||||
)
|
||||
};
|
||||
let mount = vm
|
||||
.upgrade(weak)?
|
||||
.ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?;
|
||||
let result = (|| {
|
||||
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
|
||||
anyhow::ensure!(
|
||||
occurrence.mounted
|
||||
&& occurrence.version == input.layout_version
|
||||
&& occurrence.geometry.scene3d_viewport,
|
||||
"obsolete 3D viewport"
|
||||
);
|
||||
let (_, data) = block::resolve(vm.object_store(), occurrence.block)?;
|
||||
let data = block::visual_data(vm.object_store(), data)?;
|
||||
let camera = data
|
||||
.camera
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?;
|
||||
let controller = data
|
||||
.controller
|
||||
.ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?;
|
||||
let node = block::snapshot(vm.object_store(), occurrence.block)?;
|
||||
let inset = crate::layout::insets(&node);
|
||||
let geometry = &occurrence.geometry;
|
||||
let outer = geometry.view.unwrap_or_default();
|
||||
let delta = input
|
||||
.delta
|
||||
.ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?;
|
||||
let local_delta = if input.kind == "scene-zoom" {
|
||||
[0., 0.]
|
||||
} else {
|
||||
let Some(delta) = geometry.logical_delta(input.point, delta) else {
|
||||
return Ok(());
|
||||
};
|
||||
delta
|
||||
};
|
||||
let point = input
|
||||
.point
|
||||
.and_then(|p| geometry.view_point(crate::geometry::Point::new(p[0], p[1]), false))
|
||||
.map(|p| outer.inverse(p))
|
||||
.unwrap_or(crate::geometry::Point::new(
|
||||
geometry.x + inset.left,
|
||||
geometry.y + inset.top,
|
||||
));
|
||||
let gesture = vm.new_object()?;
|
||||
let result = (|| {
|
||||
vm.attach_type(
|
||||
gesture,
|
||||
desktop_type(vm.object_store(), "CameraGesture", desktop)?,
|
||||
)?;
|
||||
vm.set_attr(gesture, "kind", Value::Text(input.kind.clone()))?;
|
||||
for (name, value) in [
|
||||
("dx", local_delta[0]),
|
||||
("dy", local_delta[1]),
|
||||
("factor", delta[0]),
|
||||
("width", (geometry.width - inset.horizontal()).max(0.)),
|
||||
("height", (geometry.height - inset.vertical()).max(0.)),
|
||||
("pointX", point.x - geometry.x - inset.left),
|
||||
("pointY", point.y - geometry.y - inset.top),
|
||||
] {
|
||||
vm.set_attr(gesture, name, crate::native_visual::value(value))?;
|
||||
}
|
||||
let function = vm.bind_attr(controller, "update")?;
|
||||
let result = match function {
|
||||
Value::Object(function) => vm.call_with_limits(
|
||||
function,
|
||||
&[Value::Object(camera), Value::Object(gesture)],
|
||||
endor_core::ExecutionLimits {
|
||||
max_frames: 128,
|
||||
max_instructions: 1_000_000,
|
||||
},
|
||||
),
|
||||
_ => Err(invalid("controller.update is not callable")),
|
||||
};
|
||||
vm.release_value(function)?;
|
||||
vm.release_value(result?)?;
|
||||
Ok(())
|
||||
})();
|
||||
vm.release(gesture)?;
|
||||
result
|
||||
})();
|
||||
vm.release(mount)?;
|
||||
result
|
||||
}
|
||||
|
||||
/// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。
|
||||
pub(super) fn dispatch_viewport_view(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
input: &crate::events::Input,
|
||||
) -> Result<()> {
|
||||
let weak = {
|
||||
let state = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
||||
anyhow::ensure!(
|
||||
state.published_version == input.layout_version,
|
||||
"obsolete viewport view"
|
||||
);
|
||||
*state
|
||||
.mounts
|
||||
.active
|
||||
.get(&input.mount)
|
||||
.ok_or_else(|| anyhow::anyhow!("viewport is no longer active"))?
|
||||
};
|
||||
let mount = vm
|
||||
.upgrade(weak)?
|
||||
.ok_or_else(|| anyhow::anyhow!("viewport Mount expired"))?;
|
||||
let result = (|| {
|
||||
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?;
|
||||
anyhow::ensure!(
|
||||
occurrence.mounted
|
||||
&& occurrence.version == input.layout_version
|
||||
&& occurrence.geometry.canvas_viewport,
|
||||
"obsolete viewport occurrence"
|
||||
);
|
||||
let geometry = occurrence.geometry.clone();
|
||||
let block = occurrence.block;
|
||||
let (_, data) = block::resolve(vm.object_store(), block)?;
|
||||
let data = block::visual_data(vm.object_store(), data)?;
|
||||
let camera = data
|
||||
.camera
|
||||
.ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?;
|
||||
let old = crate::viewport::camera(vm.object_store(), camera)?;
|
||||
let delta = input
|
||||
.delta
|
||||
.ok_or_else(|| anyhow::anyhow!("viewport input has no delta"))?;
|
||||
let (mut x, mut y, mut zoom) = (old.x, old.y, old.zoom);
|
||||
if input.kind == "viewport-zoom" {
|
||||
let pointer = input
|
||||
.point
|
||||
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
|
||||
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
|
||||
anyhow::ensure!(
|
||||
geometry.contains(pointer),
|
||||
"zoom anchor is outside viewport"
|
||||
);
|
||||
let pointer = geometry
|
||||
.logical_point(pointer)
|
||||
.ok_or_else(|| anyhow::anyhow!("zoom anchor cannot be projected"))?;
|
||||
let node = block::snapshot(vm.object_store(), block)?;
|
||||
let inset = crate::layout::insets(&node);
|
||||
let local = [
|
||||
pointer.x - geometry.x - inset.left,
|
||||
pointer.y - geometry.y - inset.top,
|
||||
];
|
||||
anyhow::ensure!(
|
||||
local[0] >= 0.0
|
||||
&& local[1] >= 0.0
|
||||
&& local[0] < geometry.width - inset.horizontal()
|
||||
&& local[1] < geometry.height - inset.vertical(),
|
||||
"zoom anchor is outside content viewport"
|
||||
);
|
||||
zoom = (old.zoom * delta[0]).clamp(0.01, 100.0);
|
||||
x += local[0] / old.zoom - local[0] / zoom;
|
||||
y += local[1] / old.zoom - local[1] / zoom;
|
||||
} else {
|
||||
let Some(delta) = geometry.logical_delta(input.point, delta) else {
|
||||
return Ok(());
|
||||
};
|
||||
x += delta[0] / zoom;
|
||||
y += delta[1] / zoom;
|
||||
}
|
||||
x = x.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32);
|
||||
y = y.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32);
|
||||
vm.set_attr(camera, "x", crate::native_visual::value(x))?;
|
||||
vm.set_attr(camera, "y", crate::native_visual::value(y))?;
|
||||
vm.set_attr(camera, "zoom", crate::native_visual::value(zoom))?;
|
||||
Ok(())
|
||||
})();
|
||||
vm.release(mount)?;
|
||||
result
|
||||
}
|
||||
|
||||
/// 处理旧 Page 模式的兼容相机事件;独立视口使用各自的输入路径。
|
||||
pub(super) fn dispatch_page_view(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
input: &crate::events::Input,
|
||||
) -> Result<()> {
|
||||
let (page, mut surface, viewport) = {
|
||||
let state = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
||||
anyhow::ensure!(
|
||||
state.published_version == input.layout_version,
|
||||
"obsolete page view"
|
||||
);
|
||||
(
|
||||
state
|
||||
.active
|
||||
.ok_or_else(|| anyhow::anyhow!("no active Page"))?,
|
||||
state.surface.clone(),
|
||||
state.layout.viewport,
|
||||
)
|
||||
};
|
||||
let delta = input
|
||||
.delta
|
||||
.ok_or_else(|| anyhow::anyhow!("page view requires delta"))?;
|
||||
if input.kind == "page-zoom" {
|
||||
anyhow::ensure!(
|
||||
surface.mode == crate::page::Mode::Canvas,
|
||||
"zoom requires canvas mode"
|
||||
);
|
||||
let pointer = input
|
||||
.point
|
||||
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
|
||||
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
|
||||
anyhow::ensure!(
|
||||
pointer.x >= 0.0
|
||||
&& pointer.y >= 0.0
|
||||
&& viewport.is_none_or(|v| pointer.x < v.width && pointer.y < v.height),
|
||||
"zoom anchor is outside viewport"
|
||||
);
|
||||
let world = surface.to_world(pointer);
|
||||
surface.zoom = (surface.zoom * delta[0]).clamp(0.1, 4.0);
|
||||
surface.pan = crate::geometry::Point::new(
|
||||
world.x - pointer.x / surface.zoom,
|
||||
world.y - pointer.y / surface.zoom,
|
||||
);
|
||||
} else {
|
||||
surface.pan.x += delta[0] / surface.zoom;
|
||||
surface.pan.y += delta[1] / surface.zoom;
|
||||
}
|
||||
if let Some(viewport) = viewport {
|
||||
surface.clamp(viewport);
|
||||
}
|
||||
let limit = MAX_DIMENSION as f32;
|
||||
surface.pan.x = surface.pan.x.clamp(-limit, limit);
|
||||
surface.pan.y = surface.pan.y.clamp(-limit, limit);
|
||||
vm.set_attr(page, "panX", crate::native_visual::value(surface.pan.x))?;
|
||||
vm.set_attr(page, "panY", crate::native_visual::value(surface.pan.y))?;
|
||||
vm.set_attr(page, "zoom", crate::native_visual::value(surface.zoom))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
//! 声明宿主方法在 Endor 类型系统中的参数和返回契约。
|
||||
//! 签名用于构造/调用检查,应与 calls 和各 NativeCallable 实现保持一致;这里不执行实际业务操作。
|
||||
//!
|
||||
//! Canonical contracts for native Block methods and constructors.
|
||||
use super::*;
|
||||
|
||||
/// 根据宿主方法类别提供 Endor 方法的签名,供类型与调用检查使用。
|
||||
pub(super) fn method_signature(
|
||||
store: &endor_core::ObjectStore,
|
||||
desktop: ObjectRef,
|
||||
@@ -11,6 +15,12 @@ pub(super) fn method_signature(
|
||||
use Operation::*;
|
||||
let nominal = |name| desktop_type(store, name, desktop).map(Nominal);
|
||||
let (arguments, returns, effect) = match operation {
|
||||
Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating),
|
||||
CameraControl => (
|
||||
vec![nominal("Object")?, nominal("CameraGesture")?],
|
||||
Unit,
|
||||
CallEffect::Mutating,
|
||||
),
|
||||
Bind => (
|
||||
vec![TextType, Function],
|
||||
nominal("Subscription")?,
|
||||
@@ -80,6 +90,7 @@ pub(super) fn method_signature(
|
||||
Ok(signature)
|
||||
}
|
||||
|
||||
/// 将具体宿主 Operation 映射到参数/返回类型契约。
|
||||
pub(super) fn operation_signature(
|
||||
store: &endor_core::ObjectStore,
|
||||
desktop: ObjectRef,
|
||||
@@ -133,11 +144,13 @@ pub(super) fn operation_signature(
|
||||
nominal("ScrollBlock")?,
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
Row | Column => (
|
||||
Row | Column | Overlay => (
|
||||
vec![Number, Number],
|
||||
Some(nominal("Block")?),
|
||||
nominal(if matches!(operation, Row) {
|
||||
"Row"
|
||||
} else if matches!(operation, Overlay) {
|
||||
"Overlay"
|
||||
} else {
|
||||
"Column"
|
||||
})?,
|
||||
@@ -150,6 +163,51 @@ pub(super) fn operation_signature(
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly),
|
||||
Viewport3D => (
|
||||
vec![Number, Number, nominal("Scene3D")?],
|
||||
None,
|
||||
nominal("Viewport3D")?,
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
SceneObject(kind) => {
|
||||
let parameters = kind
|
||||
.parameters()
|
||||
.iter()
|
||||
.map(|name| match *name {
|
||||
"Number" => Ok(Number),
|
||||
"String" => Ok(TextType),
|
||||
name => nominal(name),
|
||||
})
|
||||
.collect::<Result<Vec<_>, VmError>>()?;
|
||||
(
|
||||
parameters,
|
||||
if kind.variadic() {
|
||||
Some(nominal("Object")?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
nominal(kind.name())?,
|
||||
CallEffect::ReadOnly,
|
||||
)
|
||||
}
|
||||
Scene2D => (
|
||||
vec![],
|
||||
Some(nominal("Block")?),
|
||||
nominal("Scene2D")?,
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
CanvasViewport => (
|
||||
vec![Number, Number, nominal("Scene2D")?],
|
||||
None,
|
||||
nominal("CanvasViewport")?,
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
Camera2D => (
|
||||
vec![Number, Number, Number],
|
||||
None,
|
||||
nominal("Camera2D")?,
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
_ => return Err(invalid("unsupported UI function signature")),
|
||||
};
|
||||
Ok(CallSignature {
|
||||
|
||||
@@ -1,80 +1,129 @@
|
||||
// Tessera's start page is an ordinary Endor Page, including its layout and actions.
|
||||
// This module has its own globals; creating tabs does not replace user bindings.
|
||||
class StartPage: Page {
|
||||
function measure(self: StartPage, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(constraints.maxWidth, constraints.maxHeight)
|
||||
// The page, cards and actions belong to Endor. The host only owns its sidebar.
|
||||
// 默认页由真实 Endor 对象组成:宿主负责窗口和侧栏,卡片布局、绘制和按钮回调在源代码中定义。
|
||||
// Note 复用 Column 的内容测量,但固定卡片宽度;paint 只提供背景,孩子仍沿正常树绘制。
|
||||
class Note: Column {
|
||||
tint: String
|
||||
function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
measured = Column.measure(self, mount, constraints)
|
||||
return MeasureResult(224, measured.height)
|
||||
}
|
||||
function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList {
|
||||
fill = PaintCommand()
|
||||
fill.kind = "roundedRect"
|
||||
fill.x = geometry.x
|
||||
fill.y = geometry.y
|
||||
fill.width = geometry.width
|
||||
fill.height = geometry.height
|
||||
fill.radius = 14
|
||||
fill.color = self.tint
|
||||
return DisplayList(ObjectList(fill))
|
||||
}
|
||||
}
|
||||
class StartRow: Row {
|
||||
function measure(self: StartRow, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
measured = Row.measure(self, mount, constraints)
|
||||
return MeasureResult(constraints.maxWidth, measured.height)
|
||||
}
|
||||
}
|
||||
// Quiet action rows retain native hit targets and keyboard focus without card chrome.
|
||||
class StartButton: Button {
|
||||
function measure(self: StartButton, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(360, 40)
|
||||
}
|
||||
function paint(self: StartButton, mount: Mount, geometry: Bounds): DisplayList {
|
||||
class QuietButton: Button {
|
||||
// 返回空绘制列表保留 Button 的交互能力,同时让这个入口只显示孩子文字。
|
||||
function paint(self: QuietButton, mount: Mount, geometry: Bounds): DisplayList {
|
||||
return DisplayList(ObjectList())
|
||||
}
|
||||
}
|
||||
class StartLine: Row {
|
||||
function measure(self: StartLine, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
measured = Row.measure(self, mount, constraints)
|
||||
return MeasureResult(360, measured.height)
|
||||
}
|
||||
}
|
||||
class StartActions {
|
||||
// 保存起始页面身份,子页面中的返回按钮激活同一 Page,而不是复制一份页面。
|
||||
home: Page
|
||||
// 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。
|
||||
function blank(self: StartActions, event: Event): Unit {
|
||||
page = Page()
|
||||
page.title = "未命名页面"
|
||||
back = Button("返回开始页")
|
||||
page.title = "窗口页面"
|
||||
back = Button("返回画布")
|
||||
back.onClick(self.home.activate)
|
||||
page.render(Column(32, 16, TextBlock("未命名页面", 2), TextBlock("一个新的 Page 对象,已加入当前窗口。"), TextBlock("用 Endor 的 page.render(...) 组织内容。"), back))
|
||||
page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back))
|
||||
}
|
||||
// 延伸方向属于容器与滚动组合:Column 安排内容,有限 ScrollBlock 控制可见范围。
|
||||
function vertical(self: StartActions, event: Event): Unit {
|
||||
page = Page()
|
||||
page.title = "纵向页面"
|
||||
content = Column(48, 28, Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("纵向滚动和 Column 排版可以独立组合。")))
|
||||
content.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9"))
|
||||
content.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7"))
|
||||
content.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,内容自然延伸。"), "#FAF2E7"))
|
||||
content.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6"))
|
||||
view = ScrollBlock(1024, 800, content)
|
||||
view.axis = "vertical"
|
||||
view.flex = 1
|
||||
page.crossAlign = "stretch"
|
||||
page.render(view)
|
||||
}
|
||||
// Row 内可继续放 Column 卡片;横向滚动不要求改变 Page 的身份或生命周期。
|
||||
function horizontal(self: StartActions, event: Event): Unit {
|
||||
page = Page()
|
||||
page.title = "横向页面"
|
||||
content = Row(48, 32, Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("Row 内仍然可以包含 Column。"), "#EDF3F8"))
|
||||
content.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC"))
|
||||
content.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7"))
|
||||
content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8"))
|
||||
content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5"))
|
||||
view = ScrollBlock(1024, 800, content)
|
||||
view.axis = "horizontal"
|
||||
view.flex = 1
|
||||
page.crossAlign = "stretch"
|
||||
page.render(view)
|
||||
}
|
||||
// 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。
|
||||
function canvas(self: StartActions, event: Event): Unit {
|
||||
page = newTab()
|
||||
}
|
||||
// 两个文字绑定观察同一个状态对象,示范共享业务状态与多个展示位置的区别。
|
||||
function counter(self: StartActions, event: Event): Unit {
|
||||
count = ui.state(0)
|
||||
add = Button("增加计数")
|
||||
add.onClick(count.increment)
|
||||
page = Page()
|
||||
page.title = "共享对象"
|
||||
page.render(Column(32, 16, TextBlock("一个对象,两个视图", 2), TextBlock("点击一次,两处展示同时更新。切换标签后,状态仍然保留。"), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
|
||||
}
|
||||
function compose(self: StartActions, event: Event): Unit {
|
||||
label = TextBlock("对象可以被多处引用")
|
||||
page = Page()
|
||||
page.title = "组合页面"
|
||||
page.render(Column(32, 20, TextBlock("把对象组合成界面", 2), TextBlock("Column 组织纵向内容,Row 组织横向内容。"), Row(0, 16, RectBlock(100, 64, 12, "#DCE8FF"), RectBlock(100, 64, 12, "#E3ECDC"), RectBlock(100, 64, 12, "#F3E4DB")), label, Row(0, 24, label, label)))
|
||||
page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
|
||||
}
|
||||
}
|
||||
// 页面工厂:先建立业务对象/动作,再建立世界组成,最后用一个有限视口显示它。
|
||||
function newTab(): Page {
|
||||
page = StartPage()
|
||||
page.title = "新标签页"
|
||||
page = Page()
|
||||
page.title = "自由画布"
|
||||
actions = StartActions(page)
|
||||
blank = StartButton("+ 创建页面")
|
||||
counter = StartButton("↗ 共享对象示例")
|
||||
compose = StartButton("◇ 界面组合示例")
|
||||
blank.on("click", actions.blank)
|
||||
counter.on("click", actions.counter)
|
||||
compose.on("click", actions.compose)
|
||||
heading = StartLine(0, 0, TextBlock("Endor", 2, "#30343B"))
|
||||
heading.mainAlign = "center"
|
||||
subtitle = StartLine(0, 0, TextBlock("创建页面,或从一个示例开始。", 1, "#80858D"))
|
||||
subtitle.mainAlign = "center"
|
||||
newHint = StartLine(0, 0, TextBlock("新建标签页", 1, "#8A8F97"), TextBlock("⌘ T / Ctrl + T", 1, "#747B85"))
|
||||
newHint.mainAlign = "space-between"
|
||||
closeHint = StartLine(0, 0, TextBlock("关闭标签页", 1, "#8A8F97"), TextBlock("⌘ W / Ctrl + W", 1, "#747B85"))
|
||||
closeHint.mainAlign = "space-between"
|
||||
content = Column(0, 8,
|
||||
heading, subtitle, Spacer(20),
|
||||
blank, counter, compose, Spacer(20),
|
||||
newHint, closeHint)
|
||||
row = StartRow(24, 0, content)
|
||||
row.mainAlign = "center"
|
||||
page.mainAlign = "center"
|
||||
page.render(row)
|
||||
fixed = Button("窗口页面")
|
||||
vertical = Button("纵向延伸")
|
||||
horizontal = Button("横向延伸")
|
||||
canvas = Button("新的画布")
|
||||
shared = QuietButton("试试:一个对象,两个视图 ↗")
|
||||
fixed.on("click", actions.blank)
|
||||
vertical.on("click", actions.vertical)
|
||||
horizontal.on("click", actions.horizontal)
|
||||
canvas.on("click", actions.canvas)
|
||||
shared.on("click", actions.counter)
|
||||
hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared)
|
||||
// x/y 属于 Scene2D 子组件的世界位置;卡片内部依旧由普通 Row/Column 排版。
|
||||
hero.x = 316
|
||||
hero.y = 254
|
||||
world = Scene2D(hero)
|
||||
left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9")
|
||||
left.x = 36
|
||||
left.y = 92
|
||||
world.append(left)
|
||||
top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7")
|
||||
top.x = 466
|
||||
top.y = 58
|
||||
world.append(top)
|
||||
right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("布局属于组件,视角属于摄像机。"), "#F1EDF5")
|
||||
right.x = 826
|
||||
right.y = 190
|
||||
world.append(right)
|
||||
bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5")
|
||||
bottom.x = 104
|
||||
bottom.y = 524
|
||||
world.append(bottom)
|
||||
end = Note(24, 14, TextBlock("继续向外探索", 2), RectBlock(176, 100, 10, "#CEDFE7"), TextBlock("画布上的对象仍是普通 Endor 组件。"), "#EAF1F5")
|
||||
end.x = 716
|
||||
end.y = 614
|
||||
world.append(end)
|
||||
// 视口参加有限布局并占用剩余空间;移动它的 Camera2D 不改写卡片世界位置。
|
||||
view = CanvasViewport(1024, 800, world)
|
||||
view.flex = 1
|
||||
page.crossAlign = "stretch"
|
||||
page.render(view)
|
||||
return page
|
||||
}
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
//! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。
|
||||
//! 节点保存排版、绘制和协议投影信息;浅比较用于决定是否复用已有快照。
|
||||
//!
|
||||
//! Owned immutable visual derivation; no managed VM handles.
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 不可变视觉节点。block_id 关联业务身份,slots 保留子项出现的稳定组成;kind 和盒模型字段用于派生排版与绘制。
|
||||
pub struct UiNode {
|
||||
// 只有页面根带兼容 Page 相机;其余字段是普通有限盒子的布局策略。
|
||||
pub surface: Option<crate::page::Surface>,
|
||||
pub flex: f32,
|
||||
pub flex_policy: crate::layout::FlexPolicy,
|
||||
pub main_align: crate::layout::MainAlign,
|
||||
pub cross_align: crate::layout::CrossAlign,
|
||||
pub align_self: Option<crate::layout::CrossAlign>,
|
||||
pub margin: crate::layout::Insets,
|
||||
pub sizing: crate::box_model::Sizing,
|
||||
pub padding_edges: [Option<f32>; 4],
|
||||
pub border: crate::layout::Insets,
|
||||
pub border_color: String,
|
||||
pub clip_children: bool,
|
||||
// 这些字符串只是派生身份/协议版本键,不是可以操作托管堆的 ObjectRef。
|
||||
pub block_id: String,
|
||||
pub protocol_key: String,
|
||||
// slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。
|
||||
pub slots: Vec<u64>,
|
||||
pub interactive: bool,
|
||||
pub pointer_events: bool,
|
||||
pub kind: UiKind,
|
||||
}
|
||||
|
||||
impl UiNode {
|
||||
/// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。
|
||||
pub fn children(&self) -> &[Arc<UiNode>] {
|
||||
match &self.kind {
|
||||
UiKind::Column { children, .. }
|
||||
| UiKind::Overlay { children, .. }
|
||||
| UiKind::Canvas { children, .. }
|
||||
| UiKind::Scene2D { children, .. }
|
||||
| UiKind::CanvasViewport { children, .. }
|
||||
| UiKind::Viewport3D { children, .. }
|
||||
| UiKind::Row { children, .. }
|
||||
| UiKind::Button { children, .. }
|
||||
| UiKind::Scroll { children, .. } => children,
|
||||
@@ -24,18 +47,42 @@ impl UiNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否需要对子项执行弹性主轴分配。
|
||||
pub fn has_flex(&self) -> bool {
|
||||
self.flex > 0.0 || self.flex_policy != crate::layout::FlexPolicy::default()
|
||||
self.flex > 0.0
|
||||
|| self.flex_policy != crate::layout::FlexPolicy::default()
|
||||
|| self.sizing.width == crate::box_model::Dimension::Fill
|
||||
|| self.sizing.height == crate::box_model::Dimension::Fill
|
||||
}
|
||||
|
||||
/// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。
|
||||
pub fn allocates_children(&self) -> bool {
|
||||
self.children().iter().any(|child| {
|
||||
child.has_flex()
|
||||
|| child.align_self.unwrap_or(self.cross_align)
|
||||
== crate::layout::CrossAlign::Stretch
|
||||
})
|
||||
}
|
||||
|
||||
/// 比较节点自身配置和直接子项身份,决定能否复用旧 Arc;不是深拷贝业务图。
|
||||
pub fn shallow_eq(&self, other: &Self) -> bool {
|
||||
if self.flex_policy != other.flex_policy
|
||||
if self.surface != other.surface
|
||||
|| self.flex_policy != other.flex_policy
|
||||
|| self.flex != other.flex
|
||||
|| self.main_align != other.main_align
|
||||
|| self.cross_align != other.cross_align
|
||||
|| self.align_self != other.align_self
|
||||
|| self.margin != other.margin
|
||||
|| self.sizing != other.sizing
|
||||
|| self.padding_edges != other.padding_edges
|
||||
|| self.border != other.border
|
||||
|| self.border_color != other.border_color
|
||||
|| self.clip_children != other.clip_children
|
||||
|| self.block_id != other.block_id
|
||||
|| self.protocol_key != other.protocol_key
|
||||
|| self.slots != other.slots
|
||||
|| self.interactive != other.interactive
|
||||
|| self.pointer_events != other.pointer_events
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -48,6 +95,47 @@ impl UiNode {
|
||||
.all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
};
|
||||
match (&self.kind, &other.kind) {
|
||||
(
|
||||
UiKind::Viewport3D {
|
||||
width: a,
|
||||
height: b,
|
||||
view: c,
|
||||
controls: g,
|
||||
panels: i,
|
||||
..
|
||||
},
|
||||
UiKind::Viewport3D {
|
||||
width: d,
|
||||
height: e,
|
||||
view: f,
|
||||
controls: h,
|
||||
panels: j,
|
||||
..
|
||||
},
|
||||
) => a == d && b == e && c == f && g == h && i == j && same_children(),
|
||||
(
|
||||
UiKind::CanvasViewport {
|
||||
width: a,
|
||||
height: b,
|
||||
camera: c,
|
||||
..
|
||||
},
|
||||
UiKind::CanvasViewport {
|
||||
width: d,
|
||||
height: e,
|
||||
camera: f,
|
||||
..
|
||||
},
|
||||
) => a == d && b == e && c == f && same_children(),
|
||||
(UiKind::Scene2D { positions: a, .. }, UiKind::Scene2D { positions: b, .. }) => {
|
||||
a == b && same_children()
|
||||
}
|
||||
(UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => {
|
||||
a == b && same_children()
|
||||
}
|
||||
(UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => {
|
||||
a == b && same_children()
|
||||
}
|
||||
(
|
||||
UiKind::Text {
|
||||
value: a,
|
||||
@@ -123,7 +211,34 @@ impl UiNode {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 视觉内容的原生分类。叶节点保存绘制数据,容器保存子树,场景视口另外保存相机与世界投影。
|
||||
pub enum UiKind {
|
||||
Viewport3D {
|
||||
width: f32,
|
||||
height: f32,
|
||||
view: Arc<crate::scene3d::View>,
|
||||
controls: bool,
|
||||
panels: Vec<crate::layout::Size>,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Scene2D {
|
||||
positions: Vec<[f32; 2]>,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
CanvasViewport {
|
||||
width: f32,
|
||||
height: f32,
|
||||
camera: crate::viewport::CameraView,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Overlay {
|
||||
padding: f32,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Canvas {
|
||||
positions: Vec<[f32; 2]>,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Button {
|
||||
padding: f32,
|
||||
gap: f32,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。
|
||||
//! VM 操作由运行时线程串行处理;窗口线程只消费发布文档与纯数据通知。crate 私有别名用于保持模块内部引用路径稳定。
|
||||
//!
|
||||
//! Application composition root. Directory modules own implementation; these
|
||||
//! crate-private aliases keep existing internal paths stable during reorganization.
|
||||
mod bridge;
|
||||
@@ -7,19 +10,22 @@ mod runtime;
|
||||
mod shell;
|
||||
|
||||
pub(crate) use bridge::ui_bridge;
|
||||
pub(crate) use model::scene3d as scene3d_model;
|
||||
pub(crate) use model::{
|
||||
binding, block, document, mount, object_list, presentation, protocols, snapshot, window,
|
||||
};
|
||||
pub(crate) use render::{
|
||||
display_list, geometry, layout, native_visual, scene, scroll, software_renderer, source_hit,
|
||||
source_layout, source_paint, text,
|
||||
box_model, display_list, geometry, layout, native_visual, page, scene, scene3d, scroll,
|
||||
software_renderer, source_hit, source_layout, source_paint, text, viewport,
|
||||
};
|
||||
pub(crate) use runtime::{
|
||||
api, endor_host, endor_script, event_dispatch, events, io_tasks, tasks, vm_sequence,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use shell::native_chrome;
|
||||
pub(crate) use shell::{activity, app, clipboard, developer_tools, sources, system_ui, tabs};
|
||||
pub(crate) use shell::{
|
||||
activity, app, clipboard, developer_tools, sidebar, sources, system_ui, tabs,
|
||||
};
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
@@ -28,12 +34,16 @@ use app::{AppEvent, TesseraApp};
|
||||
use document::NamespaceState;
|
||||
use winit::event_loop::{ControlFlow, EventLoop};
|
||||
|
||||
/// 按顺序建立事件循环、共享文档、API/VM worker 和应用窗口;事件循环结束后返回原生退出清理结果。
|
||||
fn main() -> Result<()> {
|
||||
// 原生窗口由事件循环所属线程驱动;Wait 让空闲时休眠,而不是持续重绘。
|
||||
let event_loop = EventLoop::<AppEvent>::with_user_event().build()?;
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
// 共享最近完整发布的文档。后台 worker 拥有 VM,GUI 不借用其托管堆。
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
let (activity, control) = api::start(namespace.clone(), event_loop.create_proxy())?;
|
||||
let mut app = TesseraApp::new(namespace, activity, event_loop.create_proxy(), control)?;
|
||||
// run_app 接管线程直至退出;退出后仍需检查清理过程中记录的错误。
|
||||
event_loop.run_app(&mut app)?;
|
||||
app.exit_result()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。
|
||||
//! 缓存记录读取版本而不是复制业务模型;依赖变化才重新计算。托管连接对象追踪其拥有的引用。
|
||||
//!
|
||||
//! Target-owned managed bindings. Dependency versions are borrowed identities;
|
||||
//! the host's weak Block registry never roots an otherwise unreachable graph.
|
||||
use std::{
|
||||
@@ -13,27 +16,33 @@ use crate::{block, event_dispatch::Subscription, ui_bridge::attach_desktop_type}
|
||||
/// The output value is borrowed for equality only, including object handles.
|
||||
/// It never owns a reference or dereferences a possibly reclaimed cached object.
|
||||
#[derive(Debug, Clone)]
|
||||
/// 上次绑定计算的读取观察,用于判断是否需要重新执行源计算。
|
||||
struct Cached {
|
||||
reads: Vec<endor_core::ReadDependency>,
|
||||
output: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 托管属性连接,强持有计算/目标所需引用,并保留只读计算的版本缓存。
|
||||
pub struct Binding {
|
||||
field: String,
|
||||
pub compute: Option<ObjectRef>,
|
||||
reads: Arc<Mutex<Option<Cached>>>,
|
||||
}
|
||||
impl ObjectPayload for Binding {
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-binding"
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
if let Some(compute) = self.compute {
|
||||
visit(compute);
|
||||
@@ -41,6 +50,7 @@ impl ObjectPayload for Binding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查绑定目标与可调用计算,建立托管连接及其引用关系。
|
||||
pub fn connect(
|
||||
cx: &mut NativeContext<'_>,
|
||||
target: ObjectRef,
|
||||
@@ -114,6 +124,7 @@ pub fn connect(
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// 在只读调用中重算失效绑定,采集新的读取版本,再将成功结果应用到目标属性。
|
||||
pub fn refresh(
|
||||
vm: &mut Vm,
|
||||
nodes: &[WeakObjectRef],
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。
|
||||
//! Data 保存子组件 slot、布局配置、绑定和订阅。属性读写需要沿有效的类型/存储路径解析,不能把快照当成可变业务对象。
|
||||
//! 提取视觉数据时将托管属性覆盖到原生描述;校验负责拒绝循环、非法尺寸和不符合类型的组成。
|
||||
//!
|
||||
//! Live visual objects. All semantic references are owned by Aster's heap;
|
||||
//! native Arcs are reserved for immutable rendering snapshots.
|
||||
use std::sync::Arc;
|
||||
@@ -7,12 +11,14 @@ use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, Vm
|
||||
use crate::ui_bridge::UiNode;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 稳定组成 slot 与子 Block 的配对;slot 不依赖孩子当前的屏幕位置。
|
||||
pub struct Child {
|
||||
pub slot: u64,
|
||||
pub block: ObjectRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。
|
||||
pub enum Kind {
|
||||
Text {
|
||||
value: String,
|
||||
@@ -32,6 +38,16 @@ pub enum Kind {
|
||||
},
|
||||
Row,
|
||||
Column,
|
||||
Overlay,
|
||||
Scene2D,
|
||||
Viewport3D {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
CanvasViewport {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
Button,
|
||||
Page,
|
||||
}
|
||||
@@ -39,6 +55,7 @@ pub enum Kind {
|
||||
/// The VM owns the Block -> Cell -> Data relationship. Shared assignment
|
||||
/// changes the Cell to point at another Block, retaining the original identity.
|
||||
#[derive(Debug, Clone)]
|
||||
/// 对共享 Cell 的稳定包装。赋值可让所有别名一起改指向,而每次出现的 Mount 仍独立。
|
||||
pub struct Block {
|
||||
pub state: ObjectRef,
|
||||
pub page: bool,
|
||||
@@ -46,9 +63,11 @@ pub struct Block {
|
||||
}
|
||||
|
||||
impl ObjectPayload for Block {
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。
|
||||
fn attribute_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
|
||||
let Value::Object(value) = store.cell_get(self.state)? else {
|
||||
return Err(VmError::InvalidArgument("Block state is not an object"));
|
||||
@@ -66,6 +85,7 @@ impl ObjectPayload for Block {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Block 当前 Data 解析其类型转发目标,使共享替换后的类型查询仍一致。
|
||||
fn type_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
|
||||
let Value::Object(value) = store.cell_get(self.state)? else {
|
||||
return Err(VmError::InvalidArgument("Block state is not an object"));
|
||||
@@ -78,21 +98,26 @@ impl ObjectPayload for Block {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-block"
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
visit(self.state);
|
||||
if let Some(tasks) = self.tasks {
|
||||
visit(tasks);
|
||||
}
|
||||
}
|
||||
/// 向 VM 暴露共享赋值单元;替换的是指向关系,不是逐项复制组件业务字段。
|
||||
fn assignment_cell(
|
||||
&self,
|
||||
source: ObjectRef,
|
||||
@@ -112,6 +137,7 @@ impl ObjectPayload for Block {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。
|
||||
pub struct Data {
|
||||
pub kind: Kind,
|
||||
pub children: Vec<Child>,
|
||||
@@ -120,7 +146,20 @@ pub struct Data {
|
||||
pub flex: f32,
|
||||
pub flex_policy: crate::layout::FlexPolicy,
|
||||
pub main_align: crate::layout::MainAlign,
|
||||
pub cross_align: crate::layout::CrossAlign,
|
||||
pub align_self: Option<crate::layout::CrossAlign>,
|
||||
pub margin: crate::layout::Insets,
|
||||
pub sizing: crate::box_model::Sizing,
|
||||
pub padding_edges: [Option<f32>; 4],
|
||||
pub border: crate::layout::Insets,
|
||||
pub border_color: String,
|
||||
pub clip_children: bool,
|
||||
pub camera: Option<ObjectRef>,
|
||||
pub scene3d: Option<ObjectRef>,
|
||||
pub controller: Option<ObjectRef>,
|
||||
pub scroll_axis: crate::scroll::Axis,
|
||||
pub position: [f32; 2],
|
||||
pub surface: crate::page::Surface,
|
||||
pub next_slot: u64,
|
||||
pub history: Vec<ObjectRef>,
|
||||
pub history_index: usize,
|
||||
@@ -132,6 +171,7 @@ pub struct Data {
|
||||
}
|
||||
|
||||
impl Data {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
|
||||
let next_slot = children.len() as u64;
|
||||
Self {
|
||||
@@ -149,7 +189,20 @@ impl Data {
|
||||
flex: 0.0,
|
||||
flex_policy: crate::layout::FlexPolicy::default(),
|
||||
main_align: crate::layout::MainAlign::Start,
|
||||
cross_align: crate::layout::CrossAlign::Start,
|
||||
align_self: None,
|
||||
margin: crate::layout::Insets::default(),
|
||||
sizing: crate::box_model::Sizing::default(),
|
||||
padding_edges: [None; 4],
|
||||
border: crate::layout::Insets::default(),
|
||||
border_color: "#D8DCE2".into(),
|
||||
clip_children: false,
|
||||
camera: None,
|
||||
scene3d: None,
|
||||
controller: None,
|
||||
scroll_axis: crate::scroll::Axis::Both,
|
||||
position: [0.0, 0.0],
|
||||
surface: crate::page::Surface::new(crate::page::Mode::Window),
|
||||
next_slot,
|
||||
history: vec![],
|
||||
history_index: 0,
|
||||
@@ -160,25 +213,46 @@ impl Data {
|
||||
properties: None,
|
||||
}
|
||||
}
|
||||
/// 检查原生组件种类是否承载子组成,供树修改和布局选择使用。
|
||||
pub fn is_container(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
Kind::Row | Kind::Column | Kind::Button | Kind::Page | Kind::Scroll { .. }
|
||||
Kind::Row
|
||||
| Kind::Column
|
||||
| Kind::Overlay
|
||||
| Kind::Scene2D
|
||||
| Kind::CanvasViewport { .. }
|
||||
| Kind::Button
|
||||
| Kind::Page
|
||||
| Kind::Scroll { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectPayload for Data {
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-block-data"
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
if let Some(scene) = self.scene3d {
|
||||
visit(scene);
|
||||
}
|
||||
if let Some(controller) = self.controller {
|
||||
visit(controller);
|
||||
}
|
||||
if let Some(camera) = self.camera {
|
||||
visit(camera);
|
||||
}
|
||||
if let Some(properties) = self.properties {
|
||||
visit(properties);
|
||||
}
|
||||
@@ -203,6 +277,7 @@ impl ObjectPayload for Data {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。
|
||||
pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef, VmError> {
|
||||
let page = matches!(data.kind, Kind::Page);
|
||||
for child in &data.children {
|
||||
@@ -224,11 +299,13 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef,
|
||||
|
||||
/// Borrowed final data; forwarding is part of the managed graph, never an Arc
|
||||
/// ownership graph hidden from the VM's cycle collector.
|
||||
/// 解析一个 Block 当前指向的 Data,返回底层身份和借用描述。
|
||||
pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> {
|
||||
let (reference, data, _) = resolve_tracked(store, block)?;
|
||||
Ok((reference, data))
|
||||
}
|
||||
|
||||
/// 解析 Block 的完整引用路径,并返回快照失效所需的依赖身份。
|
||||
pub fn resolve_tracked(
|
||||
store: &ObjectStore,
|
||||
block: ObjectRef,
|
||||
@@ -259,11 +336,13 @@ pub fn resolve_tracked(
|
||||
|
||||
/// Validate the entire finite expansion before establishing a new composition
|
||||
/// edge. The node count is an explicit rejected resource quota, not truncated UI.
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
pub fn validate(
|
||||
store: &ObjectStore,
|
||||
root: ObjectRef,
|
||||
forbidden_state: Option<ObjectRef>,
|
||||
) -> Result<(), VmError> {
|
||||
// 显式进入/退出栈模拟深度优先遍历;退出时移除祖先链标记,允许共享子树。
|
||||
enum Step {
|
||||
Enter(ObjectRef),
|
||||
Exit(ObjectRef),
|
||||
@@ -309,17 +388,22 @@ pub fn validate(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
|
||||
pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmError> {
|
||||
crate::snapshot::RenderCache::default().snapshot(store, root)
|
||||
}
|
||||
|
||||
/// Native visual parameters are ordinary managed attributes too. Composition,
|
||||
/// histories and connections remain structural payload edges.
|
||||
/// 列出本类型可投影到托管属性的原生字段及默认值。
|
||||
pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
|
||||
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
|
||||
fn number(value: f32) -> Value {
|
||||
Value::Number((value as f64).into())
|
||||
}
|
||||
let mut fields = vec![
|
||||
("x", number(data.position[0])),
|
||||
("y", number(data.position[1])),
|
||||
("flex", number(data.flex)),
|
||||
(
|
||||
"flexBasis",
|
||||
@@ -330,8 +414,73 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
|
||||
("flexMin", number(data.flex_policy.min)),
|
||||
("flexMax", number(data.flex_policy.max)),
|
||||
("flexShrink", number(data.flex_policy.shrink)),
|
||||
("marginTop", number(data.margin.top)),
|
||||
("marginRight", number(data.margin.right)),
|
||||
("marginBottom", number(data.margin.bottom)),
|
||||
("marginLeft", number(data.margin.left)),
|
||||
("boxWidth", data.sizing.width.value()),
|
||||
("boxHeight", data.sizing.height.value()),
|
||||
("minWidth", number(data.sizing.min_width)),
|
||||
("minHeight", number(data.sizing.min_height)),
|
||||
("maxWidth", number(data.sizing.max_width)),
|
||||
("maxHeight", number(data.sizing.max_height)),
|
||||
("borderTop", number(data.border.top)),
|
||||
("borderRight", number(data.border.right)),
|
||||
("borderBottom", number(data.border.bottom)),
|
||||
("borderLeft", number(data.border.left)),
|
||||
("borderColor", Value::Text(data.border_color.clone())),
|
||||
(
|
||||
"overflow",
|
||||
Value::Text(
|
||||
if data.clip_children {
|
||||
"clip"
|
||||
} else {
|
||||
"visible"
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"alignSelf",
|
||||
Value::Text(
|
||||
data.align_self
|
||||
.map_or("auto", crate::layout::CrossAlign::name)
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
];
|
||||
for (name, edge) in ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]
|
||||
.into_iter()
|
||||
.zip(data.padding_edges)
|
||||
{
|
||||
fields.push((
|
||||
name,
|
||||
edge.map_or_else(|| Value::Text("auto".into()), number),
|
||||
));
|
||||
}
|
||||
match &data.kind {
|
||||
Kind::Viewport3D { width, height } => fields.extend([
|
||||
("width", number(*width)),
|
||||
("height", number(*height)),
|
||||
("scene", data.scene3d.map_or(Value::Nil, Value::Object)),
|
||||
(
|
||||
"controller",
|
||||
data.controller
|
||||
.map_or_else(|| Value::Text("none".into()), Value::Object),
|
||||
),
|
||||
("camera", data.camera.map_or(Value::Nil, Value::Object)),
|
||||
]),
|
||||
Kind::CanvasViewport { width, height } => fields.extend([
|
||||
("width", number(*width)),
|
||||
("height", number(*height)),
|
||||
("camera", data.camera.map_or(Value::Nil, Value::Object)),
|
||||
]),
|
||||
Kind::Page => fields.extend([
|
||||
("mode", Value::Text(data.surface.mode.name().into())),
|
||||
("panX", number(data.surface.pan.x)),
|
||||
("panY", number(data.surface.pan.y)),
|
||||
("zoom", number(data.surface.zoom)),
|
||||
]),
|
||||
Kind::Text { value, size, color } => fields.extend([
|
||||
("text", Value::Text(value.clone())),
|
||||
("size", Value::Number((*size as i64).into())),
|
||||
@@ -361,11 +510,13 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
|
||||
("padding", number(data.padding)),
|
||||
("gap", number(data.gap)),
|
||||
("mainAlign", Value::Text(data.main_align.name().into())),
|
||||
("crossAlign", Value::Text(data.cross_align.name().into())),
|
||||
]);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// 复制原生描述并用当前托管属性覆盖它;结果只用于展示,不反向修改业务对象。
|
||||
pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
|
||||
let mut data = data.clone();
|
||||
let properties = data
|
||||
@@ -378,9 +529,53 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
|
||||
if data.flex_policy.min > data.flex_policy.max {
|
||||
return Err(VmError::InvalidArgument("flexMin exceeds flexMax"));
|
||||
}
|
||||
if data.sizing.min_width > data.sizing.max_width
|
||||
|| data.sizing.min_height > data.sizing.max_height
|
||||
{
|
||||
return Err(VmError::InvalidArgument("box minimum exceeds maximum"));
|
||||
}
|
||||
if matches!(data.kind, Kind::CanvasViewport { .. }) {
|
||||
crate::viewport::camera(
|
||||
store,
|
||||
data.camera.ok_or(VmError::InvalidArgument(
|
||||
"CanvasViewport requires a Camera2D",
|
||||
))?,
|
||||
)?;
|
||||
if data.children.len() != 1
|
||||
|| !matches!(
|
||||
resolve(store, data.children[0].block)?.1.kind,
|
||||
Kind::Scene2D
|
||||
)
|
||||
{
|
||||
return Err(VmError::InvalidArgument(
|
||||
"CanvasViewport requires exactly one Scene2D",
|
||||
));
|
||||
}
|
||||
}
|
||||
if matches!(data.kind, Kind::Viewport3D { .. })
|
||||
&& let Some(controller) = data.controller
|
||||
{
|
||||
let definition = store.type_of(controller)?.ok_or(VmError::InvalidArgument(
|
||||
"3D controller requires a nominal update method",
|
||||
))?;
|
||||
let method = store
|
||||
.lookup_method(definition, "update")?
|
||||
.ok_or(VmError::InvalidArgument(
|
||||
"3D controller must define update(camera, gesture)",
|
||||
))?;
|
||||
if store
|
||||
.call_arity(method)?
|
||||
.is_none_or(|arity| !arity.accepts(3))
|
||||
{
|
||||
return Err(VmError::InvalidArgument(
|
||||
"3D controller update expects camera and CameraGesture",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 按属性名检查和解码源值,再更新对应视觉字段;非法尺寸、枚举或颜色被拒绝。
|
||||
pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> {
|
||||
let bad = || VmError::InvalidArgument("invalid native visual property");
|
||||
let number = || -> Result<f32, VmError> {
|
||||
@@ -393,7 +588,151 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
}
|
||||
Ok(n)
|
||||
};
|
||||
if matches!(name, "x" | "y" | "panX" | "panY" | "zoom") {
|
||||
let n = crate::scroll::coordinate(value)?;
|
||||
match name {
|
||||
"x" => data.position[0] = n,
|
||||
"y" => data.position[1] = n,
|
||||
"panX" if matches!(data.kind, Kind::Page) => data.surface.pan.x = n,
|
||||
"panY" if matches!(data.kind, Kind::Page) => data.surface.pan.y = n,
|
||||
"zoom" if matches!(data.kind, Kind::Page) => {
|
||||
if !(0.1..=4.0).contains(&n) {
|
||||
return Err(invalid_zoom());
|
||||
}
|
||||
data.surface.zoom = n;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(data.kind, Kind::Page) && name == "mode" {
|
||||
let Value::Text(name) = value else {
|
||||
return Err(bad());
|
||||
};
|
||||
data.surface.mode = crate::page::Mode::parse(name)?;
|
||||
return Ok(());
|
||||
}
|
||||
match name {
|
||||
"controller" if matches!(data.kind, Kind::Viewport3D { .. }) => {
|
||||
data.controller = match value {
|
||||
Value::Object(controller) => Some(*controller),
|
||||
Value::Text(text) if text == "none" => None,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
"camera"
|
||||
if matches!(
|
||||
data.kind,
|
||||
Kind::CanvasViewport { .. } | Kind::Viewport3D { .. }
|
||||
) =>
|
||||
{
|
||||
let Value::Object(camera) = value else {
|
||||
return Err(bad());
|
||||
};
|
||||
data.camera = Some(*camera);
|
||||
return Ok(());
|
||||
}
|
||||
"scene" if matches!(data.kind, Kind::Viewport3D { .. }) => {
|
||||
let Value::Object(scene) = value else {
|
||||
return Err(bad());
|
||||
};
|
||||
data.scene3d = Some(*scene);
|
||||
return Ok(());
|
||||
}
|
||||
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
|
||||
let index = match name {
|
||||
"paddingTop" => 0,
|
||||
"paddingRight" => 1,
|
||||
"paddingBottom" => 2,
|
||||
_ => 3,
|
||||
};
|
||||
data.padding_edges[index] = if matches!(value, Value::Text(text) if text == "auto") {
|
||||
None
|
||||
} else {
|
||||
Some(number()?)
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
"borderTop" => {
|
||||
data.border.top = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"borderRight" => {
|
||||
data.border.right = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"borderBottom" => {
|
||||
data.border.bottom = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"borderLeft" => {
|
||||
data.border.left = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"borderColor" => {
|
||||
let Value::Text(color) = value else {
|
||||
return Err(bad());
|
||||
};
|
||||
crate::document::parse_color(color).map_err(|_| bad())?;
|
||||
data.border_color.clone_from(color);
|
||||
return Ok(());
|
||||
}
|
||||
"overflow" => {
|
||||
let Value::Text(overflow) = value else {
|
||||
return Err(bad());
|
||||
};
|
||||
data.clip_children = match overflow.as_str() {
|
||||
"clip" => true,
|
||||
"visible" => false,
|
||||
_ => {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"overflow expects visible or clip; use ScrollBlock for scrolling",
|
||||
));
|
||||
}
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
"boxWidth" => {
|
||||
data.sizing.width = crate::box_model::Dimension::parse(value)?;
|
||||
return Ok(());
|
||||
}
|
||||
"boxHeight" => {
|
||||
data.sizing.height = crate::box_model::Dimension::parse(value)?;
|
||||
return Ok(());
|
||||
}
|
||||
"minWidth" => {
|
||||
data.sizing.min_width = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"minHeight" => {
|
||||
data.sizing.min_height = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"maxWidth" => {
|
||||
data.sizing.max_width = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"maxHeight" => {
|
||||
data.sizing.max_height = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"marginTop" => {
|
||||
data.margin.top = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"marginRight" => {
|
||||
data.margin.right = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"marginBottom" => {
|
||||
data.margin.bottom = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"marginLeft" => {
|
||||
data.margin.left = number()?;
|
||||
return Ok(());
|
||||
}
|
||||
"flexBasis" => {
|
||||
data.flex_policy.basis = if matches!(value, Value::Nil)
|
||||
|| matches!(value, Value::Text(text) if text == "auto")
|
||||
@@ -423,6 +762,21 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
return Ok(());
|
||||
}
|
||||
let container = data.is_container();
|
||||
if name == "alignSelf" || (container && name == "crossAlign") {
|
||||
let Value::Text(value) = value else {
|
||||
return Err(bad());
|
||||
};
|
||||
if name == "alignSelf" {
|
||||
data.align_self = if value == "auto" {
|
||||
None
|
||||
} else {
|
||||
Some(crate::layout::CrossAlign::parse(value)?)
|
||||
};
|
||||
} else {
|
||||
data.cross_align = crate::layout::CrossAlign::parse(value)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if container && name == "mainAlign" {
|
||||
let Value::Text(value) = value else {
|
||||
return Err(bad());
|
||||
@@ -431,6 +785,10 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
return Ok(());
|
||||
}
|
||||
match (&mut data.kind, name) {
|
||||
(Kind::Viewport3D { width, .. }, "width") => *width = number()?,
|
||||
(Kind::Viewport3D { height, .. }, "height") => *height = number()?,
|
||||
(Kind::CanvasViewport { width, .. }, "width") => *width = number()?,
|
||||
(Kind::CanvasViewport { height, .. }, "height") => *height = number()?,
|
||||
(Kind::Text { value: text, .. }, "text") => {
|
||||
let Value::Text(value) = value else {
|
||||
return Err(bad());
|
||||
@@ -472,3 +830,8 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统一返回非法页面缩放错误,确保属性写入与输入路径使用相同限制。
|
||||
fn invalid_zoom() -> VmError {
|
||||
VmError::InvalidArgument("Page.zoom expects a number from 0.1 to 4")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。
|
||||
//! RenderDocument/Frame 包含绘制命令、Mount 几何、页面标签和相机投影。发布前统一检查数量、坐标、颜色及索引一致性。
|
||||
//! 屏幕命中、逻辑坐标和世界 UI 的射线映射必须与绘制采用同一变换;这里的 ID 是数据身份,不是窗口线程可操作的托管引用。
|
||||
//!
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -13,18 +17,23 @@ use crate::{
|
||||
pub const DEFAULT_NAMESPACE: &str = "app.main";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// HTTP/GUI 共享的最近完整发布状态;submission 决定结果归属,revision 表示实际发布次数。
|
||||
pub struct NamespaceState {
|
||||
pub name: String,
|
||||
pub revision: u64,
|
||||
// 较新完整 PUT/DELETE 会改变提交身份;旧输入即使晚完成也不得覆盖新结果。
|
||||
pub submission: u64,
|
||||
pub document: RenderDocument,
|
||||
// viewport 是主窗口逻辑页面区域;window_viewports 同时记录仍存活的副窗口。
|
||||
pub viewport: Option<crate::layout::Size>,
|
||||
pub window_viewports: std::collections::BTreeMap<u64, crate::layout::Size>,
|
||||
// 原生命令与运行时世代一起发布,防止窗口 ID 在跨 VM 时被误用。
|
||||
pub runtime_session: u64,
|
||||
pub window_commands: Vec<crate::window::Envelope>,
|
||||
}
|
||||
|
||||
impl Default for NamespaceState {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: DEFAULT_NAMESPACE.to_owned(),
|
||||
@@ -40,6 +49,7 @@ impl Default for NamespaceState {
|
||||
}
|
||||
|
||||
impl NamespaceState {
|
||||
/// 仅接收当前 session 的纯数据窗口命令,并按种类合并同资源的重复请求。
|
||||
pub fn publish_window_commands(&mut self, session: u64, commands: Vec<crate::window::Command>) {
|
||||
if self.runtime_session != session {
|
||||
self.window_commands.clear();
|
||||
@@ -63,13 +73,16 @@ impl NamespaceState {
|
||||
.sort_by_key(|command| command.command.sequence);
|
||||
}
|
||||
|
||||
/// 计算下一个完整源码提交身份;计数溢出时返回错误而不是复用旧身份。
|
||||
pub fn next_submission(&self) -> Result<u64> {
|
||||
self.submission
|
||||
.checked_add(1)
|
||||
.context("submission counter exhausted")
|
||||
}
|
||||
|
||||
/// 仅当提交身份仍是最新时替换完整文档并递增版本;已被取代的结果不发布。
|
||||
pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result<Option<u64>> {
|
||||
// 先核对归属,再计算新版本;失败或过时的结果不会替换已显示文档。
|
||||
if submission != self.submission {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -82,6 +95,7 @@ impl NamespaceState {
|
||||
Ok(Some(revision))
|
||||
}
|
||||
|
||||
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
|
||||
pub fn clear(&mut self) -> Result<u64> {
|
||||
let submission = self.next_submission()?;
|
||||
let revision = self
|
||||
@@ -98,6 +112,7 @@ impl NamespaceState {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
/// 可以离开 VM 线程的完整展示值,包含源码投影、根帧和其他窗口的文档。
|
||||
pub struct RenderDocument {
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub windows: std::collections::BTreeMap<u64, RenderDocument>,
|
||||
@@ -112,7 +127,20 @@ pub struct RenderDocument {
|
||||
}
|
||||
|
||||
impl RenderDocument {
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let surface = &self.frame.surface;
|
||||
if !surface.zoom.is_finite()
|
||||
|| !(0.1..=4.0).contains(&surface.zoom)
|
||||
|| ![surface.pan.x, surface.pan.y]
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
|| ![surface.extent.width, surface.extent.height]
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v))
|
||||
{
|
||||
bail!("invalid page camera or extent");
|
||||
}
|
||||
if self.windows.len() > 15 {
|
||||
bail!("too many window projections");
|
||||
}
|
||||
@@ -144,6 +172,16 @@ impl RenderDocument {
|
||||
for clip in self.frame.command_clips.iter().flatten() {
|
||||
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
|
||||
}
|
||||
if !self.frame.command_views.is_empty()
|
||||
&& self.frame.command_views.len() != self.frame.commands.len()
|
||||
{
|
||||
bail!("invalid command view table");
|
||||
}
|
||||
for view in self.frame.command_views.iter().flatten() {
|
||||
if !view.valid() {
|
||||
bail!("invalid command view transform");
|
||||
}
|
||||
}
|
||||
for command in &self.frame.commands {
|
||||
command.validate()?;
|
||||
}
|
||||
@@ -163,6 +201,9 @@ impl RenderDocument {
|
||||
}
|
||||
let mut mount_ids = HashSet::new();
|
||||
for mount in &self.frame.mounts {
|
||||
if mount.view.is_some_and(|view| !view.valid()) {
|
||||
bail!("invalid Mount view transform");
|
||||
}
|
||||
if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) {
|
||||
bail!("invalid or duplicate mount identity");
|
||||
}
|
||||
@@ -170,6 +211,9 @@ impl RenderDocument {
|
||||
if let Some(clip) = mount.clip {
|
||||
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
|
||||
}
|
||||
if let Some(clip) = mount.viewport_clip {
|
||||
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
|
||||
}
|
||||
if let Some(scroll) = mount.scroll
|
||||
&& (!scroll.iter().all(|n| {
|
||||
n.is_finite() && (0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(n)
|
||||
@@ -206,6 +250,7 @@ impl RenderDocument {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验文档后把逻辑绘制命令转换成给定设备比例的显示列表,应用变换与裁剪。
|
||||
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
|
||||
let frame = &self.frame;
|
||||
let mut list = DisplayList::default();
|
||||
@@ -217,7 +262,17 @@ impl RenderDocument {
|
||||
clip[0], clip[1], clip[2], clip[3], scale,
|
||||
)));
|
||||
}
|
||||
list.push(command.to_draw_command(scale)?);
|
||||
let view = frame
|
||||
.command_views
|
||||
.get(index)
|
||||
.copied()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
list.push(
|
||||
command
|
||||
.to_draw_command(scale * view.zoom)?
|
||||
.translated(Point::new(view.x * scale, view.y * scale)),
|
||||
);
|
||||
if clip.is_some() {
|
||||
list.push(DrawCommand::PopClip);
|
||||
}
|
||||
@@ -225,6 +280,7 @@ impl RenderDocument {
|
||||
if let Some(focus) = frame
|
||||
.focus
|
||||
.and_then(|id| frame.mounts.iter().find(|m| m.id == id))
|
||||
.filter(|mount| mount.panel.is_none())
|
||||
{
|
||||
if let Some(c) = focus.clip {
|
||||
list.push(DrawCommand::PushClip(scaled_rect(
|
||||
@@ -233,10 +289,18 @@ impl RenderDocument {
|
||||
}
|
||||
list.push(
|
||||
RenderCommand::StrokeRoundedRect {
|
||||
x: focus.x,
|
||||
y: focus.y,
|
||||
width: focus.width,
|
||||
height: focus.height,
|
||||
x: focus
|
||||
.view
|
||||
.unwrap_or_default()
|
||||
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
|
||||
.x,
|
||||
y: focus
|
||||
.view
|
||||
.unwrap_or_default()
|
||||
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
|
||||
.y,
|
||||
width: focus.width * focus.view.unwrap_or_default().zoom,
|
||||
height: focus.height * focus.view.unwrap_or_default().zoom,
|
||||
radius: 7.0,
|
||||
stroke_width: 2.0,
|
||||
color: "#4F6FDB".into(),
|
||||
@@ -274,12 +338,14 @@ impl RenderDocument {
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// 从源码投影根按 ID 查找节点,供 Sources 查看与定位使用。
|
||||
pub fn node(&self, id: &str) -> Option<&CodeNode> {
|
||||
self.root.as_ref()?.find(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
/// Sources 使用的源码层次节点;ID、名称、种类和行列范围都属于代码投影而不是运行时对象句柄。
|
||||
pub struct CodeNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -293,6 +359,7 @@ pub struct CodeNode {
|
||||
}
|
||||
|
||||
impl CodeNode {
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
fn validate<'a>(&'a self, depth: usize, ids: &mut HashSet<&'a str>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
bail!("architecture tree exceeds 64 levels");
|
||||
@@ -312,6 +379,7 @@ impl CodeNode {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按请求的代码节点身份或路径查找对应的源码投影。
|
||||
fn find(&self, id: &str) -> Option<&CodeNode> {
|
||||
if self.id == id {
|
||||
return Some(self);
|
||||
@@ -321,7 +389,13 @@ impl CodeNode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
/// 一个窗口的一次完整视觉发布:有序命令及其裁剪/变换、出现几何、焦点和版本。平行索引表必须和命令数量一致。
|
||||
pub struct Frame {
|
||||
// 命中索引是可重建的本地优化,不作为序列化协议的一部分保存。
|
||||
#[serde(skip)]
|
||||
pub hit_index: Arc<crate::viewport::spatial::Index>,
|
||||
#[serde(default)]
|
||||
pub surface: crate::page::Surface,
|
||||
#[serde(default = "default_window")]
|
||||
pub window: u64,
|
||||
#[serde(default = "default_background")]
|
||||
@@ -331,6 +405,10 @@ pub struct Frame {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub command_clips: Vec<Option<[f32; 4]>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub command_views: Vec<Option<crate::viewport::Transform>>,
|
||||
#[serde(skip)]
|
||||
pub command_mounts: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mounts: Vec<MountGeometry>,
|
||||
#[serde(default)]
|
||||
pub layout_version: u64,
|
||||
@@ -345,12 +423,17 @@ pub struct Frame {
|
||||
}
|
||||
|
||||
impl Default for Frame {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hit_index: Arc::default(),
|
||||
surface: crate::page::Surface::default(),
|
||||
window: crate::window::MAIN_WINDOW,
|
||||
background: default_background(),
|
||||
commands: Vec::new(),
|
||||
command_clips: Vec::new(),
|
||||
command_views: Vec::new(),
|
||||
command_mounts: Vec::new(),
|
||||
mounts: Vec::new(),
|
||||
layout_version: 0,
|
||||
session: 0,
|
||||
@@ -363,7 +446,20 @@ impl Default for Frame {
|
||||
|
||||
/// A position in a published frame. Identifiers are opaque data, never VM handles.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 某次出现的几何与投影元数据。逻辑矩形不被相机直接改写,view/panel 保存通往视图空间的映射。
|
||||
pub struct MountGeometry {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub panel: Option<Arc<crate::scene3d::panel::Mapping>>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub canvas_viewport: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub scene3d_viewport: bool,
|
||||
#[serde(default)]
|
||||
pub camera_controls: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub viewport_clip: Option<[f32; 4]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub view: Option<crate::viewport::Transform>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<[f32; 4]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -378,11 +474,44 @@ pub struct MountGeometry {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub interactive: bool,
|
||||
#[serde(default)]
|
||||
pub pointer_events: bool,
|
||||
}
|
||||
|
||||
impl MountGeometry {
|
||||
/// 先沿世界 UI 面板逆投影指针,按需要执行可见性和裁剪检查。
|
||||
pub fn view_point(&self, point: Point, clipped: bool) -> Option<Point> {
|
||||
self.panel
|
||||
.as_ref()
|
||||
.map_or(Some(point), |panel| panel.texture_point(point, clipped))
|
||||
}
|
||||
/// 把页面指针转换为该出现的布局坐标,供 hitTest 和局部事件处理使用。
|
||||
pub fn logical_point(&self, point: Point) -> Option<Point> {
|
||||
let point = self.view_point(point, true)?;
|
||||
Some(self.view.unwrap_or_default().inverse(point))
|
||||
}
|
||||
/// 把视图位移换算成目标出现的局部位移,处理缩放与世界 UI 投影差异。
|
||||
pub fn logical_delta(&self, point: Option<[f32; 2]>, delta: [f32; 2]) -> Option<[f32; 2]> {
|
||||
let zoom = self.view.unwrap_or_default().zoom;
|
||||
if self.panel.is_none() {
|
||||
return Some([delta[0] / zoom, delta[1] / zoom]);
|
||||
}
|
||||
let point = point?;
|
||||
let current = self.view_point(Point::new(point[0], point[1]), false)?;
|
||||
let previous =
|
||||
self.view_point(Point::new(point[0] + delta[0], point[1] + delta[1]), false)?;
|
||||
Some([
|
||||
((previous.x - current.x) / zoom).clamp(-1_000_000., 1_000_000.),
|
||||
((previous.y - current.y) / zoom).clamp(-1_000_000., 1_000_000.),
|
||||
])
|
||||
}
|
||||
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
|
||||
pub fn contains(&self, point: Point) -> bool {
|
||||
Rect::new(self.x, self.y, self.width, self.height).contains(point)
|
||||
let Some(point) = self.view_point(point, true) else {
|
||||
return false;
|
||||
};
|
||||
let local = self.view.map_or(point, |view| view.inverse(point));
|
||||
Rect::new(self.x, self.y, self.width, self.height).contains(local)
|
||||
&& self.clip.is_none_or(|c| {
|
||||
c[2] > 0.0 && c[3] > 0.0 && Rect::new(c[0], c[1], c[2], c[3]).contains(point)
|
||||
})
|
||||
@@ -390,28 +519,69 @@ impl MountGeometry {
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// 从顶层绘制顺序向后查找可控制的视口,命中与裁剪均成立才接收相机手势。
|
||||
pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> {
|
||||
self.hit_index
|
||||
.candidates(point, self.mounts.len())
|
||||
.into_iter()
|
||||
.map(|index| &self.mounts[index])
|
||||
.find(|mount| {
|
||||
(mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls)
|
||||
&& mount.contains(point)
|
||||
&& mount.viewport_clip.is_some_and(|clip| {
|
||||
mount.view_point(point, true).is_some_and(|point| {
|
||||
Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// 在无故障帧中按逆绘制顺序选中可交互的出现,空间索引只缩小候选。
|
||||
pub fn hit_test(&self, point: Point) -> Option<&MountGeometry> {
|
||||
if self.fault.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.mounts
|
||||
.iter()
|
||||
.rev()
|
||||
self.hit_index
|
||||
.candidates(point, self.mounts.len())
|
||||
.into_iter()
|
||||
.map(|index| &self.mounts[index])
|
||||
.find(|mount| mount.interactive && mount.contains(point))
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。
|
||||
fn default_window() -> u64 {
|
||||
crate::window::MAIN_WINDOW
|
||||
}
|
||||
|
||||
/// 提供空帧或未指定背景时的默认页面底色。
|
||||
fn default_background() -> String {
|
||||
"#F7F7F5".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
/// 可校验、可序列化的逻辑绘制命令;转换到设备像素在 display_list 阶段完成。
|
||||
pub enum RenderCommand {
|
||||
#[serde(skip_deserializing)]
|
||||
Image {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
offset_x: f32,
|
||||
offset_y: f32,
|
||||
angle: f32,
|
||||
opacity: f32,
|
||||
tint: Option<Color>,
|
||||
image: Arc<crate::render::image::Image>,
|
||||
},
|
||||
Scene3D {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
view: Arc<crate::scene3d::View>,
|
||||
},
|
||||
RoundedRect {
|
||||
x: f32,
|
||||
y: f32,
|
||||
@@ -445,8 +615,44 @@ pub enum RenderCommand {
|
||||
}
|
||||
|
||||
impl RenderCommand {
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::Image {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
offset_x,
|
||||
offset_y,
|
||||
angle,
|
||||
opacity,
|
||||
image,
|
||||
..
|
||||
} => {
|
||||
validate_rect(*x, *y, *width, *height, 0.)?;
|
||||
if ![offset_x, offset_y, angle, opacity]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
|| !(0. ..=1.).contains(opacity)
|
||||
|| image.width == 0
|
||||
|| image.height == 0
|
||||
{
|
||||
bail!("invalid image command");
|
||||
}
|
||||
}
|
||||
Self::Scene3D {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
view,
|
||||
} => {
|
||||
validate_rect(*x, *y, *width, *height, 0.0)?;
|
||||
if !view.valid() {
|
||||
bail!("invalid 3D view");
|
||||
}
|
||||
}
|
||||
Self::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
@@ -505,8 +711,38 @@ impl RenderCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。
|
||||
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
|
||||
Ok(match self {
|
||||
Self::Image {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
offset_x,
|
||||
offset_y,
|
||||
angle,
|
||||
opacity,
|
||||
tint,
|
||||
image,
|
||||
} => DrawCommand::Image {
|
||||
image: image.clone(),
|
||||
origin: Point::new(x * scale, y * scale),
|
||||
rect: scaled_rect(*offset_x, *offset_y, *width, *height, scale),
|
||||
angle: *angle,
|
||||
opacity: *opacity,
|
||||
tint: *tint,
|
||||
},
|
||||
Self::Scene3D {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
view,
|
||||
} => DrawCommand::Scene3D {
|
||||
rect: scaled_rect(*x, *y, *width, *height, scale),
|
||||
view: view.clone(),
|
||||
},
|
||||
Self::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
@@ -558,14 +794,17 @@ impl RenderCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提供未指定描边宽度时的兼容默认值。
|
||||
fn default_stroke_width() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// 提供未指定字号时的协议默认值。
|
||||
fn default_text_size() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// 统一检查有限矩形坐标、尺寸与可选圆角,阻止异常值进入光栅循环。
|
||||
fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result<()> {
|
||||
if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() {
|
||||
bail!("rectangle values must be finite");
|
||||
@@ -576,10 +815,12 @@ fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将逻辑矩形转换为设备比例下的矩形,保持位置与尺寸同比缩放。
|
||||
fn scaled_rect(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Rect {
|
||||
Rect::new(x * scale, y * scale, width * scale, height * scale)
|
||||
}
|
||||
|
||||
/// 解析支持的十六进制 RGB/RGBA 文本,拒绝错误长度或非法字符。
|
||||
pub fn parse_color(value: &str) -> Result<Color> {
|
||||
let hex = value
|
||||
.strip_prefix('#')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod binding;
|
||||
pub(crate) mod block;
|
||||
@@ -6,5 +8,6 @@ pub(crate) mod mount;
|
||||
pub(crate) mod object_list;
|
||||
pub(crate) mod presentation;
|
||||
pub(crate) mod protocols;
|
||||
pub(crate) mod scene3d;
|
||||
pub(crate) mod snapshot;
|
||||
pub(crate) mod window;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。
|
||||
//! Mount 保存父子关系、出现几何和布局期间的查询上下文;MountTable 用弱索引定位对象,不靠索引强行保活业务树。
|
||||
//! 同步会安排 onMount/onUnmount。布局取消时使用 Checkpoint 恢复未发布状态,避免半完成的出现关系影响下一次刷新。
|
||||
//!
|
||||
//! Managed presentation contexts. The active desktop owns the mount dictionary;
|
||||
//! reverse links and the host registry are weak, so shared content cannot retain a Page.
|
||||
use crate::document::{Frame, MountGeometry};
|
||||
@@ -11,16 +15,20 @@ use std::{
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 一个组件在一个展示位置上的上下文。强持有 Block,反向页面/父子链接使用弱身份,避免共享组件把已关闭页面保活。
|
||||
pub struct Mount {
|
||||
// 组件是强引用;页面、父节点和兄弟链接是弱引用,防止反向链接形成保活环。
|
||||
pub block: ObjectRef,
|
||||
pub page: Option<WeakObjectRef>,
|
||||
pub parent: Option<WeakObjectRef>,
|
||||
pub first_child: Option<WeakObjectRef>,
|
||||
pub next_sibling: Option<WeakObjectRef>,
|
||||
// 已发布几何与每次出现的滚动位置分开保存,同一 Block 的多个位置互不覆盖。
|
||||
pub geometry: MountGeometry,
|
||||
pub scroll_offset: [f32; 2],
|
||||
pub version: u64,
|
||||
pub mounted: bool,
|
||||
// Mount 可以拥有任务;退役后任务交付还要检查 owner 是否仍有效。
|
||||
pub tasks: Option<ObjectRef>,
|
||||
// Private derived scratch, installed before the read-only layout call and
|
||||
// cleared by geometry commit. It contains no managed references or roots.
|
||||
@@ -28,18 +36,23 @@ pub struct Mount {
|
||||
}
|
||||
|
||||
impl ObjectPayload for Mount {
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-mount"
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
visit(self.block);
|
||||
if let Some(tasks) = self.tasks {
|
||||
@@ -49,12 +62,17 @@ impl ObjectPayload for Mount {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
|
||||
enum Query {
|
||||
Bounds,
|
||||
ScrollTo,
|
||||
ScrollBy,
|
||||
ScrollPosition,
|
||||
ScrollIntoView,
|
||||
FitView,
|
||||
Pick3D,
|
||||
ToView,
|
||||
ToLocal,
|
||||
Parent,
|
||||
FirstChild,
|
||||
NextSibling,
|
||||
@@ -64,6 +82,7 @@ enum Query {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
|
||||
struct Method {
|
||||
signature: endor_core::CallSignature,
|
||||
owner: ObjectRef,
|
||||
@@ -72,14 +91,21 @@ struct Method {
|
||||
}
|
||||
|
||||
impl NativeCallable for Method {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(
|
||||
if matches!(
|
||||
self.query,
|
||||
Query::HitTest | Query::ScrollTo | Query::ScrollBy
|
||||
Query::HitTest
|
||||
| Query::Pick3D
|
||||
| Query::ToView
|
||||
| Query::ToLocal
|
||||
| Query::ScrollTo
|
||||
| Query::ScrollBy
|
||||
) {
|
||||
2
|
||||
} else {
|
||||
@@ -88,6 +114,7 @@ impl NativeCallable for Method {
|
||||
))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mount = cx.store().payload::<Mount>(self.owner, "Mount")?.clone();
|
||||
if !mount.mounted
|
||||
@@ -99,6 +126,10 @@ impl NativeCallable for Method {
|
||||
| Query::ScrollTo
|
||||
| Query::ScrollBy
|
||||
| Query::ScrollIntoView
|
||||
| Query::FitView
|
||||
| Query::Pick3D
|
||||
| Query::ToView
|
||||
| Query::ToLocal
|
||||
| Query::ScrollPosition
|
||||
)
|
||||
{
|
||||
@@ -106,7 +137,12 @@ impl NativeCallable for Method {
|
||||
}
|
||||
let arity = if matches!(
|
||||
self.query,
|
||||
Query::HitTest | Query::ScrollTo | Query::ScrollBy
|
||||
Query::HitTest
|
||||
| Query::Pick3D
|
||||
| Query::ToView
|
||||
| Query::ToLocal
|
||||
| Query::ScrollTo
|
||||
| Query::ScrollBy
|
||||
) {
|
||||
2
|
||||
} else {
|
||||
@@ -116,6 +152,50 @@ impl NativeCallable for Method {
|
||||
return Err(VmError::InvalidArgument("incorrect Mount method arity"));
|
||||
}
|
||||
let value = match self.query {
|
||||
Query::ToView | Query::ToLocal => {
|
||||
if mount.layout.is_some() {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"coordinate conversion requires committed geometry",
|
||||
));
|
||||
}
|
||||
let point = crate::geometry::Point::new(
|
||||
crate::scroll::coordinate(&args[0])?,
|
||||
crate::scroll::coordinate(&args[1])?,
|
||||
);
|
||||
let point =
|
||||
if matches!(self.query, Query::ToView) {
|
||||
let point = mount.geometry.view.unwrap_or_default().point(
|
||||
crate::geometry::Point::new(
|
||||
point.x + mount.geometry.x,
|
||||
point.y + mount.geometry.y,
|
||||
),
|
||||
);
|
||||
mount
|
||||
.geometry
|
||||
.panel
|
||||
.as_ref()
|
||||
.map_or(Some(point), |panel| panel.project_point(point))
|
||||
} else {
|
||||
mount.geometry.logical_point(point).map(|point| {
|
||||
crate::geometry::Point::new(
|
||||
point.x - mount.geometry.x,
|
||||
point.y - mount.geometry.y,
|
||||
)
|
||||
})
|
||||
};
|
||||
if let Some(point) = point {
|
||||
let result = cx.new_object()?;
|
||||
let desktop = cx.upgrade(self.desktop)?.ok_or(VmError::InvalidArgument(
|
||||
"coordinate conversion desktop expired",
|
||||
))?;
|
||||
crate::ui_bridge::attach_desktop_type(cx, result, "Point", desktop)?;
|
||||
cx.set_attr(result, "x", crate::native_visual::value(point.x))?;
|
||||
cx.set_attr(result, "y", crate::native_visual::value(point.y))?;
|
||||
Value::Object(result)
|
||||
} else {
|
||||
Value::Nil
|
||||
}
|
||||
}
|
||||
Query::ScrollTo | Query::ScrollBy => {
|
||||
crate::scroll::set(
|
||||
cx,
|
||||
@@ -133,6 +213,24 @@ impl NativeCallable for Method {
|
||||
crate::scroll::into_view(cx, self.owner, self.desktop)?;
|
||||
Value::Nil
|
||||
}
|
||||
Query::FitView => {
|
||||
crate::viewport::fit(cx, &mount)?;
|
||||
Value::Nil
|
||||
}
|
||||
Query::Pick3D => {
|
||||
let desktop = cx
|
||||
.upgrade(self.desktop)?
|
||||
.ok_or(VmError::InvalidArgument("viewport desktop expired"))?;
|
||||
crate::scene3d_model::pick(
|
||||
cx,
|
||||
desktop,
|
||||
&mount,
|
||||
crate::geometry::Point::new(
|
||||
crate::scroll::coordinate(&args[0])?,
|
||||
crate::scroll::coordinate(&args[1])?,
|
||||
),
|
||||
)?
|
||||
}
|
||||
Query::ScrollPosition => {
|
||||
let p = mount
|
||||
.geometry
|
||||
@@ -244,6 +342,7 @@ impl NativeCallable for Method {
|
||||
}
|
||||
}
|
||||
|
||||
/// 声明 Mount 查询方法的源类型契约,区分几何、层次与坐标操作。
|
||||
fn mount_signature(
|
||||
store: &endor_core::ObjectStore,
|
||||
desktop: ObjectRef,
|
||||
@@ -258,18 +357,28 @@ fn mount_signature(
|
||||
}
|
||||
Query::Page => CallType::nullable(nominal("Page")?),
|
||||
Query::HitTest => T::Bool.into(),
|
||||
Query::Pick3D => CallType::nullable(nominal("RayHit3D")?),
|
||||
Query::ToView | Query::ToLocal => CallType::nullable(nominal("Point")?),
|
||||
Query::ScrollPosition => T::Any.into(),
|
||||
_ => T::Unit.into(),
|
||||
};
|
||||
let effect = if matches!(
|
||||
query,
|
||||
Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::Focus
|
||||
Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::FitView | Query::Focus
|
||||
) {
|
||||
CallEffect::Mutating
|
||||
} else {
|
||||
CallEffect::ReadOnly
|
||||
};
|
||||
let parameters = if matches!(query, Query::HitTest | Query::ScrollTo | Query::ScrollBy) {
|
||||
let parameters = if matches!(
|
||||
query,
|
||||
Query::HitTest
|
||||
| Query::Pick3D
|
||||
| Query::ToView
|
||||
| Query::ToLocal
|
||||
| Query::ScrollTo
|
||||
| Query::ScrollBy
|
||||
) {
|
||||
vec![T::Number, T::Number]
|
||||
} else {
|
||||
vec![]
|
||||
@@ -280,12 +389,14 @@ fn mount_signature(
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
/// 活动出现的弱索引。真实托管拥有关系在 desktop 的挂载字典中,索引不能充当隐藏 GC 根。
|
||||
pub struct MountTable {
|
||||
pub active: HashMap<u64, WeakObjectRef>,
|
||||
order: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 同步出现关系时的共享临时工作状态,保存本轮重用、建立和退役安排。
|
||||
struct Sync {
|
||||
desktop: ObjectRef,
|
||||
page: Option<ObjectRef>,
|
||||
@@ -298,6 +409,7 @@ struct Sync {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 将一个生命周期回调及参数接入同一 VM 调用序列。
|
||||
struct LifecycleCall {
|
||||
function: ObjectRef,
|
||||
block: ObjectRef,
|
||||
@@ -305,10 +417,12 @@ struct LifecycleCall {
|
||||
unmount: bool,
|
||||
}
|
||||
impl NativeCallable for LifecycleCall {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
if self.unmount {
|
||||
crate::tasks::cancel_owner(cx, self.mount)?;
|
||||
@@ -320,6 +434,7 @@ impl NativeCallable for LifecycleCall {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造生命周期调用所需的托管函数与参数,使回调遵循同一 VM 的引用和执行约束。
|
||||
fn lifecycle_call(
|
||||
cx: &mut NativeContext<'_>,
|
||||
mount: ObjectRef,
|
||||
@@ -347,10 +462,12 @@ fn lifecycle_call(
|
||||
}
|
||||
|
||||
impl NativeCallable for Sync {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut blocks = HashMap::new();
|
||||
for weak in &self.nodes {
|
||||
@@ -405,6 +522,10 @@ impl NativeCallable for Sync {
|
||||
("scrollBy", Query::ScrollBy),
|
||||
("scrollPosition", Query::ScrollPosition),
|
||||
("scrollIntoView", Query::ScrollIntoView),
|
||||
("fitView", Query::FitView),
|
||||
("pick3D", Query::Pick3D),
|
||||
("toView", Query::ToView),
|
||||
("toLocal", Query::ToLocal),
|
||||
("parent", Query::Parent),
|
||||
("firstChild", Query::FirstChild),
|
||||
("nextSibling", Query::NextSibling),
|
||||
@@ -536,6 +657,7 @@ impl NativeCallable for Sync {
|
||||
|
||||
/// Explicitly owned roots protect the old presentation while preparation
|
||||
/// temporarily replaces its desktop dictionary and Mount payloads.
|
||||
/// 布局准备前的挂载快照与临时保留,用于取消恢复;成功提交后必须释放其保留引用。
|
||||
pub struct Checkpoint {
|
||||
table: MountTable,
|
||||
dictionary: Option<ObjectRef>,
|
||||
@@ -544,6 +666,7 @@ pub struct Checkpoint {
|
||||
}
|
||||
|
||||
impl Checkpoint {
|
||||
/// 释放检查点为回滚临时保留的托管根,成功提交后不再需要这些引用。
|
||||
pub fn release(self, vm: &mut Vm) -> Result<(), VmError> {
|
||||
let mut result = Ok(());
|
||||
for root in self.roots.into_iter().rev() {
|
||||
@@ -554,6 +677,7 @@ impl Checkpoint {
|
||||
result
|
||||
}
|
||||
|
||||
/// 恢复检查点中尚未发布的出现关系与布局上下文,并释放临时保留。
|
||||
pub fn restore(
|
||||
self,
|
||||
vm: &mut Vm,
|
||||
@@ -613,6 +737,7 @@ impl Checkpoint {
|
||||
}
|
||||
|
||||
impl MountTable {
|
||||
/// 保留布局准备前的 Mount 状态,供失败或取消路径恢复。
|
||||
pub fn checkpoint(&self, vm: &mut Vm, desktop: ObjectRef) -> Result<Checkpoint, VmError> {
|
||||
let dictionary = match vm.get_attr(desktop, "mounts") {
|
||||
Ok(Value::Object(dictionary)) => Some(dictionary),
|
||||
@@ -668,6 +793,7 @@ impl MountTable {
|
||||
|
||||
/// A faulted domain cannot run user onUnmount callbacks. Still detach every
|
||||
/// context and release its owning dictionary through the normal heap barrier.
|
||||
/// 退役当前出现表,撤销挂载关系并准备不再展示的生命周期状态。
|
||||
pub fn retire(&mut self, vm: &mut Vm, desktop: ObjectRef) -> Result<(), VmError> {
|
||||
for weak in self.ordered().into_iter().rev() {
|
||||
if let Some(object) = vm.upgrade(weak)? {
|
||||
@@ -694,6 +820,7 @@ impl MountTable {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按原始展示顺序返回弱 Mount 身份,供事件和协议驱动器保持稳定顺序。
|
||||
pub fn ordered(&self) -> Vec<WeakObjectRef> {
|
||||
self.order
|
||||
.iter()
|
||||
@@ -701,6 +828,7 @@ impl MountTable {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 根据新结构复用或建立每次出现的 Mount,连接父子关系并准备生命周期调用。
|
||||
pub fn synchronize(
|
||||
&mut self,
|
||||
vm: &mut Vm,
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
//! 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。
|
||||
//! 用于儿童组成、布局结果和几何输入等需要保留对象身份的场景;不是窗口线程共享的可变容器。
|
||||
//!
|
||||
//! Managed indexed host results; values and the at method are traced edges.
|
||||
use endor_core::{
|
||||
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
|
||||
Value, VmError,
|
||||
};
|
||||
#[derive(Debug)]
|
||||
/// 托管对象序列,trace 负责拥有其中的对象引用;源代码通过受检查方法读取。
|
||||
struct ObjectList(Vec<ObjectRef>);
|
||||
impl ObjectPayload for ObjectList {
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-object-list"
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
for mount in &self.0 {
|
||||
visit(*mount);
|
||||
@@ -26,8 +35,10 @@ impl ObjectPayload for ObjectList {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 绑定到某个列表对象的下标访问适配器。
|
||||
struct At(ObjectRef);
|
||||
impl NativeCallable for At {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(endor_core::CallSignature::exact(
|
||||
vec![endor_core::AnnotationType::Number],
|
||||
@@ -35,10 +46,12 @@ impl NativeCallable for At {
|
||||
endor_core::CallEffect::ReadOnly,
|
||||
))
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Number(index)] = args else {
|
||||
return Err(VmError::InvalidArgument("object list at expects an index"));
|
||||
@@ -57,6 +70,73 @@ impl NativeCallable for At {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Edit {
|
||||
owner: ObjectRef,
|
||||
desktop: ObjectRef,
|
||||
remove: bool,
|
||||
persistent: bool,
|
||||
extend: bool,
|
||||
}
|
||||
impl NativeCallable for Edit {
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut members = items(cx.store(), self.owner)?.to_vec();
|
||||
if self.extend {
|
||||
let [Value::Object(other)] = args else {
|
||||
return Err(VmError::InvalidArgument("extend expects ObjectList"));
|
||||
};
|
||||
let incoming = items(cx.store(), *other)?;
|
||||
if members.len() + incoming.len() > 10_000 {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"object list exceeds 10000 members",
|
||||
));
|
||||
}
|
||||
members.extend_from_slice(incoming);
|
||||
} else if self.remove {
|
||||
let [Value::Number(index)] = args else {
|
||||
return Err(VmError::InvalidArgument("remove expects index"));
|
||||
};
|
||||
let index = index
|
||||
.as_i64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|&i| i < members.len())
|
||||
.ok_or(VmError::InvalidArgument("list index out of range"))?;
|
||||
members.remove(index);
|
||||
} else {
|
||||
let [Value::Object(item)] = args else {
|
||||
return Err(VmError::InvalidArgument("append expects object"));
|
||||
};
|
||||
if members.len() >= 10_000 {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"object list exceeds 10000 members",
|
||||
));
|
||||
}
|
||||
members.push(*item);
|
||||
}
|
||||
if self.persistent {
|
||||
return Ok(NativeResult::Return(Value::Object(collection(
|
||||
cx,
|
||||
members,
|
||||
self.desktop,
|
||||
)?)));
|
||||
}
|
||||
let count = members.len();
|
||||
let first = members
|
||||
.first()
|
||||
.copied()
|
||||
.map(Value::Object)
|
||||
.unwrap_or(Value::Nil);
|
||||
cx.replace_payload(self.owner, ObjectList(members))?;
|
||||
cx.set_attr(self.owner, "count", Value::Number((count as i64).into()))?;
|
||||
cx.set_attr(self.owner, "first", first)?;
|
||||
Ok(NativeResult::Return(Value::Nil))
|
||||
}
|
||||
}
|
||||
|
||||
/// 将已有对象引用包装为新的 ObjectList,并安装默认名义类型及下标查询方法。
|
||||
pub fn collection(
|
||||
cx: &mut NativeContext<'_>,
|
||||
mounts: Vec<ObjectRef>,
|
||||
@@ -65,6 +145,7 @@ pub fn collection(
|
||||
build(cx, mounts, desktop, None)
|
||||
}
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
fn build(
|
||||
cx: &mut NativeContext<'_>,
|
||||
mounts: Vec<ObjectRef>,
|
||||
@@ -93,26 +174,51 @@ fn build(
|
||||
let at = cx.allocate(ContextNativeFunction::new("at", At(set), vec![set]))?;
|
||||
crate::ui_bridge::attach_desktop_type(cx, at, "Function", desktop)?;
|
||||
cx.set_attr(set, "at", Value::Object(at))?;
|
||||
for (name, remove, persistent, extend) in [
|
||||
("append", false, false, false),
|
||||
("remove", true, false, false),
|
||||
("added", false, true, false),
|
||||
("extend", false, false, true),
|
||||
] {
|
||||
let method = cx.allocate(ContextNativeFunction::new(
|
||||
name,
|
||||
Edit {
|
||||
owner: set,
|
||||
desktop,
|
||||
remove,
|
||||
persistent,
|
||||
extend,
|
||||
},
|
||||
vec![set, desktop],
|
||||
))?;
|
||||
crate::ui_bridge::attach_desktop_type(cx, method, "Function", desktop)?;
|
||||
cx.set_attr(set, name, Value::Object(method))?;
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// 借用受检查列表的条目;返回值的有效期仍依赖原托管对象存活。
|
||||
pub fn items(store: &endor_core::ObjectStore, list: ObjectRef) -> Result<&[ObjectRef], VmError> {
|
||||
Ok(&store.payload::<ObjectList>(list, "ObjectList")?.0)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// ObjectList 原生类型的可调用构造器,负责检查传入子对象。
|
||||
pub struct Constructor {
|
||||
pub signature: Option<endor_core::CallSignature>,
|
||||
pub desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Constructor {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
self.signature.clone()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::range(1, usize::MAX))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(definition), values @ ..] = args else {
|
||||
return Err(VmError::InvalidArgument("ObjectList type argument"));
|
||||
@@ -122,10 +228,12 @@ impl NativeCallable for Constructor {
|
||||
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
|
||||
.fields()
|
||||
.to_vec();
|
||||
if fields
|
||||
.iter()
|
||||
.any(|field| matches!(field.name.as_str(), "count" | "first" | "at"))
|
||||
{
|
||||
if fields.iter().any(|field| {
|
||||
matches!(
|
||||
field.name.as_str(),
|
||||
"count" | "first" | "at" | "append" | "remove" | "added" | "extend"
|
||||
)
|
||||
}) {
|
||||
return Err(VmError::InvalidArgument("reserved ObjectList field"));
|
||||
}
|
||||
let count = values
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。
|
||||
//! 多个窗口仍使用同一个 VM 和共享业务对象。读取版本失效时只标记相关展示需要刷新,不能因此复制一份业务状态。
|
||||
//!
|
||||
//! Per-window rendering state. Business objects and code remain in one VM.
|
||||
//! Registries and caches borrow checked identities; the managed desktop owns
|
||||
//! presentation roots through its pending/mount dictionaries.
|
||||
@@ -6,6 +9,7 @@ use endor_core::{ChangeBatch, NativeContext, ObjectRef, Vm, VmError, WeakObjectR
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 一个窗口独立的展示状态:活动页面、待提交请求、Mount、布局与源读取缓存;共享 VM 的业务状态不放在这里复制。
|
||||
pub(crate) struct Presentation {
|
||||
pub desktop: ObjectRef,
|
||||
pub closing: bool,
|
||||
@@ -16,6 +20,7 @@ pub(crate) struct Presentation {
|
||||
pub tab_ids: Vec<(WeakObjectRef, u64)>,
|
||||
pub next_tab: u64,
|
||||
pub last_tabs: Vec<crate::tabs::Tab>,
|
||||
pub surface: crate::page::Surface,
|
||||
pub render_cache: crate::snapshot::RenderCache,
|
||||
pub layout: crate::layout::LayoutRuntime,
|
||||
pub mounts: crate::mount::MountTable,
|
||||
@@ -33,6 +38,7 @@ pub(crate) struct Presentation {
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
/// 比较布局/绘制读取版本和名称依赖,标记需要刷新并通知快照缓存。
|
||||
pub fn observe_changes(&mut self, vm: &Vm, changes: &ChangeBatch) {
|
||||
if let Some((phase, object, version)) = self
|
||||
.layout_reads
|
||||
@@ -81,6 +87,7 @@ impl Presentation {
|
||||
});
|
||||
}
|
||||
|
||||
/// 按尺寸、内容或绘制失效原因清理对应缓存,避免把所有变化都当成相同重建。
|
||||
pub fn invalidate(
|
||||
&mut self,
|
||||
cx: &mut NativeContext<'_>,
|
||||
@@ -111,6 +118,7 @@ impl Presentation {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self {
|
||||
Self {
|
||||
desktop,
|
||||
@@ -122,6 +130,7 @@ impl Presentation {
|
||||
tab_ids: Vec::new(),
|
||||
next_tab: 0,
|
||||
last_tabs: Vec::new(),
|
||||
surface: crate::page::Surface::default(),
|
||||
render_cache: crate::snapshot::RenderCache::default(),
|
||||
layout: crate::layout::LayoutRuntime::default(),
|
||||
mounts: crate::mount::MountTable::default(),
|
||||
@@ -140,6 +149,7 @@ impl Presentation {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把读取记录拆成对象版本和精确名称依赖,便于按实际观察内容判断失效。
|
||||
pub(crate) fn split_reads(
|
||||
reads: Vec<endor_core::ReadDependency>,
|
||||
) -> (
|
||||
@@ -177,13 +187,23 @@ mod tests {
|
||||
height: 160.0,
|
||||
});
|
||||
let root = UiNode {
|
||||
surface: None,
|
||||
flex: 0.0,
|
||||
flex_policy: crate::layout::FlexPolicy::default(),
|
||||
main_align: crate::layout::MainAlign::Start,
|
||||
cross_align: crate::layout::CrossAlign::Start,
|
||||
align_self: None,
|
||||
margin: crate::layout::Insets::default(),
|
||||
sizing: crate::box_model::Sizing::default(),
|
||||
padding_edges: [None; 4],
|
||||
border: crate::layout::Insets::default(),
|
||||
border_color: "#D8DCE2".into(),
|
||||
clip_children: false,
|
||||
block_id: "shared".into(),
|
||||
protocol_key: "rect".into(),
|
||||
slots: Vec::new(),
|
||||
interactive: false,
|
||||
pointer_events: false,
|
||||
kind: UiKind::Rect {
|
||||
width: 500.0,
|
||||
height: 500.0,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。
|
||||
//! 契约描述对象能响应什么源方法;具体测量、绘制、事件执行仍在各自协议驱动器中完成。
|
||||
//!
|
||||
//! Host capability definitions over Aster's explicit managed trait relations.
|
||||
use endor_core::{
|
||||
AnnotationType, CallArity, CallEffect, CallSignature, ObjectRef, TypedMethodRequirement, Value,
|
||||
Vm, VmError,
|
||||
};
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
//! 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。
|
||||
//! 构造器和字段仍属于 VM;快照复制必要的数值、不可变网格和依赖,渲染器不读取托管堆。
|
||||
//! 场景遍历组合局部变换、检查环和资源限制,并为 Panel3D 提取独立 UI 根。控制器更新相机;拾取结果再映射回托管节点。
|
||||
//!
|
||||
//! Traced 3D objects and checked immutable snapshots. No managed handle enters
|
||||
//! the renderer; cameras and node transforms remain editable Endor attributes.
|
||||
use crate::{
|
||||
object_list,
|
||||
scene3d::{
|
||||
self,
|
||||
math::{Camera, Matrix, Projection, Vec3},
|
||||
},
|
||||
};
|
||||
use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, VmError};
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 源 3D 类型的构造元数据入口,统一管理名称、参数、数量和可变子项。
|
||||
pub enum Constructor {
|
||||
Vec3,
|
||||
Triangle,
|
||||
Geometry,
|
||||
Mesh,
|
||||
Box,
|
||||
Group,
|
||||
Scene,
|
||||
Light,
|
||||
Perspective,
|
||||
Orthographic,
|
||||
Orbit,
|
||||
Panel,
|
||||
}
|
||||
impl Constructor {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vec3 => "Vec3",
|
||||
Self::Triangle => "Triangle3D",
|
||||
Self::Geometry => "Geometry3D",
|
||||
Self::Mesh => "Mesh3D",
|
||||
Self::Box => "Box3D",
|
||||
Self::Group => "Group3D",
|
||||
Self::Scene => "Scene3D",
|
||||
Self::Light => "DirectionalLight",
|
||||
Self::Perspective => "PerspectiveCamera",
|
||||
Self::Orthographic => "OrthographicCamera",
|
||||
Self::Orbit => "OrbitController",
|
||||
Self::Panel => "Panel3D",
|
||||
}
|
||||
}
|
||||
/// 取得本构造器对应的源工厂函数名称。
|
||||
pub fn function(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vec3 => "vec3",
|
||||
Self::Triangle => "triangle3D",
|
||||
Self::Geometry => "geometry3D",
|
||||
Self::Mesh => "mesh3D",
|
||||
Self::Box => "box3D",
|
||||
Self::Group => "group3D",
|
||||
Self::Scene => "scene3D",
|
||||
Self::Light => "directionalLight",
|
||||
Self::Perspective => "perspectiveCamera",
|
||||
Self::Orthographic => "orthographicCamera",
|
||||
Self::Orbit => "orbitController",
|
||||
Self::Panel => "panel3D",
|
||||
}
|
||||
}
|
||||
/// 反查源构造器名称对应的原生 3D 种类,不认识的名称返回空值。
|
||||
pub fn from_function(name: &str) -> Option<Self> {
|
||||
[
|
||||
Self::Vec3,
|
||||
Self::Triangle,
|
||||
Self::Geometry,
|
||||
Self::Mesh,
|
||||
Self::Box,
|
||||
Self::Group,
|
||||
Self::Scene,
|
||||
Self::Light,
|
||||
Self::Perspective,
|
||||
Self::Orthographic,
|
||||
Self::Orbit,
|
||||
Self::Panel,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|kind| kind.function() == name)
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
pub fn arity(self) -> (usize, usize) {
|
||||
match self {
|
||||
Self::Vec3 | Self::Triangle => (3, 3),
|
||||
Self::Geometry | Self::Mesh => (2, 2),
|
||||
Self::Box => (4, 4),
|
||||
Self::Group | Self::Scene => (0, 4096),
|
||||
Self::Light => (5, 5),
|
||||
Self::Panel => (3, 3),
|
||||
_ => (0, 0),
|
||||
}
|
||||
}
|
||||
/// 列出本构造器的参数类型名称,供签名安装与源调用检查使用。
|
||||
pub fn parameters(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Vec3 | Self::Triangle => &["Number", "Number", "Number"],
|
||||
Self::Geometry => &["ObjectList", "ObjectList"],
|
||||
Self::Mesh => &["Geometry3D", "String"],
|
||||
Self::Box => &["Number", "Number", "Number", "String"],
|
||||
Self::Light => &["Number", "Number", "Number", "String", "Number"],
|
||||
Self::Panel => &["Number", "Number", "Block"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// 标记构造器是否接收数量可变的子对象。
|
||||
pub fn variadic(self) -> bool {
|
||||
matches!(self, Self::Group | Self::Scene)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 托管 3D 节点的名义种类,防止仅有同名字段的任意对象冒充场景节点。
|
||||
pub enum NodeKind {
|
||||
Vector,
|
||||
Triangle,
|
||||
Mesh,
|
||||
Group,
|
||||
Scene,
|
||||
Light,
|
||||
Perspective,
|
||||
Orthographic,
|
||||
Orbit,
|
||||
Panel,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 允许普通属性的 3D 标记 payload;数值字段仍由托管属性系统拥有。
|
||||
pub struct Node(pub NodeKind);
|
||||
impl ObjectPayload for Node {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"scene-3d-object"
|
||||
}
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 托管不可变网格包装,顶点和三角形数值已经复制为原生 Arc 数据。
|
||||
pub struct Geometry(pub Arc<scene3d::Geometry>);
|
||||
impl ObjectPayload for Geometry {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"geometry-3d"
|
||||
}
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 把源 Value 转换为受检查的托管对象引用,拒绝非对象输入。
|
||||
fn object_ref(value: &Value) -> Result<ObjectRef, VmError> {
|
||||
if let Value::Object(object) = value {
|
||||
Ok(*object)
|
||||
} else {
|
||||
Err(invalid("3D field requires an object"))
|
||||
}
|
||||
}
|
||||
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
|
||||
fn number(value: &Value) -> Result<f32, VmError> {
|
||||
crate::scroll::coordinate(value)
|
||||
}
|
||||
/// 把当前源值或文本选择解码成此调用需要的文本表示。
|
||||
fn text(value: &Value) -> Result<String, VmError> {
|
||||
if let Value::Text(text) = value {
|
||||
Ok(text.clone())
|
||||
} else {
|
||||
Err(invalid("3D field requires text"))
|
||||
}
|
||||
}
|
||||
/// 把经过转换的数值写回指定托管字段,仍通过 VM/NativeContext 的检查入口。
|
||||
fn set_number(
|
||||
cx: &mut NativeContext<'_>,
|
||||
object: ObjectRef,
|
||||
name: &str,
|
||||
value: f32,
|
||||
) -> Result<(), VmError> {
|
||||
cx.set_attr(object, name, crate::native_visual::value(value))
|
||||
}
|
||||
/// 解析颜色值并检查其满足当前原生绘制约定。
|
||||
fn color(value: &str) -> Result<[f32; 3], VmError> {
|
||||
let color = crate::document::parse_color(value).map_err(|_| invalid("invalid 3D color"))?;
|
||||
if color.alpha != 255 {
|
||||
return Err(invalid("3D opaque material color requires full alpha"));
|
||||
}
|
||||
Ok([
|
||||
color.red as f32 / 255.,
|
||||
color.green as f32 / 255.,
|
||||
color.blue as f32 / 255.,
|
||||
])
|
||||
}
|
||||
|
||||
/// 按构造器建立对应托管 3D 类型,检查几何输入并安装默认属性。
|
||||
pub fn allocate(
|
||||
cx: &mut NativeContext<'_>,
|
||||
desktop: ObjectRef,
|
||||
definition: ObjectRef,
|
||||
constructor: Constructor,
|
||||
args: &[Value],
|
||||
) -> Result<ObjectRef, VmError> {
|
||||
let kind = match constructor {
|
||||
Constructor::Vec3 => NodeKind::Vector,
|
||||
Constructor::Triangle => NodeKind::Triangle,
|
||||
Constructor::Mesh | Constructor::Box => NodeKind::Mesh,
|
||||
Constructor::Group => NodeKind::Group,
|
||||
Constructor::Scene => NodeKind::Scene,
|
||||
Constructor::Light => NodeKind::Light,
|
||||
Constructor::Perspective => NodeKind::Perspective,
|
||||
Constructor::Orthographic => NodeKind::Orthographic,
|
||||
Constructor::Orbit => NodeKind::Orbit,
|
||||
Constructor::Panel => NodeKind::Panel,
|
||||
Constructor::Geometry => {
|
||||
let vertices = object_list::items(cx.store(), object_ref(&args[0])?)?
|
||||
.iter()
|
||||
.map(|&item| {
|
||||
let p = cx.store();
|
||||
if p.payload::<Node>(item, "Vec3")?.0 != NodeKind::Vector {
|
||||
return Err(invalid("geometry vertices require Vec3"));
|
||||
}
|
||||
Ok(Vec3::new(
|
||||
number(&p.get_attr(item, "x")?)?,
|
||||
number(&p.get_attr(item, "y")?)?,
|
||||
number(&p.get_attr(item, "z")?)?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, VmError>>()?;
|
||||
let triangles = object_list::items(cx.store(), object_ref(&args[1])?)?
|
||||
.iter()
|
||||
.map(|&item| {
|
||||
if cx.store().payload::<Node>(item, "Triangle3D")?.0 != NodeKind::Triangle {
|
||||
return Err(invalid("geometry indices require Triangle3D"));
|
||||
}
|
||||
let mut triangle = [0; 3];
|
||||
for (index, name) in ["a", "b", "c"].into_iter().enumerate() {
|
||||
let value = number(&cx.store().get_attr(item, name)?)?;
|
||||
if value < 0. || value.fract() != 0. {
|
||||
return Err(invalid("triangle indices must be nonnegative integers"));
|
||||
}
|
||||
triangle[index] = value as u32;
|
||||
}
|
||||
Ok(triangle)
|
||||
})
|
||||
.collect::<Result<Vec<_>, VmError>>()?;
|
||||
let geometry = scene3d::Geometry {
|
||||
vertices,
|
||||
triangles,
|
||||
uv: vec![],
|
||||
};
|
||||
if !geometry.valid() {
|
||||
return Err(invalid(
|
||||
"invalid Geometry3D buffers or geometry budget exceeded",
|
||||
));
|
||||
}
|
||||
let object = cx.allocate(Geometry(Arc::new(geometry)))?;
|
||||
cx.attach_type(object, definition)?;
|
||||
return Ok(object);
|
||||
}
|
||||
};
|
||||
let instance = cx.allocate(Node(kind))?;
|
||||
cx.attach_type(instance, definition)?;
|
||||
match kind {
|
||||
NodeKind::Vector | NodeKind::Triangle => {
|
||||
for (name, value) in (if kind == NodeKind::Vector {
|
||||
["x", "y", "z"]
|
||||
} else {
|
||||
["a", "b", "c"]
|
||||
})
|
||||
.into_iter()
|
||||
.zip(args)
|
||||
{
|
||||
set_number(cx, instance, name, number(value)?)?;
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh => {
|
||||
let (geometry, material) = if matches!(constructor, Constructor::Box) {
|
||||
let (w, h, d) = (number(&args[0])?, number(&args[1])?, number(&args[2])?);
|
||||
if w < 0. || h < 0. || d < 0. {
|
||||
return Err(invalid("Box3D dimensions must be nonnegative"));
|
||||
}
|
||||
let geometry =
|
||||
cx.allocate(Geometry(Arc::new(scene3d::Geometry::cuboid(w, h, d))))?;
|
||||
crate::ui_bridge::attach_desktop_type(cx, geometry, "Geometry3D", desktop)?;
|
||||
(geometry, text(&args[3])?)
|
||||
} else {
|
||||
(object_ref(&args[0])?, text(&args[1])?)
|
||||
};
|
||||
cx.store().payload::<Geometry>(geometry, "Geometry3D")?;
|
||||
color(&material)?;
|
||||
cx.set_attr(instance, "geometry", Value::Object(geometry))?;
|
||||
cx.set_attr(instance, "color", Value::Text(material))?;
|
||||
}
|
||||
NodeKind::Group | NodeKind::Scene => {
|
||||
let children = args.iter().map(object_ref).collect::<Result<Vec<_>, _>>()?;
|
||||
for child in &children {
|
||||
if !matches!(
|
||||
cx.store().payload::<Node>(*child, "3D node")?.0,
|
||||
NodeKind::Mesh | NodeKind::Group | NodeKind::Light | NodeKind::Panel
|
||||
) {
|
||||
return Err(invalid(
|
||||
"Scene3D/Group3D children require meshes, groups or lights",
|
||||
));
|
||||
}
|
||||
}
|
||||
let children = object_list::collection(cx, children, desktop)?;
|
||||
cx.set_attr(instance, "children", Value::Object(children))?;
|
||||
if kind == NodeKind::Scene {
|
||||
set_number(cx, instance, "ambient", 0.25)?;
|
||||
cx.set_attr(instance, "background", Value::Text("#F1F3F6".into()))?;
|
||||
}
|
||||
}
|
||||
NodeKind::Light => {
|
||||
for (name, value) in ["directionX", "directionY", "directionZ"]
|
||||
.into_iter()
|
||||
.zip(args)
|
||||
{
|
||||
set_number(cx, instance, name, number(value)?)?;
|
||||
}
|
||||
color(&text(&args[3])?)?;
|
||||
cx.set_attr(instance, "color", args[3].clone())?;
|
||||
set_number(cx, instance, "intensity", number(&args[4])?)?;
|
||||
}
|
||||
NodeKind::Panel => {
|
||||
let content = object_ref(&args[2])?;
|
||||
crate::block::validate(cx.store(), content, None)?;
|
||||
set_number(cx, instance, "width", number(&args[0])?)?;
|
||||
set_number(cx, instance, "height", number(&args[1])?)?;
|
||||
set_number(cx, instance, "uiWidth", 512.)?;
|
||||
set_number(cx, instance, "uiHeight", 320.)?;
|
||||
cx.set_attr(instance, "content", Value::Object(content))?;
|
||||
cx.set_attr(instance, "background", Value::Text("#FFFFFF".into()))?;
|
||||
}
|
||||
NodeKind::Perspective | NodeKind::Orthographic => {
|
||||
for (name, value) in [
|
||||
("x", 0.),
|
||||
("y", 0.),
|
||||
("z", 5.),
|
||||
("targetX", 0.),
|
||||
("targetY", 0.),
|
||||
("targetZ", 0.),
|
||||
("upX", 0.),
|
||||
("upY", 1.),
|
||||
("upZ", 0.),
|
||||
("near", 0.1),
|
||||
("far", 1000.),
|
||||
("fov", 50.),
|
||||
("height", 5.),
|
||||
] {
|
||||
set_number(cx, instance, name, value)?;
|
||||
}
|
||||
}
|
||||
NodeKind::Orbit => {
|
||||
set_number(cx, instance, "rotationSpeed", 0.005)?;
|
||||
set_number(cx, instance, "panSpeed", 1.)?;
|
||||
}
|
||||
}
|
||||
if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
|
||||
for (name, value) in [
|
||||
("x", 0.),
|
||||
("y", 0.),
|
||||
("z", 0.),
|
||||
("rotationX", 0.),
|
||||
("rotationY", 0.),
|
||||
("rotationZ", 0.),
|
||||
("scaleX", 1.),
|
||||
("scaleY", 1.),
|
||||
("scaleZ", 1.),
|
||||
] {
|
||||
set_number(cx, instance, name, value)?;
|
||||
}
|
||||
}
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// 快照字段读取器,同时记录实际观察到的对象/属性依赖。
|
||||
struct Reader<'a> {
|
||||
store: &'a ObjectStore,
|
||||
reads: HashSet<(ObjectRef, String)>,
|
||||
}
|
||||
impl Reader<'_> {
|
||||
/// 读取字段并登记依赖,使后续字段变更能够使 3D 快照失效。
|
||||
fn value(&mut self, object: ObjectRef, name: &str) -> Result<Value, VmError> {
|
||||
self.reads.insert((object, name.into()));
|
||||
self.store.get_attr(object, name)
|
||||
}
|
||||
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
|
||||
fn number(&mut self, object: ObjectRef, name: &str) -> Result<f32, VmError> {
|
||||
number(&self.value(object, name)?)
|
||||
}
|
||||
/// 连续读取三个数值字段,组成位置、旋转或缩放向量。
|
||||
fn vector(&mut self, object: ObjectRef, names: [&str; 3]) -> Result<Vec3, VmError> {
|
||||
Ok(Vec3::new(
|
||||
self.number(object, names[0])?,
|
||||
self.number(object, names[1])?,
|
||||
self.number(object, names[2])?,
|
||||
))
|
||||
}
|
||||
/// 读取并校验透视或正交摄像机字段,生成不持有 VM 引用的投影描述。
|
||||
fn camera(&mut self, object: ObjectRef) -> Result<Camera, VmError> {
|
||||
let projection = match self.store.payload::<Node>(object, "3D camera")?.0 {
|
||||
NodeKind::Perspective => Projection::Perspective {
|
||||
fov: self.number(object, "fov")?,
|
||||
},
|
||||
NodeKind::Orthographic => Projection::Orthographic {
|
||||
height: self.number(object, "height")?,
|
||||
},
|
||||
_ => {
|
||||
return Err(invalid(
|
||||
"Viewport3D requires a PerspectiveCamera or OrthographicCamera",
|
||||
));
|
||||
}
|
||||
};
|
||||
let camera = Camera {
|
||||
position: self.vector(object, ["x", "y", "z"])?,
|
||||
target: self.vector(object, ["targetX", "targetY", "targetZ"])?,
|
||||
up: self.vector(object, ["upX", "upY", "upZ"])?,
|
||||
near: self.number(object, "near")?,
|
||||
far: self.number(object, "far")?,
|
||||
projection,
|
||||
};
|
||||
if !camera.valid() {
|
||||
return Err(invalid("invalid 3D camera basis or projection range"));
|
||||
}
|
||||
Ok(camera)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从场景中提取的有限 UI 根,携带稳定面板 key 和逻辑排版尺寸。
|
||||
pub struct PanelMount {
|
||||
pub key: u64,
|
||||
pub content: ObjectRef,
|
||||
pub size: crate::layout::Size,
|
||||
}
|
||||
pub type Snapshot = (
|
||||
Arc<scene3d::View>,
|
||||
Vec<(ObjectRef, String)>,
|
||||
Vec<PanelMount>,
|
||||
);
|
||||
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
|
||||
pub fn snapshot(
|
||||
store: &ObjectStore,
|
||||
scene: ObjectRef,
|
||||
camera: ObjectRef,
|
||||
) -> Result<Snapshot, VmError> {
|
||||
snapshot_reusing(store, scene, camera, None)
|
||||
}
|
||||
/// 遍历场景并记录字段依赖;如果世界数据未变则复用不可变场景和 BVH,只替换摄像机视图。
|
||||
pub fn snapshot_reusing(
|
||||
store: &ObjectStore,
|
||||
scene: ObjectRef,
|
||||
camera: ObjectRef,
|
||||
reuse: Option<Arc<scene3d::Scene>>,
|
||||
) -> Result<Snapshot, VmError> {
|
||||
if store.payload::<Node>(scene, "Scene3D")?.0 != NodeKind::Scene {
|
||||
return Err(invalid("Viewport3D requires a Scene3D"));
|
||||
}
|
||||
let mut reader = Reader {
|
||||
store,
|
||||
reads: HashSet::new(),
|
||||
};
|
||||
let camera = reader.camera(camera)?;
|
||||
let ambient = reader.number(scene, "ambient")?;
|
||||
if !(0.0..=1.0).contains(&ambient) {
|
||||
return Err(invalid("Scene3D.ambient expects 0 through 1"));
|
||||
}
|
||||
let background = color(&text(&reader.value(scene, "background")?)?)?;
|
||||
let mut output = scene3d::Scene {
|
||||
meshes: Vec::new(),
|
||||
lights: Vec::new(),
|
||||
ambient,
|
||||
background,
|
||||
acceleration: Arc::default(),
|
||||
};
|
||||
let mut work = vec![(scene, Matrix::default(), false, 0u64)];
|
||||
let mut panels = Vec::new();
|
||||
let mut path = HashSet::new();
|
||||
let mut visits = 0;
|
||||
let mut triangles = 0;
|
||||
let mut vertices = 0;
|
||||
while let Some((object, parent, finish, key)) = work.pop() {
|
||||
if finish {
|
||||
path.remove(&object);
|
||||
continue;
|
||||
}
|
||||
visits += 1;
|
||||
if visits > 4096 {
|
||||
return Err(VmError::ExecutionLimit("3D scene occurrences"));
|
||||
}
|
||||
if !path.insert(object) {
|
||||
return Err(invalid("cyclic 3D scene graph"));
|
||||
}
|
||||
work.push((object, parent, true, key));
|
||||
let kind = store.payload::<Node>(object, "3D node")?.0;
|
||||
let world = if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
|
||||
parent.compose(Matrix::trs(
|
||||
reader.vector(object, ["x", "y", "z"])?,
|
||||
reader.vector(object, ["rotationX", "rotationY", "rotationZ"])?,
|
||||
reader.vector(object, ["scaleX", "scaleY", "scaleZ"])?,
|
||||
))
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
match kind {
|
||||
NodeKind::Scene | NodeKind::Group => {
|
||||
let list = object_ref(&reader.value(object, "children")?)?;
|
||||
for (index, child) in object_list::items(store, list)?.iter().enumerate().rev() {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
index.hash(&mut hasher);
|
||||
child.hash(&mut hasher);
|
||||
work.push((*child, world, false, hasher.finish()));
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh => {
|
||||
let geometry = object_ref(&reader.value(object, "geometry")?)?;
|
||||
let geometry = store.payload::<Geometry>(geometry, "Geometry3D")?.0.clone();
|
||||
triangles += geometry.triangles.len();
|
||||
vertices += geometry.vertices.len();
|
||||
if vertices > 262_144 {
|
||||
return Err(VmError::ExecutionLimit("3D scene vertices"));
|
||||
}
|
||||
if triangles > scene3d::MAX_TRIANGLES {
|
||||
return Err(VmError::ExecutionLimit("3D scene triangles"));
|
||||
}
|
||||
let material = color(&text(&reader.value(object, "color")?)?)?;
|
||||
output.meshes.push(scene3d::Mesh {
|
||||
identity: object.to_string(),
|
||||
geometry,
|
||||
world,
|
||||
color: material,
|
||||
panel: None,
|
||||
});
|
||||
}
|
||||
NodeKind::Panel => {
|
||||
// Arrangement slots cross Endor's signed integer API. A raw
|
||||
// u64 hash can turn negative when native Bounds are created.
|
||||
let key = key & i64::MAX as u64;
|
||||
if panels.iter().any(|panel: &PanelMount| panel.key == key) {
|
||||
return Err(invalid("colliding 3D UI occurrence identities"));
|
||||
}
|
||||
if panels.len() >= 64 {
|
||||
return Err(VmError::ExecutionLimit("3D UI panels"));
|
||||
}
|
||||
let width = reader.number(object, "width")?;
|
||||
let height = reader.number(object, "height")?;
|
||||
let ui_width = reader.number(object, "uiWidth")?;
|
||||
let ui_height = reader.number(object, "uiHeight")?;
|
||||
if width <= 0.
|
||||
|| height <= 0.
|
||||
|| ![ui_width, ui_height]
|
||||
.iter()
|
||||
.all(|size| (1.0..=2048.0).contains(size) && size.fract() == 0.)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Panel3D requires positive world dimensions and integer UI dimensions from 1 through 2048",
|
||||
));
|
||||
}
|
||||
let content = object_ref(&reader.value(object, "content")?)?;
|
||||
crate::block::resolve(store, content)?;
|
||||
let background = color(&text(&reader.value(object, "background")?)?)?;
|
||||
let geometry = Arc::new(scene3d::Geometry {
|
||||
vertices: vec![
|
||||
Vec3::new(-width / 2., height / 2., 0.),
|
||||
Vec3::new(width / 2., height / 2., 0.),
|
||||
Vec3::new(width / 2., -height / 2., 0.),
|
||||
Vec3::new(-width / 2., -height / 2., 0.),
|
||||
],
|
||||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||||
uv: vec![[0., 0.], [1., 0.], [1., 1.], [0., 1.]],
|
||||
});
|
||||
output.meshes.push(scene3d::Mesh {
|
||||
identity: object.to_string(),
|
||||
geometry,
|
||||
world,
|
||||
color: [1., 1., 1.],
|
||||
panel: Some(scene3d::Panel {
|
||||
key,
|
||||
width: ui_width as u32,
|
||||
height: ui_height as u32,
|
||||
background,
|
||||
surface: None,
|
||||
}),
|
||||
});
|
||||
panels.push(PanelMount {
|
||||
key,
|
||||
content,
|
||||
size: crate::layout::Size {
|
||||
width: ui_width,
|
||||
height: ui_height,
|
||||
},
|
||||
});
|
||||
}
|
||||
NodeKind::Light => {
|
||||
if output.lights.len() >= 32 {
|
||||
return Err(VmError::ExecutionLimit("3D scene lights"));
|
||||
}
|
||||
let direction =
|
||||
reader.vector(object, ["directionX", "directionY", "directionZ"])?;
|
||||
let direction = (world.point(direction) - world.point(Vec3::default()))
|
||||
.normalized()
|
||||
.ok_or(invalid("light direction cannot be zero"))?;
|
||||
let color = color(&text(&reader.value(object, "color")?)?)?;
|
||||
let intensity = reader.number(object, "intensity")?;
|
||||
if !(0.0..=32.0).contains(&intensity) {
|
||||
return Err(invalid("light intensity expects 0 through 32"));
|
||||
}
|
||||
output.lights.push(scene3d::Light {
|
||||
direction,
|
||||
color,
|
||||
intensity,
|
||||
});
|
||||
}
|
||||
_ => return Err(invalid("invalid node in 3D scene graph")),
|
||||
}
|
||||
}
|
||||
let scene = if let Some(scene) = reuse.filter(|scene| **scene == output) {
|
||||
scene
|
||||
} else {
|
||||
output.acceleration = Arc::new(scene3d::spatial::Index::build(&output.meshes));
|
||||
Arc::new(output)
|
||||
};
|
||||
let view = Arc::new(scene3d::View { scene, camera });
|
||||
if !view.valid() {
|
||||
return Err(invalid("invalid 3D scene snapshot"));
|
||||
}
|
||||
Ok((view, reader.reads.into_iter().collect(), panels))
|
||||
}
|
||||
|
||||
/// 把视口中的指针射线结果转换成 RayHit3D,关联实际托管网格和交点数据。
|
||||
pub fn pick(
|
||||
cx: &mut NativeContext<'_>,
|
||||
desktop: ObjectRef,
|
||||
mount: &crate::mount::Mount,
|
||||
point: crate::geometry::Point,
|
||||
) -> Result<Value, VmError> {
|
||||
if mount.layout.is_some() {
|
||||
return Err(invalid("pick3D requires a committed viewport"));
|
||||
}
|
||||
let (_, data) = crate::block::resolve(cx.store(), mount.block)?;
|
||||
let data = crate::block::visual_data(cx.store(), data)?;
|
||||
if !matches!(data.kind, crate::block::Kind::Viewport3D { .. }) {
|
||||
return Err(invalid("pick3D requires a Viewport3D"));
|
||||
}
|
||||
if !mount.geometry.contains(point) {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let node = crate::block::snapshot(cx.store(), mount.block)?;
|
||||
let inset = crate::layout::insets(&node);
|
||||
let Some(point) = mount.geometry.logical_point(point) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let (width, height) = (
|
||||
(mount.geometry.width - inset.horizontal()).max(0.),
|
||||
(mount.geometry.height - inset.vertical()).max(0.),
|
||||
);
|
||||
if width <= 0. || height <= 0. {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let (view, reads, _) = snapshot(
|
||||
cx.store(),
|
||||
data.scene3d.ok_or(invalid("missing Scene3D"))?,
|
||||
data.camera.ok_or(invalid("missing 3D camera"))?,
|
||||
)?;
|
||||
let hit = view.pick(
|
||||
(point.x - mount.geometry.x - inset.left) / width,
|
||||
(point.y - mount.geometry.y - inset.top) / height,
|
||||
width / height,
|
||||
);
|
||||
let Some(hit) = hit else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let mesh = reads
|
||||
.iter()
|
||||
.find_map(|(object, _)| (object.to_string() == hit.identity).then_some(*object))
|
||||
.ok_or(invalid("picked mesh is unavailable"))?;
|
||||
let result = cx.new_object()?;
|
||||
crate::ui_bridge::attach_desktop_type(cx, result, "RayHit3D", desktop)?;
|
||||
cx.set_attr(result, "mesh", Value::Object(mesh))?;
|
||||
cx.set_attr(result, "node", Value::Object(mesh))?;
|
||||
cx.set_attr(
|
||||
result,
|
||||
"instance",
|
||||
Value::Number((hit.instance as i64).into()),
|
||||
)?;
|
||||
cx.set_attr(
|
||||
result,
|
||||
"distance",
|
||||
crate::native_visual::value(hit.distance),
|
||||
)?;
|
||||
cx.set_attr(
|
||||
result,
|
||||
"triangle",
|
||||
Value::Number((hit.triangle as i64).into()),
|
||||
)?;
|
||||
for (name, vector) in [
|
||||
("point", hit.point),
|
||||
("normal", hit.normal),
|
||||
(
|
||||
"barycentric",
|
||||
Vec3::new(hit.barycentric[0], hit.barycentric[1], hit.barycentric[2]),
|
||||
),
|
||||
] {
|
||||
let definition = crate::ui_bridge::desktop_type(cx.store(), "Vec3", desktop)?;
|
||||
let vector = allocate(
|
||||
cx,
|
||||
desktop,
|
||||
definition,
|
||||
Constructor::Vec3,
|
||||
&[
|
||||
crate::native_visual::value(vector.x),
|
||||
crate::native_visual::value(vector.y),
|
||||
crate::native_visual::value(vector.z),
|
||||
],
|
||||
)?;
|
||||
cx.set_attr(result, name, Value::Object(vector))?;
|
||||
}
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 指针拾取的中间结果,保留交点与所属实例信息供 Event/RayHit3D 使用。
|
||||
pub struct PointerPick {
|
||||
pub mount: ObjectRef,
|
||||
pub desktop: ObjectRef,
|
||||
pub point: crate::geometry::Point,
|
||||
}
|
||||
|
||||
/// The default controller is replaceable; camera data has no dependency on it.
|
||||
/// 为指定出现解码相机手势并调用控制器;嵌套面板输入先转换到所属视口坐标。
|
||||
pub fn control(
|
||||
cx: &mut NativeContext<'_>,
|
||||
controller: ObjectRef,
|
||||
camera_ref: ObjectRef,
|
||||
gesture: ObjectRef,
|
||||
) -> Result<(), VmError> {
|
||||
if cx.store().payload::<Node>(controller, "OrbitController")?.0 != NodeKind::Orbit {
|
||||
return Err(invalid("invalid orbit controller"));
|
||||
}
|
||||
let mut reader = Reader {
|
||||
store: cx.store(),
|
||||
reads: HashSet::new(),
|
||||
};
|
||||
let camera = reader.camera(camera_ref)?;
|
||||
let dx = reader.number(gesture, "dx")?;
|
||||
let dy = reader.number(gesture, "dy")?;
|
||||
let kind = text(&reader.value(gesture, "kind")?)?;
|
||||
let offset = camera.position - camera.target;
|
||||
let radius = offset.length();
|
||||
let mut position = camera.position;
|
||||
let mut target = camera.target;
|
||||
match kind.as_str() {
|
||||
"scene-orbit" => {
|
||||
let speed = reader.number(controller, "rotationSpeed")?;
|
||||
if !(0.0..=1.0).contains(&speed) {
|
||||
return Err(invalid("rotationSpeed expects 0 through 1"));
|
||||
}
|
||||
let up = camera
|
||||
.up
|
||||
.normalized()
|
||||
.ok_or(invalid("camera up cannot be zero"))?;
|
||||
let vertical = (offset.dot(up) / radius).clamp(-1., 1.);
|
||||
let horizontal = (offset - up * offset.dot(up))
|
||||
.normalized()
|
||||
.ok_or(invalid("orbit at pole requires a different up vector"))?;
|
||||
let (sine, cosine) = (dx * speed).sin_cos();
|
||||
let horizontal = horizontal * cosine + up.cross(horizontal) * sine;
|
||||
let pitch = (vertical.asin() + dy * speed).clamp(
|
||||
-std::f32::consts::FRAC_PI_2 + 0.01,
|
||||
std::f32::consts::FRAC_PI_2 - 0.01,
|
||||
);
|
||||
position = target + horizontal * (radius * pitch.cos()) + up * (radius * pitch.sin());
|
||||
}
|
||||
"scene-pan" => {
|
||||
let speed = reader.number(controller, "panSpeed")?;
|
||||
if !(0.0..=100.0).contains(&speed) {
|
||||
return Err(invalid("panSpeed expects 0 through 100"));
|
||||
}
|
||||
let height = reader.number(gesture, "height")?.max(1.);
|
||||
let units = match camera.projection {
|
||||
Projection::Perspective { fov } => {
|
||||
2. * radius * (fov.to_radians() / 2.).tan() / height
|
||||
}
|
||||
Projection::Orthographic { height: extent } => extent / height,
|
||||
};
|
||||
let basis = camera.basis().ok_or(invalid("invalid camera basis"))?;
|
||||
let delta = basis.right * (dx * units * speed) + basis.up * (-dy * units * speed);
|
||||
position = position + delta;
|
||||
target = target + delta;
|
||||
}
|
||||
"scene-zoom" => {
|
||||
let factor = reader.number(gesture, "factor")?;
|
||||
if !(0.1..=10.0).contains(&factor) {
|
||||
return Err(invalid("zoom factor expects 0.1 through 10"));
|
||||
}
|
||||
if let Projection::Orthographic { height } = camera.projection {
|
||||
set_number(
|
||||
cx,
|
||||
camera_ref,
|
||||
"height",
|
||||
(height / factor).clamp(0.001, 1_000_000.),
|
||||
)?;
|
||||
} else {
|
||||
let next =
|
||||
(radius / factor).clamp(camera.near * 2., camera.far.max(camera.near * 2.));
|
||||
position = target + offset * (next / radius);
|
||||
}
|
||||
}
|
||||
_ => return Err(invalid("unsupported 3D camera gesture")),
|
||||
}
|
||||
if !position.valid() || !target.valid() {
|
||||
return Err(invalid("camera movement exceeds world budget"));
|
||||
}
|
||||
for (name, value) in [
|
||||
("x", position.x),
|
||||
("y", position.y),
|
||||
("z", position.z),
|
||||
("targetX", target.x),
|
||||
("targetY", target.y),
|
||||
("targetZ", target.z),
|
||||
] {
|
||||
set_number(cx, camera_ref, name, value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行托管 3D 节点的原生平移/旋转操作,供尚不支持普通数值算术表达式的源程序调用。
|
||||
pub fn transform(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
args: &[Value],
|
||||
rotate: bool,
|
||||
) -> Result<(), VmError> {
|
||||
let kind = cx.store().payload::<Node>(owner, "3D transform target")?.0;
|
||||
let camera = matches!(kind, NodeKind::Perspective | NodeKind::Orthographic);
|
||||
if !matches!(
|
||||
kind,
|
||||
NodeKind::Mesh
|
||||
| NodeKind::Group
|
||||
| NodeKind::Panel
|
||||
| NodeKind::Perspective
|
||||
| NodeKind::Orthographic
|
||||
) || rotate && camera
|
||||
{
|
||||
return Err(invalid("unsupported 3D transform target"));
|
||||
}
|
||||
let names = if rotate {
|
||||
["rotationX", "rotationY", "rotationZ"]
|
||||
} else {
|
||||
["x", "y", "z"]
|
||||
};
|
||||
let mut updates = Vec::new();
|
||||
for (index, name) in names.into_iter().enumerate() {
|
||||
let delta = number(&args[index])?;
|
||||
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
|
||||
if !value.is_finite() || !rotate && value.abs() > 1_000_000. {
|
||||
return Err(invalid("3D transform exceeds world budget"));
|
||||
}
|
||||
updates.push((
|
||||
name,
|
||||
if rotate {
|
||||
value.rem_euclid(360.)
|
||||
} else {
|
||||
value
|
||||
},
|
||||
));
|
||||
if camera {
|
||||
let name = ["targetX", "targetY", "targetZ"][index];
|
||||
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
|
||||
if value.abs() > 1_000_000. {
|
||||
return Err(invalid("camera target exceeds world budget"));
|
||||
}
|
||||
updates.push((name, value));
|
||||
}
|
||||
}
|
||||
for (name, value) in updates {
|
||||
set_number(cx, owner, name, value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl endor_core::NativeCallable for PointerPick {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(
|
||||
&self,
|
||||
cx: &mut NativeContext<'_>,
|
||||
_: &[Value],
|
||||
) -> Result<endor_core::NativeResult, VmError> {
|
||||
let mount = cx
|
||||
.store()
|
||||
.payload::<crate::mount::Mount>(self.mount, "Mount")?
|
||||
.clone();
|
||||
let hit = pick(cx, self.desktop, &mount, self.point)?;
|
||||
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
|
||||
let event = crate::event_dispatch::new_event(cx, definition, "click")?;
|
||||
cx.set_attr(event, "hit", hit)?;
|
||||
crate::event_dispatch::dispatch(cx, mount.block, event, Some(self.mount), self.desktop)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。
|
||||
//! 缓存只借用可检查的对象身份,不增加托管根。对象释放后必须清理缓存索引;快照仍可持有独立的原生数值数据。
|
||||
//! 干净子树直接复用 Arc;脏节点重新检查属性、方法、绑定和子项,循环与展开数量在发布前拒绝。
|
||||
//!
|
||||
//! Object-to-visual dependencies. Cached results never own VM references.
|
||||
use crate::{
|
||||
block::{self, Kind},
|
||||
@@ -10,6 +14,7 @@ use std::{
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 某个业务组件的视觉快照,以及使它失效的读取依赖和子项身份。
|
||||
struct Cached {
|
||||
node: Arc<UiNode>,
|
||||
dependencies: Vec<Dependency>,
|
||||
@@ -17,13 +22,16 @@ struct Cached {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
/// 反向依赖键:区分整个对象内容和精确属性名称,减少无关字段变化引起的失效。
|
||||
struct Dependency {
|
||||
object: ObjectRef,
|
||||
property: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// 视觉快照缓存与反向依赖图。对象身份是借用索引,原生 Arc 数据不拥有 VM 根。
|
||||
pub struct RenderCache {
|
||||
scenes3d: HashMap<ObjectRef, Arc<crate::scene3d::Scene>>,
|
||||
entries: HashMap<ObjectRef, Cached>,
|
||||
dependents: HashMap<Dependency, HashSet<ObjectRef>>,
|
||||
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
|
||||
@@ -34,6 +42,7 @@ pub struct RenderCache {
|
||||
}
|
||||
|
||||
impl RenderCache {
|
||||
/// 把 VM 变化映射到读取过这些属性/对象的视觉节点,再沿父链向上传播脏标记。
|
||||
pub fn invalidate(&mut self, batch: ChangeBatch) {
|
||||
let mut queue = VecDeque::new();
|
||||
if batch.rescan_required {
|
||||
@@ -57,6 +66,7 @@ impl RenderCache {
|
||||
self.invalidate_objects(queue);
|
||||
}
|
||||
|
||||
/// 迭代传播显式失效,使用集合避免共享父链重复遍历或循环入队。
|
||||
pub fn invalidate_objects(&mut self, objects: impl IntoIterator<Item = ObjectRef>) {
|
||||
let mut queue: VecDeque<_> = objects.into_iter().collect();
|
||||
while let Some(block) = queue.pop_front() {
|
||||
@@ -68,6 +78,7 @@ impl RenderCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。
|
||||
fn remove(&mut self, block: ObjectRef) -> Option<Cached> {
|
||||
let old = self.entries.remove(&block)?;
|
||||
for source in &old.dependencies {
|
||||
@@ -89,7 +100,10 @@ impl RenderCache {
|
||||
Some(old)
|
||||
}
|
||||
|
||||
/// 删除托管堆中已不存在的身份,缓存本身不负责保持业务对象存活。
|
||||
pub fn prune(&mut self, store: &ObjectStore) {
|
||||
self.scenes3d
|
||||
.retain(|object, _| store.object(*object).is_ok());
|
||||
let dead: Vec<_> = self
|
||||
.entries
|
||||
.keys()
|
||||
@@ -102,6 +116,7 @@ impl RenderCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按显式栈遍历托管组成;复用干净子树,重建脏节点,并在完成前检查循环和资源上限。
|
||||
pub fn snapshot(
|
||||
&mut self,
|
||||
store: &ObjectStore,
|
||||
@@ -113,6 +128,7 @@ impl RenderCache {
|
||||
let mut path = HashSet::new();
|
||||
let mut values = Vec::<Arc<UiNode>>::new();
|
||||
while let Some((block, finish)) = work.pop() {
|
||||
// 入栈时先尝试复用;退出标记表示孩子已形成快照,可以组装父节点。
|
||||
if !finish {
|
||||
if !self.dirty.contains(&block)
|
||||
&& let Some(cached) = self.entries.get(&block)
|
||||
@@ -128,7 +144,7 @@ impl RenderCache {
|
||||
}
|
||||
store.check_typed_instance_storage(block)?;
|
||||
let (data_ref, raw_data, raw_dependencies) = block::resolve_tracked(store, block)?;
|
||||
let data = block::visual_data(store, raw_data)?;
|
||||
let mut data = block::visual_data(store, raw_data)?;
|
||||
let mut dependencies: Vec<_> = raw_dependencies
|
||||
.into_iter()
|
||||
.map(|object| Dependency {
|
||||
@@ -154,6 +170,48 @@ impl RenderCache {
|
||||
});
|
||||
}
|
||||
}
|
||||
if matches!(data.kind, Kind::CanvasViewport { .. })
|
||||
&& let Some(camera) = data.camera
|
||||
{
|
||||
for name in ["x", "y", "zoom"] {
|
||||
dependencies.push(Dependency {
|
||||
object: camera,
|
||||
property: Some(name.into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut panel_sizes = Vec::new();
|
||||
let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) {
|
||||
let scene = data
|
||||
.scene3d
|
||||
.ok_or(VmError::InvalidArgument("missing Scene3D"))?;
|
||||
let (view, reads, panels) = crate::scene3d_model::snapshot_reusing(
|
||||
store,
|
||||
scene,
|
||||
data.camera
|
||||
.ok_or(VmError::InvalidArgument("missing 3D camera"))?,
|
||||
self.scenes3d.get(&scene).cloned(),
|
||||
)?;
|
||||
self.scenes3d.insert(scene, view.scene.clone());
|
||||
dependencies.extend(reads.into_iter().map(|(object, name)| Dependency {
|
||||
object,
|
||||
property: Some(name),
|
||||
}));
|
||||
data.children = panels
|
||||
.into_iter()
|
||||
.map(|panel| {
|
||||
panel_sizes.push(panel.size);
|
||||
crate::block::Child {
|
||||
slot: panel.key,
|
||||
block: panel.content,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(view)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// path 只追踪当前祖先链,因此允许同一业务组件在不同分支多次出现。
|
||||
if !finish {
|
||||
if !path.insert(data_ref) {
|
||||
return Err(VmError::InvalidArgument("cyclic Block composition"));
|
||||
@@ -165,6 +223,11 @@ impl RenderCache {
|
||||
continue;
|
||||
}
|
||||
path.remove(&data_ref);
|
||||
let pointer_connection = data.subscriptions.iter().any(|&o| {
|
||||
store
|
||||
.payload::<crate::event_dispatch::Connection>(o, "Subscription")
|
||||
.is_ok_and(|s| s.handler.is_some() && s.kind.starts_with("pointer"))
|
||||
});
|
||||
let mut protocol_key = String::new();
|
||||
let mut custom_event = false;
|
||||
if let Some(definition) = store.type_of(block)? {
|
||||
@@ -259,11 +322,60 @@ impl RenderCache {
|
||||
children,
|
||||
},
|
||||
Kind::Spacer(size) => UiKind::Spacer(*size),
|
||||
Kind::Viewport3D { width, height } => UiKind::Viewport3D {
|
||||
width: *width,
|
||||
height: *height,
|
||||
view: view3d.ok_or(VmError::InvalidArgument("missing 3D view"))?,
|
||||
controls: data.controller.is_some(),
|
||||
panels: panel_sizes,
|
||||
children,
|
||||
},
|
||||
Kind::CanvasViewport { width, height } => UiKind::CanvasViewport {
|
||||
width: *width,
|
||||
height: *height,
|
||||
camera: crate::viewport::camera(
|
||||
store,
|
||||
data.camera
|
||||
.ok_or(VmError::InvalidArgument("missing camera"))?,
|
||||
)?,
|
||||
children,
|
||||
},
|
||||
Kind::Scene2D => UiKind::Scene2D {
|
||||
positions: data
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| {
|
||||
let (_, child) = block::resolve(store, child.block)?;
|
||||
Ok(block::visual_data(store, child)?.position)
|
||||
})
|
||||
.collect::<Result<Vec<_>, VmError>>()?,
|
||||
children,
|
||||
},
|
||||
Kind::Overlay => UiKind::Overlay {
|
||||
padding: data.padding,
|
||||
children,
|
||||
},
|
||||
Kind::Row => UiKind::Row {
|
||||
padding: data.padding,
|
||||
gap: data.gap,
|
||||
children,
|
||||
},
|
||||
Kind::Page if data.surface.mode == crate::page::Mode::Canvas => UiKind::Canvas {
|
||||
positions: data
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| {
|
||||
let (_, data) = block::resolve(store, child.block)?;
|
||||
Ok(block::visual_data(store, data)?.position)
|
||||
})
|
||||
.collect::<Result<Vec<_>, VmError>>()?,
|
||||
children,
|
||||
},
|
||||
Kind::Page if data.surface.mode == crate::page::Mode::Horizontal => UiKind::Row {
|
||||
padding: data.padding,
|
||||
gap: data.gap,
|
||||
children,
|
||||
},
|
||||
Kind::Column | Kind::Page => UiKind::Column {
|
||||
padding: data.padding,
|
||||
gap: data.gap,
|
||||
@@ -276,13 +388,28 @@ impl RenderCache {
|
||||
},
|
||||
};
|
||||
let next = UiNode {
|
||||
surface: matches!(data.kind, Kind::Page).then(|| {
|
||||
let mut surface = data.surface.clone();
|
||||
surface.page = block.to_string();
|
||||
surface
|
||||
}),
|
||||
flex: data.flex,
|
||||
flex_policy: data.flex_policy,
|
||||
main_align: data.main_align,
|
||||
cross_align: data.cross_align,
|
||||
align_self: data.align_self,
|
||||
margin: data.margin,
|
||||
sizing: data.sizing,
|
||||
padding_edges: data.padding_edges,
|
||||
border: data.border,
|
||||
border_color: data.border_color.clone(),
|
||||
clip_children: data.clip_children,
|
||||
block_id: block.to_string(),
|
||||
protocol_key,
|
||||
slots: data.children.iter().map(|c| c.slot).collect(),
|
||||
interactive: matches!(&data.kind, Kind::Button)
|
||||
pointer_events: pointer_connection,
|
||||
interactive: pointer_connection
|
||||
|| matches!(&data.kind, Kind::Button | Kind::Viewport3D { .. })
|
||||
|| data.click.is_some()
|
||||
|| click_connection
|
||||
|| custom_event,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 托管 Window 的页面所有权、原生命令与关闭生命周期。
|
||||
//! 源码 resize/focus/setTitle 先产生纯数据命令;只有所属输入成功发布后,GUI 线程才应用它们。
|
||||
//! 窗口 ID 和运行时 session 共同限定资源归属;切换 VM 或关闭窗口时不能让旧命令作用到新资源。
|
||||
//!
|
||||
//! Managed window resources and versioned, coalesced native projections.
|
||||
//! The VM owns Page roots; only scalar commands cross to the GUI thread.
|
||||
use crate::{native_visual, object_list, ui_bridge::desktop_type};
|
||||
@@ -10,6 +14,7 @@ use std::sync::{Arc, Mutex};
|
||||
pub const MAIN_WINDOW: u64 = 1;
|
||||
pub const MAX_WINDOW_DIMENSION: f64 = 4096.0;
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 可投影到原生窗口的动作类别,如尺寸、标题、焦点与关闭。
|
||||
pub enum Action {
|
||||
Resize { width: f64, height: f64 },
|
||||
SetTitle(String),
|
||||
@@ -18,23 +23,27 @@ pub enum Action {
|
||||
Close,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 成功发布后由 GUI 执行的一条原生窗口请求,含资源身份和动作顺序。
|
||||
pub struct Command {
|
||||
pub window: u64,
|
||||
pub sequence: u64,
|
||||
pub action: Action,
|
||||
}
|
||||
impl Command {
|
||||
/// 判断两条命令是否针对同一资源的同类动作,用于替换尚未执行的旧请求。
|
||||
pub fn same_kind(&self, other: &Self) -> bool {
|
||||
self.window == other.window
|
||||
&& std::mem::discriminant(&self.action) == std::mem::discriminant(&other.action)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
/// 附带运行时 session 的命令,阻止旧域请求在新 VM 上生效。
|
||||
pub struct Envelope {
|
||||
pub session: u64,
|
||||
pub command: Command,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
/// 托管窗口资源状态与页面拥有关系;本对象存在不等于 OS 窗口已创建。
|
||||
pub struct ManagedWindow {
|
||||
pub id: u64,
|
||||
pub desktop: ObjectRef,
|
||||
@@ -48,18 +57,23 @@ pub struct ManagedWindow {
|
||||
outbox: Arc<Mutex<Vec<Command>>>,
|
||||
}
|
||||
impl ObjectPayload for ManagedWindow {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-window"
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
visit(self.desktop);
|
||||
if let Some(tasks) = self.tasks {
|
||||
@@ -71,9 +85,11 @@ impl ObjectPayload for ManagedWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 把源数值转换为有限屏幕坐标,拒绝越界或不可表示的值。
|
||||
fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
|
||||
let Value::Number(value) = value else {
|
||||
return Err(invalid("screen position requires integer coordinates"));
|
||||
@@ -84,6 +100,7 @@ fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
.ok_or(invalid("screen position exceeds limits"))
|
||||
}
|
||||
/// 从源 Value 取得窗口操作需要的数值,保留错误类型诊断。
|
||||
fn scalar(value: &Value) -> Result<f64, VmError> {
|
||||
let Value::Number(value) = value else {
|
||||
return Err(invalid("window size requires numbers"));
|
||||
@@ -98,6 +115,7 @@ fn scalar(value: &Value) -> Result<f64, VmError> {
|
||||
Ok(number)
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 当前宿主 API 的操作分类,分派代码负责参数校验与状态更新。
|
||||
enum Operation {
|
||||
Id,
|
||||
Viewport,
|
||||
@@ -108,15 +126,18 @@ enum Operation {
|
||||
Focus,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
|
||||
struct Method {
|
||||
signature: endor_core::CallSignature,
|
||||
operation: Operation,
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Method {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(match self.operation {
|
||||
Operation::Resize | Operation::MoveTo => 3,
|
||||
@@ -125,6 +146,7 @@ impl NativeCallable for Method {
|
||||
}))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let Some(Value::Object(owner)) = args.first() else {
|
||||
return Err(invalid("Window method requires a Window"));
|
||||
@@ -196,6 +218,7 @@ impl NativeCallable for Method {
|
||||
Ok(NativeResult::Return(result))
|
||||
}
|
||||
}
|
||||
/// 把合法窗口操作追加到所属窗口的待发布命令,不能直接在 VM 线程操作 OS。
|
||||
pub fn enqueue(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -225,6 +248,7 @@ pub fn enqueue(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将页面登记到其窗口,维护托管所有权与页面顺序。
|
||||
pub fn register_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
window: ObjectRef,
|
||||
@@ -253,6 +277,7 @@ pub fn register_page(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 从窗口的页面集合撤销指定页面,保持后续展示索引一致。
|
||||
pub fn unregister_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
window: ObjectRef,
|
||||
@@ -276,6 +301,7 @@ pub fn unregister_page(
|
||||
Ok(())
|
||||
}
|
||||
/// Discard an unpublished Page registration without invoking guest callbacks.
|
||||
/// 宿主收尾路径撤销页面登记,用于已经由外层决定的关闭/退役流程。
|
||||
pub fn unregister_page_host(
|
||||
vm: &mut Vm,
|
||||
window: ObjectRef,
|
||||
@@ -290,6 +316,7 @@ pub fn unregister_page_host(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在窗口之间移动页面的所有权和登记顺序,保留同一业务页面身份。
|
||||
pub fn move_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
window: ObjectRef,
|
||||
@@ -323,9 +350,11 @@ pub fn move_page(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 借用一个有效托管窗口的已登记页面集合。
|
||||
pub fn pages(store: &ObjectStore, window: ObjectRef) -> Result<&[ObjectRef], VmError> {
|
||||
Ok(&store.payload::<ManagedWindow>(window, "Window")?.pages)
|
||||
}
|
||||
/// 取走当前窗口累积的命令,后续由成功发布结果携带到 GUI。
|
||||
pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError> {
|
||||
let data = vm.payload::<ManagedWindow>(window, "Window")?;
|
||||
let mut queue = data
|
||||
@@ -336,6 +365,7 @@ pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError
|
||||
commands.sort_by_key(|command| command.sequence);
|
||||
Ok(commands)
|
||||
}
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
@@ -413,6 +443,7 @@ pub fn install(
|
||||
|
||||
/// Create a managed native-window resource. The GUI creates the OS resource only
|
||||
/// after the owning candidate/input publishes its immutable projection.
|
||||
/// 建立托管窗口、desktop 与页面容器;原生窗口通过命令发布后出现。
|
||||
pub fn create(
|
||||
cx: &mut NativeContext<'_>,
|
||||
master: ObjectRef,
|
||||
@@ -449,6 +480,7 @@ pub fn create(
|
||||
Ok((window, desktop, tabs))
|
||||
}
|
||||
|
||||
/// 发起窗口关闭和相应生命周期清理,拒绝重复或不再有效的操作。
|
||||
pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmError> {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid(
|
||||
@@ -477,6 +509,7 @@ pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmErro
|
||||
}
|
||||
|
||||
/// Host teardown for a stopped VM: no binding, dispatch or source execution.
|
||||
/// 撤销已失效窗口的托管资源关系,不重新执行源关闭请求。
|
||||
pub fn retire(vm: &mut Vm, window: ObjectRef) -> Result<(), VmError> {
|
||||
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
|
||||
vm.dict_clear(data.tabs)?;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。
|
||||
//! margin、border、padding 的空间分配由布局层结合本模块完成;场景世界范围不被当成无限布局盒子。
|
||||
//!
|
||||
//! Native border-box sizing. An unbounded axis cannot resolve `fill`.
|
||||
use crate::{layout::Size, ui_bridge::MAX_DIMENSION};
|
||||
use anyhow::Result;
|
||||
use endor_core::{Value, VmError};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
/// 一个布局轴的尺寸策略:随内容、固定值或占用有限可用空间。
|
||||
pub enum Dimension {
|
||||
#[default]
|
||||
Auto,
|
||||
Fixed(f32),
|
||||
Fill,
|
||||
}
|
||||
|
||||
impl Dimension {
|
||||
/// 把内部尺寸策略投影为相应的源属性表示。
|
||||
pub fn value(self) -> Value {
|
||||
match self {
|
||||
Self::Auto => Value::Text("auto".into()),
|
||||
Self::Fill => Value::Text("fill".into()),
|
||||
Self::Fixed(value) => Value::Number((value as f64).into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(value: &Value) -> Result<Self, VmError> {
|
||||
match value {
|
||||
Value::Text(text) if text == "auto" => Ok(Self::Auto),
|
||||
Value::Text(text) if text == "fill" => Ok(Self::Fill),
|
||||
Value::Number(_) => Ok(Self::Fixed(crate::scroll::coordinate(value).and_then(
|
||||
|n| {
|
||||
if n >= 0.0 {
|
||||
Ok(n)
|
||||
} else {
|
||||
Err(VmError::InvalidArgument(
|
||||
"box dimensions must be nonnegative",
|
||||
))
|
||||
}
|
||||
},
|
||||
)?)),
|
||||
_ => Err(VmError::InvalidArgument(
|
||||
"box dimension expects auto, fill or a nonnegative number",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
/// border-box 尺寸策略与双轴上下限,实际求解仍要结合父级约束。
|
||||
pub struct Sizing {
|
||||
pub width: Dimension,
|
||||
pub height: Dimension,
|
||||
pub min_width: f32,
|
||||
pub min_height: f32,
|
||||
pub max_width: f32,
|
||||
pub max_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Sizing {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: Dimension::Auto,
|
||||
height: Dimension::Auto,
|
||||
min_width: 0.0,
|
||||
min_height: 0.0,
|
||||
max_width: MAX_DIMENSION as f32,
|
||||
max_height: MAX_DIMENSION as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizing {
|
||||
/// Constrain descendants using the actual finite box before wrapping text.
|
||||
/// 根据固定/auto/fill 与 min/max 规则细化父级提供的可用约束。
|
||||
pub fn available(self, mut constraints: [f32; 4]) -> [f32; 4] {
|
||||
constraints[2] = constraints[2].min(self.max_width);
|
||||
constraints[3] = constraints[3].min(self.max_height);
|
||||
if let Dimension::Fixed(width) = self.width {
|
||||
constraints[2] = constraints[2].min(width.max(self.min_width));
|
||||
}
|
||||
if let Dimension::Fixed(height) = self.height {
|
||||
constraints[3] = constraints[3].min(height.max(self.min_height));
|
||||
}
|
||||
constraints[0] = constraints[0].min(constraints[2]);
|
||||
constraints[1] = constraints[1].min(constraints[3]);
|
||||
constraints
|
||||
}
|
||||
|
||||
/// 结合内容期望尺寸、轴策略和父级约束求最终盒子尺寸;无界轴不能解析 fill。
|
||||
pub fn resolve(self, intrinsic: Size, constraints: [f32; 4]) -> Result<Size> {
|
||||
/// 针对单个尺寸轴应用约束与边界规则,供双轴尺寸求解复用。
|
||||
fn axis(
|
||||
policy: Dimension,
|
||||
intrinsic: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
available: f32,
|
||||
) -> Result<f32> {
|
||||
let value = match policy {
|
||||
Dimension::Auto => intrinsic,
|
||||
Dimension::Fixed(value) => value,
|
||||
Dimension::Fill => {
|
||||
if available >= MAX_DIMENSION as f32 {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"fill requires a finite parent allocation on this axis",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
available
|
||||
}
|
||||
};
|
||||
Ok(value.clamp(min, max).min(available))
|
||||
}
|
||||
Ok(Size {
|
||||
width: axis(
|
||||
self.width,
|
||||
intrinsic.width,
|
||||
self.min_width,
|
||||
self.max_width,
|
||||
constraints[2],
|
||||
)?,
|
||||
height: axis(
|
||||
self.height,
|
||||
intrinsic.height,
|
||||
self.min_height,
|
||||
self.max_height,
|
||||
constraints[3],
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。
|
||||
//! 平移和缩放统一作用于命令几何;文字样式和裁剪也要使用一致的设备比例。
|
||||
//!
|
||||
use crate::geometry::{Point, Rect};
|
||||
use crate::text::TextStyle;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
/// 设备绘制使用的 RGBA 字节通道,区别于源代码的颜色文本。
|
||||
pub struct Color {
|
||||
pub red: u8,
|
||||
pub green: u8,
|
||||
@@ -10,6 +14,7 @@ pub struct Color {
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// 从 RGB 通道构造不透明颜色值。
|
||||
pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
|
||||
Self {
|
||||
red,
|
||||
@@ -19,6 +24,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 RGBA 通道构造带透明度的颜色值。
|
||||
pub const fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
|
||||
Self {
|
||||
red,
|
||||
@@ -30,7 +36,21 @@ impl Color {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// 原生画布最终顺序执行的绘制原语,已经使用对应设备比例。
|
||||
pub enum DrawCommand {
|
||||
/// Immutable RGBA image mapped from its local rectangle by rotation.
|
||||
Image {
|
||||
image: std::sync::Arc<crate::render::image::Image>,
|
||||
origin: Point,
|
||||
rect: Rect,
|
||||
angle: f32,
|
||||
opacity: f32,
|
||||
tint: Option<Color>,
|
||||
},
|
||||
Scene3D {
|
||||
rect: Rect,
|
||||
view: std::sync::Arc<crate::scene3d::View>,
|
||||
},
|
||||
Clear(Color),
|
||||
PushClip(Rect),
|
||||
PopClip,
|
||||
@@ -59,27 +79,102 @@ pub enum DrawCommand {
|
||||
},
|
||||
}
|
||||
|
||||
impl DrawCommand {
|
||||
/// 返回平移后的绘制命令,并同步移动命令中的相关几何。
|
||||
pub fn translated(mut self, delta: Point) -> Self {
|
||||
match &mut self {
|
||||
Self::WrappedText { rect, .. }
|
||||
| Self::Scene3D { rect, .. }
|
||||
| Self::PushClip(rect)
|
||||
| Self::FillRoundedRect { rect, .. }
|
||||
| Self::StrokeRoundedRect { rect, .. } => {
|
||||
rect.x += delta.x;
|
||||
rect.y += delta.y;
|
||||
}
|
||||
Self::Text { origin, .. } | Self::Image { origin, .. } => {
|
||||
origin.x += delta.x;
|
||||
origin.y += delta.y;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
/// 有序绘制原语容器;命令顺序同时约束叠放和裁剪栈行为。
|
||||
pub struct DisplayList {
|
||||
commands: Vec<DrawCommand>,
|
||||
}
|
||||
|
||||
impl DisplayList {
|
||||
/// 按设备比例缩放命令,文字、边框和矩形不能分别使用不一致比例。
|
||||
pub fn scale(&mut self, scale: f32) {
|
||||
for command in &mut self.commands {
|
||||
let rect = match command {
|
||||
DrawCommand::WrappedText { rect, style, .. } => {
|
||||
*style = style.scaled(scale);
|
||||
Some(rect)
|
||||
}
|
||||
DrawCommand::PushClip(rect) => Some(rect),
|
||||
DrawCommand::Scene3D { rect, .. } => Some(rect),
|
||||
DrawCommand::FillRoundedRect { rect, radius, .. } => {
|
||||
*radius *= scale;
|
||||
Some(rect)
|
||||
}
|
||||
DrawCommand::StrokeRoundedRect {
|
||||
rect,
|
||||
radius,
|
||||
width,
|
||||
..
|
||||
} => {
|
||||
*radius *= scale;
|
||||
*width *= scale;
|
||||
Some(rect)
|
||||
}
|
||||
DrawCommand::Image { origin, rect, .. } => {
|
||||
origin.x *= scale;
|
||||
origin.y *= scale;
|
||||
rect.x *= scale;
|
||||
rect.y *= scale;
|
||||
rect.width *= scale;
|
||||
rect.height *= scale;
|
||||
None
|
||||
}
|
||||
DrawCommand::Text { origin, style, .. } => {
|
||||
origin.x *= scale;
|
||||
origin.y *= scale;
|
||||
*style = style.scaled(scale);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(rect) = rect {
|
||||
rect.x *= scale;
|
||||
rect.y *= scale;
|
||||
rect.width *= scale;
|
||||
rect.height *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 按绘制顺序追加一条原语,后加入的内容在合成顺序中位于后面。
|
||||
pub fn push(&mut self, command: DrawCommand) {
|
||||
self.commands.push(command);
|
||||
}
|
||||
|
||||
/// 平移当前显示列表中的命令,供整块页面或管理面板放入窗口坐标。
|
||||
pub fn translate(&mut self, delta: Point) {
|
||||
for command in &mut self.commands {
|
||||
match command {
|
||||
DrawCommand::WrappedText { rect, .. }
|
||||
| DrawCommand::Scene3D { rect, .. }
|
||||
| DrawCommand::PushClip(rect)
|
||||
| DrawCommand::FillRoundedRect { rect, .. }
|
||||
| DrawCommand::StrokeRoundedRect { rect, .. } => {
|
||||
rect.x += delta.x;
|
||||
rect.y += delta.y;
|
||||
}
|
||||
DrawCommand::Text { origin, .. } => {
|
||||
DrawCommand::Text { origin, .. } | DrawCommand::Image { origin, .. } => {
|
||||
origin.x += delta.x;
|
||||
origin.y += delta.y;
|
||||
}
|
||||
@@ -88,10 +183,12 @@ impl DisplayList {
|
||||
}
|
||||
}
|
||||
|
||||
/// 借用按绘制顺序排列的原生原语,不复制命令集合。
|
||||
pub fn commands(&self) -> &[DrawCommand] {
|
||||
&self.commands
|
||||
}
|
||||
|
||||
/// 按原顺序合并另一组绘制命令,不在这里重排层次。
|
||||
pub fn extend(&mut self, other: DisplayList) {
|
||||
self.commands.extend(other.commands);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
//! 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。
|
||||
//!
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
/// 二维坐标值;具体处于逻辑、局部还是设备空间由调用上下文决定。
|
||||
pub struct Point {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub const fn new(x: f32, y: f32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
/// 轴对齐矩形,保存原点和宽高,用于布局、裁剪与命中。
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
@@ -19,6 +24,7 @@ pub struct Rect {
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
@@ -28,6 +34,7 @@ impl Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
|
||||
pub fn contains(self, point: Point) -> bool {
|
||||
point.x >= self.x
|
||||
&& point.x <= self.x + self.width
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Immutable, bounded RGBA images shared by display lists. Decoding and text
|
||||
//! preparation happen outside the frame loop; the CPU backend samples them.
|
||||
use crate::{
|
||||
display_list::Color,
|
||||
geometry::Rect,
|
||||
text::{self, TextStyle},
|
||||
};
|
||||
use std::{io::Cursor, sync::Arc};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub id: u64,
|
||||
#[serde(skip)]
|
||||
pixels: Vec<u8>,
|
||||
#[serde(skip)]
|
||||
pub outline: Vec<crate::geometry::Point>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub fn png(bytes: &[u8]) -> anyhow::Result<Arc<Self>> {
|
||||
let mut decoder = png::Decoder::new(Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let info = reader.info();
|
||||
anyhow::ensure!(
|
||||
info.width > 0
|
||||
&& info.height > 0
|
||||
&& u64::from(info.width) * u64::from(info.height) <= 4_194_304,
|
||||
"image exceeds pixel budget"
|
||||
);
|
||||
let mut buffer = vec![
|
||||
0;
|
||||
reader
|
||||
.output_buffer_size()
|
||||
.ok_or_else(|| anyhow::anyhow!("image size overflow"))?
|
||||
];
|
||||
let info = reader.next_frame(&mut buffer)?;
|
||||
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
|
||||
for chunk in buffer[..info.buffer_size()].chunks_exact(info.color_type.samples()) {
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => [chunk[0], chunk[1], chunk[2], chunk[3]],
|
||||
png::ColorType::Rgb => [chunk[0], chunk[1], chunk[2], 255],
|
||||
png::ColorType::Grayscale => [chunk[0], chunk[0], chunk[0], 255],
|
||||
png::ColorType::GrayscaleAlpha => [chunk[0], chunk[0], chunk[0], chunk[1]],
|
||||
png::ColorType::Indexed => anyhow::bail!("unexpanded indexed PNG"),
|
||||
};
|
||||
pixels.extend_from_slice(&rgba);
|
||||
}
|
||||
let outline = alpha_hull(&pixels, info.width, info.height);
|
||||
Ok(Arc::new(Self {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
pixels,
|
||||
id: next_id(),
|
||||
outline,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cache a shaped word at the actual device resolution. Padding preserves
|
||||
/// italic/serif overhangs. Returns its logical image rectangle and advance.
|
||||
pub fn text(
|
||||
value: &str,
|
||||
style: TextStyle,
|
||||
scale: f32,
|
||||
color: Color,
|
||||
) -> Result<(Arc<Self>, Rect, f32), &'static str> {
|
||||
let size = text::measure(value, style);
|
||||
let pad = (style.font_size * 0.3).ceil();
|
||||
let width = ((size.width + pad * 2.) * scale).ceil().max(1.) as u32;
|
||||
let height = ((size.height + pad * 2.) * scale).ceil().max(1.) as u32;
|
||||
if u64::from(width) * u64::from(height) > 1_048_576 {
|
||||
return Err("prepared text image exceeds pixel budget");
|
||||
}
|
||||
let mut pixels = vec![0; width as usize * height as usize * 4];
|
||||
text::draw_clipped(
|
||||
value,
|
||||
style.scaled(scale),
|
||||
color,
|
||||
None,
|
||||
Rect::new(-pad * scale, -pad * scale, width as f32, height as f32),
|
||||
|x, y, c| {
|
||||
let x = x + (pad * scale).round() as i32;
|
||||
let y = y + (pad * scale).round() as i32;
|
||||
if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 {
|
||||
return;
|
||||
}
|
||||
let i = (y as usize * width as usize + x as usize) * 4;
|
||||
// All glyphs have the same ink color; alpha-over preserves overlaps.
|
||||
pixels[i..i + 3].copy_from_slice(&[c.red, c.green, c.blue]);
|
||||
pixels[i + 3] = (u16::from(c.alpha)
|
||||
+ u16::from(pixels[i + 3]) * (255 - u16::from(c.alpha)) / 255)
|
||||
as u8;
|
||||
},
|
||||
);
|
||||
Ok((
|
||||
Arc::new(Self {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
id: next_id(),
|
||||
outline: vec![],
|
||||
}),
|
||||
Rect::new(-pad, -pad, width as f32 / scale, height as f32 / scale),
|
||||
size.width,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn sample(&self, u: f32, v: f32, opacity: f32) -> Color {
|
||||
let x = u * self.width as f32 - 0.5;
|
||||
let y = v * self.height as f32 - 0.5;
|
||||
let (ix, iy) = (x.floor() as i32, y.floor() as i32);
|
||||
let (fx, fy) = (x - x.floor(), y - y.floor());
|
||||
let mut sum = [0.; 4];
|
||||
for (dx, dy, w) in [
|
||||
(0, 0, (1. - fx) * (1. - fy)),
|
||||
(1, 0, fx * (1. - fy)),
|
||||
(0, 1, (1. - fx) * fy),
|
||||
(1, 1, fx * fy),
|
||||
] {
|
||||
let px = (ix + dx).clamp(0, self.width as i32 - 1) as usize;
|
||||
let py = (iy + dy).clamp(0, self.height as i32 - 1) as usize;
|
||||
let i = (py * self.width as usize + px) * 4;
|
||||
let a = f32::from(self.pixels[i + 3]) * w;
|
||||
for (j, s) in sum.iter_mut().enumerate().take(3) {
|
||||
*s += f32::from(self.pixels[i + j]) * a;
|
||||
}
|
||||
sum[3] += a;
|
||||
}
|
||||
if sum[3] <= 0. {
|
||||
return Color::rgba(0, 0, 0, 0);
|
||||
}
|
||||
Color::rgba(
|
||||
(sum[0] / sum[3]).round() as u8,
|
||||
(sum[1] / sum[3]).round() as u8,
|
||||
(sum[2] / sum[3]).round() as u8,
|
||||
(sum[3] * opacity).round() as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conservative convex silhouette from opaque row endpoints. Unlike an image's
|
||||
/// rectangle, this excludes empty transparent margins. Generated once per PNG.
|
||||
fn alpha_hull(pixels: &[u8], width: u32, height: u32) -> Vec<crate::geometry::Point> {
|
||||
use crate::geometry::Point;
|
||||
let mut points = Vec::new();
|
||||
for y in 0..height {
|
||||
let xs: Vec<_> = (0..width)
|
||||
.filter(|&x| pixels[((y * width + x) * 4 + 3) as usize] >= 16)
|
||||
.collect();
|
||||
if let (Some(&left), Some(&right)) = (xs.first(), xs.last()) {
|
||||
for x in [left as f32, right as f32 + 1.] {
|
||||
for yy in [y as f32, y as f32 + 1.] {
|
||||
points.push(Point::new(x, yy));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
points.dedup();
|
||||
if points.len() < 3 {
|
||||
return points;
|
||||
}
|
||||
let cross =
|
||||
|a: Point, b: Point, c: Point| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
let mut hull: Vec<Point> = Vec::new();
|
||||
for &p in &points {
|
||||
while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
|
||||
hull.pop();
|
||||
}
|
||||
hull.push(p);
|
||||
}
|
||||
let lower = hull.len();
|
||||
for &p in points.iter().rev().skip(1) {
|
||||
while hull.len() > lower && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
|
||||
hull.pop();
|
||||
}
|
||||
hull.push(p);
|
||||
}
|
||||
hull.pop();
|
||||
hull
|
||||
}
|
||||
|
||||
fn next_id() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
impl PartialEq for Image {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。
|
||||
//! Row/Column/Overlay、边距、边框、内边距、对齐和弹性空间都在有限约束内计算;过大的尺寸和坐标返回错误。
|
||||
//! 缓存按组件形状、子项尺寸和约束判断可复用性。frame 先在克隆状态中求解,成功才替换运行状态,防止失败污染已发布几何。
|
||||
//!
|
||||
//! Iterative measure/arrange/paint over derived visual data. Every occurrence
|
||||
//! has its own geometry; measurement caches never become semantic object state.
|
||||
use crate::{
|
||||
@@ -10,7 +14,82 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
const MAX_LAYOUT_VISITS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
/// 上右下左四边的空间占用,用于 margin、padding 和 border 计算。
|
||||
pub struct Insets {
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
}
|
||||
|
||||
impl Insets {
|
||||
/// 返回该边距集合在水平方向占据的总空间。
|
||||
pub fn horizontal(self) -> f32 {
|
||||
self.left + self.right
|
||||
}
|
||||
/// 返回该边距集合在垂直方向占据的总空间。
|
||||
pub fn vertical(self) -> f32 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
/// 在内容尺寸外加上对应四边占用,得到外层测量尺寸。
|
||||
fn outer(self, size: Size) -> Size {
|
||||
Size {
|
||||
width: size.width + self.horizontal(),
|
||||
height: size.height + self.vertical(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment inside a finite cross-axis content box. Overflow remains start
|
||||
/// aligned so that content cannot become inaccessible beyond the leading edge.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
/// 交叉轴对齐规则;Stretch 需要结合子项是否自动尺寸决定实际拉伸。
|
||||
pub enum CrossAlign {
|
||||
#[default]
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
Stretch,
|
||||
}
|
||||
|
||||
impl CrossAlign {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
Self::Center => "center",
|
||||
Self::End => "end",
|
||||
Self::Stretch => "stretch",
|
||||
}
|
||||
}
|
||||
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
|
||||
match value {
|
||||
"start" => Ok(Self::Start),
|
||||
"center" => Ok(Self::Center),
|
||||
"end" => Ok(Self::End),
|
||||
"stretch" => Ok(Self::Stretch),
|
||||
_ => Err(endor_core::VmError::InvalidArgument(
|
||||
"crossAlign/alignSelf expects start, center, end or stretch (alignSelf also accepts auto)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据交叉轴对齐方式计算有限剩余空间中的偏移。
|
||||
fn offset(self, available: f32, occupied: f32) -> f32 {
|
||||
let spare = (available - occupied).max(0.0);
|
||||
match self {
|
||||
Self::Start | Self::Stretch => 0.0,
|
||||
Self::Center => spare / 2.0,
|
||||
Self::End => spare,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
/// 容器主轴的内容对齐或剩余间隔分配方式。
|
||||
pub enum MainAlign {
|
||||
#[default]
|
||||
Start,
|
||||
@@ -19,6 +98,7 @@ pub enum MainAlign {
|
||||
SpaceBetween,
|
||||
}
|
||||
impl MainAlign {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
@@ -27,6 +107,7 @@ impl MainAlign {
|
||||
Self::SpaceBetween => "space-between",
|
||||
}
|
||||
}
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
|
||||
match value {
|
||||
"start" => Ok(Self::Start),
|
||||
@@ -42,6 +123,7 @@ impl MainAlign {
|
||||
|
||||
/// Main-axis constraints interpreted independently by each parent occurrence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
/// 主轴伸缩策略与限值,不能用一个宽度常量替代完整分配规则。
|
||||
pub struct FlexPolicy {
|
||||
pub basis: Option<f32>,
|
||||
pub min: f32,
|
||||
@@ -49,6 +131,7 @@ pub struct FlexPolicy {
|
||||
pub shrink: f32,
|
||||
}
|
||||
impl Default for FlexPolicy {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
basis: None,
|
||||
@@ -59,20 +142,25 @@ impl Default for FlexPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
/// 逻辑宽高值,不隐含窗口像素比例或相机缩放。
|
||||
pub struct Size {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 影响原生测量的缓存形状,避免把颜色等纯绘制变化视为尺寸变化。
|
||||
enum Shape {
|
||||
Sized(Box<Shape>, crate::box_model::Sizing, [f32; 4]),
|
||||
Text(String, u32, f32),
|
||||
Rect(f32, f32),
|
||||
Container(bool, f32, f32, Vec<Size>),
|
||||
Container(bool, Insets, f32, Vec<Size>),
|
||||
Padded(Box<Shape>, Insets),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
/// 结构出现身份键,区分同一业务对象在不同父位置的展示。
|
||||
struct Key {
|
||||
parent: Option<u64>,
|
||||
slot: u64,
|
||||
@@ -80,6 +168,7 @@ struct Key {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// 布局缓存、稳定出现身份与统计。构建在临时副本上完成,成功后才写回。
|
||||
pub struct LayoutRuntime {
|
||||
sizes: HashMap<String, (Shape, Size)>,
|
||||
ids: HashMap<Key, u64>,
|
||||
@@ -90,8 +179,10 @@ pub struct LayoutRuntime {
|
||||
pub settle_passes: usize,
|
||||
pub slices: usize,
|
||||
pub viewport: Option<Size>,
|
||||
pub page_mode: Option<crate::page::Mode>,
|
||||
}
|
||||
|
||||
/// 迭代遍历中的一个出现,保存父子索引、约束与稳定 ID。
|
||||
struct Item<'a> {
|
||||
node: &'a UiNode,
|
||||
parent: Option<usize>,
|
||||
@@ -100,6 +191,7 @@ struct Item<'a> {
|
||||
constraints: [f32; 4],
|
||||
}
|
||||
|
||||
/// 检查尺寸非负、有限且不超过布局上限。
|
||||
fn bounded(value: f32) -> Result<f32> {
|
||||
if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) {
|
||||
bail!("UI layout exceeds the 1000000 coordinate/size limit");
|
||||
@@ -107,6 +199,15 @@ fn bounded(value: f32) -> Result<f32> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 检查允许有正负方向的坐标仍在安全布局范围内。
|
||||
fn coordinate(value: f32) -> Result<f32> {
|
||||
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 {
|
||||
bail!("UI coordinate exceeds the 1000000 limit");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
|
||||
pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
|
||||
let size = viewport.unwrap_or(Size {
|
||||
width: MAX_DIMENSION as f32,
|
||||
@@ -114,7 +215,33 @@ pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
|
||||
});
|
||||
[0.0, 0.0, size.width, size.height]
|
||||
}
|
||||
|
||||
/// 从父级上限扣除内边距;无界哨兵保持无界,不因减法被误认为有限。
|
||||
fn content_limit(available: f32, inset: f32) -> f32 {
|
||||
if available >= MAX_DIMENSION as f32 {
|
||||
available
|
||||
} else {
|
||||
(available - inset).max(0.0)
|
||||
}
|
||||
}
|
||||
/// 把父盒子的可用尺寸传递给子项,扣除容器内边距并考虑主轴是否可延伸。
|
||||
pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
|
||||
let parent = node.sizing.available(parent);
|
||||
if let UiKind::Overlay { .. } = node.kind {
|
||||
let padding = insets(node);
|
||||
return [
|
||||
0.0,
|
||||
0.0,
|
||||
content_limit(parent[2], padding.horizontal()),
|
||||
content_limit(parent[3], padding.vertical()),
|
||||
];
|
||||
}
|
||||
if matches!(
|
||||
node.kind,
|
||||
UiKind::Canvas { .. } | UiKind::Scene2D { .. } | UiKind::CanvasViewport { .. }
|
||||
) {
|
||||
return root_constraints(None);
|
||||
}
|
||||
if let UiKind::Scroll { width, height, .. } = node.kind {
|
||||
return arranged_child_constraints(
|
||||
node,
|
||||
@@ -127,39 +254,106 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
|
||||
0.0,
|
||||
0.0,
|
||||
MAX_DIMENSION as f32,
|
||||
(parent[3] - padding * 2.0).max(0.0),
|
||||
content_limit(parent[3], padding.vertical()),
|
||||
],
|
||||
Some((false, padding, _)) => [
|
||||
0.0,
|
||||
0.0,
|
||||
(parent[2] - padding * 2.0).max(0.0),
|
||||
content_limit(parent[2], padding.horizontal()),
|
||||
MAX_DIMENSION as f32,
|
||||
],
|
||||
None => parent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从子项可用范围扣除外边距,并使最小值不超过调整后的最大值。
|
||||
pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4] {
|
||||
for (axis, margin) in [child.margin.horizontal(), child.margin.vertical()]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if constraints[axis + 2] < MAX_DIMENSION as f32 {
|
||||
constraints[axis + 2] = (constraints[axis + 2] - margin).max(0.0);
|
||||
constraints[axis] = constraints[axis].min(constraints[axis + 2]);
|
||||
}
|
||||
}
|
||||
constraints
|
||||
}
|
||||
|
||||
/// Main-axis fill first measures against a finite offer, then participates in
|
||||
/// weighted allocation. The unlimited-axis sentinel is never made finite by padding.
|
||||
/// 根据具体子项尺寸策略和所在位置细化约束;世界 UI 根使用面板的有限像素尺寸。
|
||||
pub fn occurrence_constraints(
|
||||
parent: &UiNode,
|
||||
child: &UiNode,
|
||||
mut base: [f32; 4],
|
||||
offer: Size,
|
||||
child_index: usize,
|
||||
) -> [f32; 4] {
|
||||
if let UiKind::Viewport3D { panels, .. } = &parent.kind {
|
||||
let size = panels[child_index];
|
||||
return [size.width, size.height, size.width, size.height];
|
||||
}
|
||||
let inset = insets(parent);
|
||||
for (axis, dimension, available, padding) in [
|
||||
(0, child.sizing.width, offer.width, inset.horizontal()),
|
||||
(1, child.sizing.height, offer.height, inset.vertical()),
|
||||
] {
|
||||
if dimension == crate::box_model::Dimension::Fill
|
||||
&& base[axis + 2] >= MAX_DIMENSION as f32
|
||||
&& available < MAX_DIMENSION as f32
|
||||
{
|
||||
base[axis + 2] = (available - padding).max(0.0);
|
||||
base[axis] = base[axis].min(base[axis + 2]);
|
||||
}
|
||||
}
|
||||
margin_constraints(child, base)
|
||||
}
|
||||
|
||||
/// 生成求解开始时的子项约束,供后续测量和分配迭代使用。
|
||||
pub fn initial_child_constraints(
|
||||
parent: &UiNode,
|
||||
child: &UiNode,
|
||||
constraints: [f32; 4],
|
||||
child_index: usize,
|
||||
) -> [f32; 4] {
|
||||
let offer = parent.sizing.available(constraints);
|
||||
occurrence_constraints(
|
||||
parent,
|
||||
child,
|
||||
child_constraints(parent, constraints),
|
||||
Size {
|
||||
width: offer[2],
|
||||
height: offer[3],
|
||||
},
|
||||
child_index,
|
||||
)
|
||||
}
|
||||
/// Scroll cross-axis constraints come from this occurrence's actual viewport,
|
||||
/// including a source parent's allocation rather than only constructor size.
|
||||
/// 根据已分配父尺寸重新细化子项约束,使实际安排能够反向影响换行等测量。
|
||||
pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Size) -> [f32; 4] {
|
||||
if let UiKind::Scroll { padding, axis, .. } = node.kind {
|
||||
if let UiKind::Scroll { axis, .. } = node.kind {
|
||||
let padding = insets(node);
|
||||
return [
|
||||
0.0,
|
||||
0.0,
|
||||
if axis.horizontal() {
|
||||
MAX_DIMENSION as f32
|
||||
} else {
|
||||
(bounds.width - padding * 2.0).max(0.0)
|
||||
(bounds.width - padding.horizontal()).max(0.0)
|
||||
},
|
||||
if axis.vertical() {
|
||||
MAX_DIMENSION as f32
|
||||
} else {
|
||||
(bounds.height - padding * 2.0).max(0.0)
|
||||
(bounds.height - padding.vertical()).max(0.0)
|
||||
},
|
||||
];
|
||||
}
|
||||
child_constraints(node, available)
|
||||
}
|
||||
|
||||
/// 将期望尺寸限制在调用方给出的最小/最大边界内。
|
||||
pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
|
||||
Size {
|
||||
width: size.width.clamp(constraints[0], constraints[2]),
|
||||
@@ -167,23 +361,49 @@ pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
|
||||
}
|
||||
}
|
||||
|
||||
fn container(node: &UiNode) -> Option<(bool, f32, f32)> {
|
||||
/// Effective content inset, including borders, in border-box coordinates.
|
||||
/// 计算布局使用的有效内边距和边框占用,兼容旧的统一 padding。
|
||||
pub fn insets(node: &UiNode) -> Insets {
|
||||
let fallback = match node.kind {
|
||||
UiKind::Row { padding, .. }
|
||||
| UiKind::Column { padding, .. }
|
||||
| UiKind::Button { padding, .. }
|
||||
| UiKind::Scroll { padding, .. }
|
||||
| UiKind::Overlay { padding, .. } => padding,
|
||||
_ => 0.0,
|
||||
};
|
||||
Insets {
|
||||
top: node.padding_edges[0].unwrap_or(fallback) + node.border.top,
|
||||
right: node.padding_edges[1].unwrap_or(fallback) + node.border.right,
|
||||
bottom: node.padding_edges[2].unwrap_or(fallback) + node.border.bottom,
|
||||
left: node.padding_edges[3].unwrap_or(fallback) + node.border.left,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取容器方向与间距等原生布局配置,叶节点不参与容器规则。
|
||||
fn container(node: &UiNode) -> Option<(bool, Insets, f32)> {
|
||||
match &node.kind {
|
||||
UiKind::Row { padding, gap, .. } => Some((true, *padding, *gap)),
|
||||
UiKind::Scroll { padding, gap, .. } => Some((false, *padding, *gap)),
|
||||
UiKind::Column { padding, gap, .. } | UiKind::Button { padding, gap, .. } => {
|
||||
Some((false, *padding, *gap))
|
||||
UiKind::Row { gap, .. } => Some((true, insets(node), *gap)),
|
||||
UiKind::Scroll { gap, .. } => Some((false, insets(node), *gap)),
|
||||
UiKind::Column { gap, .. } | UiKind::Button { gap, .. } => {
|
||||
Some((false, insets(node), *gap))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据容器方向选择主轴,供 flex 和对齐共享同一判断。
|
||||
pub fn main_axis(node: &UiNode) -> Option<usize> {
|
||||
container(node).map(|(row, _, _)| usize::from(!row))
|
||||
}
|
||||
|
||||
/// 根据形状键计算期望尺寸,包括文字换行、容器子项和盒模型占用。
|
||||
fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
let size = match shape {
|
||||
Shape::Sized(shape, sizing, constraints) => {
|
||||
sizing.resolve(measure_shape(shape)?, *constraints)?
|
||||
}
|
||||
Shape::Padded(shape, padding) => padding.outer(measure_shape(shape)?),
|
||||
Shape::Text(value, size, width) => {
|
||||
let style = TextStyle::ui(*size);
|
||||
let intrinsic = text::measure(value, style);
|
||||
@@ -202,13 +422,21 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
.iter()
|
||||
.map(|s| if *row { s.width } else { s.height })
|
||||
.sum::<f32>()
|
||||
+ padding * 2.0
|
||||
+ if *row {
|
||||
padding.horizontal()
|
||||
} else {
|
||||
padding.vertical()
|
||||
}
|
||||
+ gap * children.len().saturating_sub(1) as f32;
|
||||
let cross = children
|
||||
.iter()
|
||||
.map(|s| if *row { s.height } else { s.width })
|
||||
.fold(0.0_f32, f32::max)
|
||||
+ padding * 2.0;
|
||||
+ if *row {
|
||||
padding.vertical()
|
||||
} else {
|
||||
padding.horizontal()
|
||||
};
|
||||
if *row {
|
||||
Size {
|
||||
width: along,
|
||||
@@ -227,19 +455,70 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// 建立影响测量的形状键,将布局属性、子项测量结果和约束组合起来。
|
||||
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
|
||||
Ok(match &node.kind {
|
||||
UiKind::Text { value, size, .. } => Shape::Text(value.clone(), *size, constraints[2]),
|
||||
let available = node.sizing.available(constraints);
|
||||
let shape = match &node.kind {
|
||||
UiKind::Overlay { .. } => {
|
||||
let padding = insets(node);
|
||||
let size = children.into_iter().zip(node.children()).fold(
|
||||
Size::default(),
|
||||
|size, (child, node)| {
|
||||
let child = node.margin.outer(child);
|
||||
Size {
|
||||
width: size.width.max(child.width),
|
||||
height: size.height.max(child.height),
|
||||
}
|
||||
},
|
||||
);
|
||||
Shape::Rect(
|
||||
size.width + padding.horizontal(),
|
||||
size.height + padding.vertical(),
|
||||
)
|
||||
}
|
||||
UiKind::CanvasViewport { width, height, .. } | UiKind::Viewport3D { width, height, .. } => {
|
||||
Shape::Rect(*width, *height)
|
||||
}
|
||||
UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => {
|
||||
let mut size = Size::default();
|
||||
for (position, child) in positions.iter().zip(children) {
|
||||
size.width = size.width.max(position[0] + child.width);
|
||||
size.height = size.height.max(position[1] + child.height);
|
||||
}
|
||||
Shape::Rect(size.width, size.height)
|
||||
}
|
||||
UiKind::Text { value, size, .. } => {
|
||||
let padding = insets(node);
|
||||
Shape::Padded(
|
||||
Box::new(Shape::Text(
|
||||
value.clone(),
|
||||
*size,
|
||||
(available[2] - padding.horizontal()).max(0.0),
|
||||
)),
|
||||
padding,
|
||||
)
|
||||
}
|
||||
UiKind::Rect { width, height, .. } => Shape::Rect(*width, *height),
|
||||
UiKind::Spacer(size) => Shape::Rect(*size, *size),
|
||||
UiKind::Scroll { width, height, .. } => Shape::Rect(*width, *height),
|
||||
_ => {
|
||||
let (row, padding, gap) = container(node).context("unknown native visual protocol")?;
|
||||
let children = children
|
||||
.into_iter()
|
||||
.zip(node.children())
|
||||
.map(|(size, child)| child.margin.outer(size))
|
||||
.collect();
|
||||
Shape::Container(row, padding, gap, children)
|
||||
}
|
||||
};
|
||||
Ok(if node.sizing == crate::box_model::Sizing::default() {
|
||||
shape
|
||||
} else {
|
||||
Shape::Sized(Box::new(shape), node.sizing, constraints)
|
||||
})
|
||||
}
|
||||
|
||||
/// 执行原生节点测量并应用布局约束,作为源协议的原生实现。
|
||||
pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Size> {
|
||||
measure_shape(&shape(node, children, constraints)?)
|
||||
}
|
||||
@@ -247,6 +526,7 @@ pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4])
|
||||
/// Private derived scratch space for one occurrence during layout. Contains no
|
||||
/// managed references; source code cannot mutate it through heap attributes.
|
||||
#[derive(Debug, Default)]
|
||||
/// 只读布局调用期间提供给 Mount 查询的派生上下文,不作为业务状态保留。
|
||||
pub struct Context {
|
||||
pub node: Option<std::sync::Arc<UiNode>>,
|
||||
pub children: Vec<Size>,
|
||||
@@ -254,16 +534,28 @@ pub struct Context {
|
||||
pub bounds: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// 为单个原生节点按已分配几何生成内容命令;容器背景/边框与整树遍历由外层组织。
|
||||
pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderCommand> {
|
||||
let size_for_text = size;
|
||||
let inset = insets(node);
|
||||
match &node.kind {
|
||||
UiKind::Viewport3D { view, .. } => Some(RenderCommand::Scene3D {
|
||||
x: x + inset.left,
|
||||
y: y + inset.top,
|
||||
width: (size.width - inset.horizontal()).max(0.),
|
||||
height: (size.height - inset.vertical()).max(0.),
|
||||
view: view.clone(),
|
||||
}),
|
||||
UiKind::Text { value, size, color } => Some(RenderCommand::Text {
|
||||
x,
|
||||
y,
|
||||
x: x + inset.left,
|
||||
y: y + inset.top,
|
||||
value: value.clone(),
|
||||
size: *size,
|
||||
color: color.clone(),
|
||||
wrap_bounds: Some([size_for_text.width, size_for_text.height]),
|
||||
wrap_bounds: Some([
|
||||
(size_for_text.width - inset.horizontal()).max(0.0),
|
||||
(size_for_text.height - inset.vertical()).max(0.0),
|
||||
]),
|
||||
}),
|
||||
UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect {
|
||||
x,
|
||||
@@ -285,19 +577,130 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
|
||||
}
|
||||
}
|
||||
|
||||
/// 从一个节点及其分配几何生成原生绘制命令,包括背景、边框与内容。
|
||||
pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCommand> {
|
||||
let mut commands: Vec<_> = paint_native(node, x, y, size).into_iter().collect();
|
||||
let border = node.border;
|
||||
for [x, y, width, height] in [
|
||||
[x, y, size.width, border.top.min(size.height)],
|
||||
[
|
||||
x,
|
||||
y + (size.height - border.bottom).max(0.0),
|
||||
size.width,
|
||||
border.bottom.min(size.height),
|
||||
],
|
||||
[x, y, border.left.min(size.width), size.height],
|
||||
[
|
||||
x + (size.width - border.right).max(0.0),
|
||||
y,
|
||||
border.right.min(size.width),
|
||||
size.height,
|
||||
],
|
||||
] {
|
||||
if width > 0.0 && height > 0.0 {
|
||||
commands.push(RenderCommand::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius: 0.0,
|
||||
color: node.border_color.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// Allocate main-axis space with bounded growth and basis-weighted shrinking.
|
||||
/// Preferred sizes come from this occurrence's first measurement in the solve;
|
||||
/// remeasurement must not turn a prior allocation into a new intrinsic basis.
|
||||
/// 判断子项主轴是否采用 fill,从而参与剩余空间分配。
|
||||
fn main_fill(child: &UiNode, row: bool) -> bool {
|
||||
(if row {
|
||||
child.sizing.width
|
||||
} else {
|
||||
child.sizing.height
|
||||
}) == crate::box_model::Dimension::Fill
|
||||
}
|
||||
|
||||
/// 取出主轴 flex/尺寸策略共同约束下的最小和最大分配范围。
|
||||
fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
|
||||
let sizing = child.sizing;
|
||||
let (min, max) = if row {
|
||||
(sizing.min_width, sizing.max_width)
|
||||
} else {
|
||||
(sizing.min_height, sizing.max_height)
|
||||
};
|
||||
(
|
||||
child.flex_policy.min.clamp(min, max),
|
||||
child.flex_policy.max.clamp(min, max),
|
||||
)
|
||||
}
|
||||
|
||||
/// 按权重分配主轴剩余空间,尊重每项 min/max;达到上限的项退出后重新分配剩余量。
|
||||
pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec<Size> {
|
||||
if matches!(node.kind, UiKind::Overlay { .. }) {
|
||||
let inset = insets(node);
|
||||
return children
|
||||
.iter()
|
||||
.zip(node.children())
|
||||
.map(|(size, child)| {
|
||||
let mut size = *size;
|
||||
if child.sizing.width == crate::box_model::Dimension::Fill {
|
||||
size.width = (bounds.width - inset.horizontal() - child.margin.horizontal())
|
||||
.max(0.0)
|
||||
.clamp(child.sizing.min_width, child.sizing.max_width);
|
||||
}
|
||||
if child.sizing.height == crate::box_model::Dimension::Fill
|
||||
|| (child.sizing.height == crate::box_model::Dimension::Auto
|
||||
&& child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch)
|
||||
{
|
||||
size.height = (bounds.height - inset.vertical() - child.margin.vertical())
|
||||
.max(0.0)
|
||||
.clamp(child.sizing.min_height, child.sizing.max_height);
|
||||
}
|
||||
size
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let Some((row, padding, gap)) = container(node) else {
|
||||
return children.to_vec();
|
||||
};
|
||||
let nodes = node.children();
|
||||
let mut output = children.to_vec();
|
||||
for (child, size) in nodes.iter().zip(&mut output) {
|
||||
if child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch {
|
||||
let sizing = child.sizing;
|
||||
if row && sizing.height == crate::box_model::Dimension::Auto {
|
||||
size.height = (bounds.height - padding.vertical() - child.margin.vertical())
|
||||
.max(0.0)
|
||||
.clamp(sizing.min_height, sizing.max_height);
|
||||
} else if !row && sizing.width == crate::box_model::Dimension::Auto {
|
||||
size.width = (bounds.width - padding.horizontal() - child.margin.horizontal())
|
||||
.max(0.0)
|
||||
.clamp(sizing.min_width, sizing.max_width);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !nodes.iter().any(|child| child.has_flex()) {
|
||||
return children.to_vec();
|
||||
return output;
|
||||
}
|
||||
let space = ((if row { bounds.width } else { bounds.height }) as f64
|
||||
- padding as f64 * 2.0
|
||||
- if row {
|
||||
padding.horizontal()
|
||||
} else {
|
||||
padding.vertical()
|
||||
} as f64
|
||||
- nodes
|
||||
.iter()
|
||||
.map(|child| {
|
||||
if row {
|
||||
child.margin.horizontal()
|
||||
} else {
|
||||
child.margin.vertical()
|
||||
}
|
||||
})
|
||||
.sum::<f32>() as f64
|
||||
- gap as f64 * children.len().saturating_sub(1) as f64)
|
||||
.max(0.0);
|
||||
let bases: Vec<f64> = nodes
|
||||
@@ -305,7 +708,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
.zip(preferred)
|
||||
.map(|(child, size)| {
|
||||
child.flex_policy.basis.unwrap_or_else(|| {
|
||||
if child.flex > 0.0 {
|
||||
if child.flex > 0.0 || main_fill(child, row) {
|
||||
0.0
|
||||
} else if row {
|
||||
size.width
|
||||
@@ -319,7 +722,8 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
.iter()
|
||||
.zip(&bases)
|
||||
.map(|(child, basis)| {
|
||||
basis.clamp(child.flex_policy.min as f64, child.flex_policy.max as f64)
|
||||
let (min, max) = main_limits(child, row);
|
||||
basis.clamp(min as f64, max as f64)
|
||||
})
|
||||
.collect();
|
||||
let free = space - sizes.iter().sum::<f64>();
|
||||
@@ -329,7 +733,11 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
.zip(&bases)
|
||||
.map(|(child, basis)| {
|
||||
if growing {
|
||||
child.flex as f64
|
||||
if main_fill(child, row) && child.flex == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
child.flex as f64
|
||||
}
|
||||
} else {
|
||||
child.flex_policy.shrink as f64 * basis
|
||||
}
|
||||
@@ -338,11 +746,8 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
let limits: Vec<f64> = nodes
|
||||
.iter()
|
||||
.map(|child| {
|
||||
if growing {
|
||||
child.flex_policy.max as f64
|
||||
} else {
|
||||
child.flex_policy.min as f64
|
||||
}
|
||||
let (min, max) = main_limits(child, row);
|
||||
if growing { max as f64 } else { min as f64 }
|
||||
})
|
||||
.collect();
|
||||
let mut active: Vec<usize> = (0..nodes.len())
|
||||
@@ -381,7 +786,6 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut output = children.to_vec();
|
||||
for (size, along) in output.iter_mut().zip(sizes) {
|
||||
if row {
|
||||
size.width = along as f32;
|
||||
@@ -392,6 +796,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
output
|
||||
}
|
||||
|
||||
/// 根据最终分配尺寸计算子项原点,处理容器方向、对齐、间距与外边距。
|
||||
pub fn arrange_native(
|
||||
node: &UiNode,
|
||||
x: f32,
|
||||
@@ -399,16 +804,78 @@ pub fn arrange_native(
|
||||
bounds: Size,
|
||||
children: &[Size],
|
||||
) -> Result<Vec<(f32, f32)>> {
|
||||
if matches!(node.kind, UiKind::Viewport3D { .. }) {
|
||||
return Ok(node.children().iter().map(|_| (0., 0.)).collect());
|
||||
}
|
||||
if let UiKind::Overlay { .. } = node.kind {
|
||||
let padding = insets(node);
|
||||
let horizontal = match node.main_align {
|
||||
MainAlign::Center => CrossAlign::Center,
|
||||
MainAlign::End => CrossAlign::End,
|
||||
_ => CrossAlign::Start,
|
||||
};
|
||||
return children
|
||||
.iter()
|
||||
.zip(node.children())
|
||||
.map(|(size, child)| {
|
||||
let outer = child.margin.outer(*size);
|
||||
let point = (
|
||||
x + padding.left
|
||||
+ child.margin.left
|
||||
+ horizontal.offset(bounds.width - padding.horizontal(), outer.width),
|
||||
y + padding.top
|
||||
+ child.margin.top
|
||||
+ child
|
||||
.align_self
|
||||
.unwrap_or(node.cross_align)
|
||||
.offset(bounds.height - padding.vertical(), outer.height),
|
||||
);
|
||||
coordinate(point.0)?;
|
||||
coordinate(point.1)?;
|
||||
coordinate(point.0 + size.width)?;
|
||||
coordinate(point.1 + size.height)?;
|
||||
Ok(point)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if matches!(node.kind, UiKind::CanvasViewport { .. }) {
|
||||
let inset = insets(node);
|
||||
return Ok(node
|
||||
.children()
|
||||
.iter()
|
||||
.map(|_| (x + inset.left, y + inset.top))
|
||||
.collect());
|
||||
}
|
||||
if let UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } = &node.kind {
|
||||
return Ok(positions
|
||||
.iter()
|
||||
.map(|point| (x + point[0], y + point[1]))
|
||||
.collect());
|
||||
}
|
||||
let Some((row, padding, gap)) = container(node) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let occupied = children
|
||||
.iter()
|
||||
.map(|child| if row { child.width } else { child.height })
|
||||
.zip(node.children())
|
||||
.map(|(child, node)| {
|
||||
if row {
|
||||
child.width + node.margin.horizontal()
|
||||
} else {
|
||||
child.height + node.margin.vertical()
|
||||
}
|
||||
})
|
||||
.sum::<f32>()
|
||||
+ gap * children.len().saturating_sub(1) as f32;
|
||||
let available = if row { bounds.width } else { bounds.height };
|
||||
let spare = (available - padding * 2.0 - occupied).max(0.0);
|
||||
let spare = (available
|
||||
- if row {
|
||||
padding.horizontal()
|
||||
} else {
|
||||
padding.vertical()
|
||||
}
|
||||
- occupied)
|
||||
.max(0.0);
|
||||
let offset = match node.main_align {
|
||||
MainAlign::Center => spare / 2.0,
|
||||
MainAlign::End => spare,
|
||||
@@ -419,39 +886,77 @@ pub fn arrange_native(
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut cursor = padding + offset;
|
||||
let mut cursor = if row { padding.left } else { padding.top } + offset;
|
||||
let mut points = Vec::with_capacity(children.len());
|
||||
for child in children {
|
||||
for (index, child) in children.iter().enumerate() {
|
||||
let child_node = &node.children()[index];
|
||||
let margin = child_node.margin;
|
||||
let align = child_node.align_self.unwrap_or(node.cross_align);
|
||||
let cross = align.offset(
|
||||
if row {
|
||||
bounds.height - padding.vertical()
|
||||
} else {
|
||||
bounds.width - padding.horizontal()
|
||||
},
|
||||
if row {
|
||||
child.height + margin.vertical()
|
||||
} else {
|
||||
child.width + margin.horizontal()
|
||||
},
|
||||
);
|
||||
let point = if row {
|
||||
(x + cursor, y + padding)
|
||||
(
|
||||
x + cursor + margin.left,
|
||||
y + padding.top + cross + margin.top,
|
||||
)
|
||||
} else {
|
||||
(x + padding, y + cursor)
|
||||
(
|
||||
x + padding.left + cross + margin.left,
|
||||
y + cursor + margin.top,
|
||||
)
|
||||
};
|
||||
bounded(point.0)?;
|
||||
bounded(point.1)?;
|
||||
bounded(point.0 + child.width)?;
|
||||
bounded(point.1 + child.height)?;
|
||||
coordinate(point.0)?;
|
||||
coordinate(point.1)?;
|
||||
coordinate(point.0 + child.width)?;
|
||||
coordinate(point.1 + child.height)?;
|
||||
points.push(point);
|
||||
cursor += if row { child.width } else { child.height };
|
||||
cursor += if row {
|
||||
margin.horizontal()
|
||||
} else {
|
||||
margin.vertical()
|
||||
};
|
||||
cursor += gap + distributed_gap;
|
||||
}
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
impl LayoutRuntime {
|
||||
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
|
||||
pub fn root_constraints(&self) -> [f32; 4] {
|
||||
self.page_mode.map_or_else(
|
||||
|| root_constraints(self.viewport),
|
||||
|mode| mode.constraints(self.viewport),
|
||||
)
|
||||
}
|
||||
/// 撤销指定业务组件的测量缓存,下一次遇到该节点重新求值。
|
||||
pub fn invalidate(&mut self, block: &str) {
|
||||
self.sizes.remove(block);
|
||||
}
|
||||
/// 提高新 Mount ID 的分配下限,避免取消/重试后复用已经暴露过的出现身份。
|
||||
pub fn reserve_identity_floor(&mut self, floor: u64) {
|
||||
self.next_id = self.next_id.max(floor);
|
||||
}
|
||||
/// 返回出现 ID 的当前分配高水位,供桥接层在取消/重试后继续保持唯一性。
|
||||
pub fn identity_high_watermark(&self) -> u64 {
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// 清空结构出现身份表;业务对象和源状态不因此被复制。
|
||||
pub fn clear_occurrences(&mut self) {
|
||||
self.ids.clear();
|
||||
}
|
||||
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
|
||||
pub fn frame(&mut self, root: &UiNode) -> Result<Frame> {
|
||||
// Stage all cache/identity changes; a rejected frame leaves the last
|
||||
// valid occurrence registry intact and never publishes partial geometry.
|
||||
@@ -462,6 +967,7 @@ impl LayoutRuntime {
|
||||
}
|
||||
|
||||
/// Allocate stable occurrence identities without measuring or publishing geometry.
|
||||
/// 先建立稳定的出现身份和父子关系,但不把此阶段当成最终测量结果。
|
||||
pub fn structure(&mut self, root: &UiNode) -> Result<Frame> {
|
||||
let mut next = self.clone();
|
||||
let frame = next.build(root, true)?;
|
||||
@@ -469,6 +975,7 @@ impl LayoutRuntime {
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// 以形状、子项尺寸和约束查缓存;未命中才执行测量并更新本轮统计。
|
||||
pub fn measure_node(
|
||||
&mut self,
|
||||
node: &UiNode,
|
||||
@@ -489,13 +996,14 @@ impl LayoutRuntime {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// 迭代展开出现树、计算约束和几何、产生绘制命令;结构准备与完整布局共用此驱动。
|
||||
fn build(&mut self, root: &UiNode, structural: bool) -> Result<Frame> {
|
||||
self.measured = 0;
|
||||
self.arranged = 0;
|
||||
self.settle_passes = usize::from(!structural);
|
||||
self.slices = usize::from(!structural);
|
||||
let mut items = Vec::<Item<'_>>::new();
|
||||
let mut work = vec![(root, None::<usize>, 0, root_constraints(self.viewport))];
|
||||
let mut work = vec![(root, None::<usize>, 0, self.root_constraints())];
|
||||
let mut used = HashSet::new();
|
||||
while let Some((node, parent, slot, constraints)) = work.pop() {
|
||||
if items.len() >= MAX_LAYOUT_VISITS {
|
||||
@@ -533,12 +1041,14 @@ impl LayoutRuntime {
|
||||
id,
|
||||
constraints,
|
||||
});
|
||||
for (child, slot) in node.children().iter().zip(&node.slots).rev() {
|
||||
for (child_index, (child, slot)) in
|
||||
node.children().iter().zip(&node.slots).enumerate().rev()
|
||||
{
|
||||
work.push((
|
||||
child.as_ref(),
|
||||
Some(index),
|
||||
*slot,
|
||||
child_constraints(node, constraints),
|
||||
initial_child_constraints(node, child, constraints, child_index),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -568,11 +1078,20 @@ impl LayoutRuntime {
|
||||
self.arranged += usize::from(!structural);
|
||||
let (x, y) = points[index];
|
||||
let size = sizes[index];
|
||||
bounded(x)?;
|
||||
bounded(y)?;
|
||||
bounded(x + size.width)?;
|
||||
bounded(y + size.height)?;
|
||||
coordinate(x)?;
|
||||
coordinate(y)?;
|
||||
coordinate(x + size.width)?;
|
||||
coordinate(y + size.height)?;
|
||||
mounts.push(MountGeometry {
|
||||
panel: None,
|
||||
canvas_viewport: matches!(item.node.kind, UiKind::CanvasViewport { .. }),
|
||||
scene3d_viewport: matches!(item.node.kind, UiKind::Viewport3D { .. }),
|
||||
camera_controls: matches!(
|
||||
item.node.kind,
|
||||
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { controls: true, .. }
|
||||
),
|
||||
viewport_clip: None,
|
||||
view: None,
|
||||
id: item.id,
|
||||
clip: None,
|
||||
scroll: None,
|
||||
@@ -585,9 +1104,10 @@ impl LayoutRuntime {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
interactive: item.node.interactive,
|
||||
pointer_events: item.node.pointer_events,
|
||||
});
|
||||
if !structural && let Some(command) = paint_native(item.node, x, y, size) {
|
||||
commands.push(command);
|
||||
if !structural {
|
||||
commands.extend(paint_commands(item.node, x, y, size));
|
||||
}
|
||||
if structural {
|
||||
continue;
|
||||
@@ -626,10 +1146,12 @@ impl LayoutRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
|
||||
pub fn frame(root: &UiNode) -> Result<Frame> {
|
||||
LayoutRuntime::default().frame(root)
|
||||
}
|
||||
|
||||
/// 为指定逻辑视口建立临时布局运行时并求完整帧。
|
||||
pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result<Frame> {
|
||||
LayoutRuntime {
|
||||
viewport: Some(viewport),
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod box_model;
|
||||
pub(crate) mod display_list;
|
||||
pub(crate) mod geometry;
|
||||
pub(crate) mod layout;
|
||||
pub(crate) mod native_visual;
|
||||
pub(crate) mod page;
|
||||
pub(crate) mod scene;
|
||||
pub(crate) mod scene3d;
|
||||
pub(crate) mod scroll;
|
||||
pub(crate) mod software_renderer;
|
||||
pub(crate) mod source_hit;
|
||||
pub(crate) mod source_layout;
|
||||
pub(crate) mod source_paint;
|
||||
pub(crate) mod text;
|
||||
pub(crate) mod viewport;
|
||||
|
||||
pub mod image;
|
||||
pub mod text_flow;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。
|
||||
//! 将 Constraints、Size、Bounds 与 PaintCommand 在托管值和 Rust 值之间转换;原生方法与源覆盖方法共用调用契约。
|
||||
//!
|
||||
//! Native visual protocol entry points. Values are ordinary managed Endor
|
||||
//! objects; geometry is scoped to a live occurrence, never stored on a Block.
|
||||
use crate::{
|
||||
@@ -12,9 +15,11 @@ use endor_core::{
|
||||
NativeContext, NativeResult, ObjectRef, ObjectStore, Value, Vm, VmError,
|
||||
};
|
||||
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
|
||||
fn object(value: &Value) -> Result<ObjectRef, VmError> {
|
||||
if let Value::Object(object) = value {
|
||||
Ok(*object)
|
||||
@@ -22,6 +27,7 @@ fn object(value: &Value) -> Result<ObjectRef, VmError> {
|
||||
Err(invalid("visual protocol requires objects"))
|
||||
}
|
||||
}
|
||||
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
|
||||
pub(crate) fn number(
|
||||
cx: &NativeContext<'_>,
|
||||
object: ObjectRef,
|
||||
@@ -40,9 +46,11 @@ pub(crate) fn number(
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
/// 将原生布局计算结果封装为协议需要的托管 Value。
|
||||
pub(crate) fn value(value: f32) -> Value {
|
||||
Value::Number((value as f64).into())
|
||||
}
|
||||
/// 新建指定名义类型的托管记录,并逐项设置字段,作为视觉协议的结果返回。
|
||||
pub(crate) fn record(
|
||||
cx: &mut NativeContext<'_>,
|
||||
desktop: ObjectRef,
|
||||
@@ -56,6 +64,7 @@ pub(crate) fn record(
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
/// 在约束提供的视口内执行原生布局,从根出现提取宽高,再按协议约束限制结果。
|
||||
fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size, VmError> {
|
||||
let frame = layout::frame_with_viewport(
|
||||
node,
|
||||
@@ -75,6 +84,7 @@ fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size,
|
||||
})
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
|
||||
enum Query {
|
||||
Measure,
|
||||
Arrange,
|
||||
@@ -82,12 +92,14 @@ enum Query {
|
||||
HitTest,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
|
||||
struct Method {
|
||||
query: Query,
|
||||
desktop: ObjectRef,
|
||||
signature: CallSignature,
|
||||
}
|
||||
/// Only this actual implementation can use the matching intrinsic fast path.
|
||||
/// 判断方法是否为匹配协议的宿主原生实现,决定能否安全使用快速路径。
|
||||
pub(crate) fn is_intrinsic(
|
||||
store: &ObjectStore,
|
||||
function: ObjectRef,
|
||||
@@ -122,13 +134,16 @@ pub(crate) fn is_intrinsic(
|
||||
}
|
||||
|
||||
impl NativeCallable for Method {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(3))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [owner, context, input] = args else {
|
||||
return Err(invalid("visual method expects a Mount and an input value"));
|
||||
@@ -202,8 +217,8 @@ impl NativeCallable for Method {
|
||||
)?)
|
||||
}
|
||||
Query::Arrange => {
|
||||
let x = number(cx, input, "x", false)?;
|
||||
let y = number(cx, input, "y", false)?;
|
||||
let x = number(cx, input, "x", true)?;
|
||||
let y = number(cx, input, "y", true)?;
|
||||
let width = number(cx, input, "width", false)?;
|
||||
let height = number(cx, input, "height", false)?;
|
||||
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
|
||||
@@ -214,13 +229,20 @@ impl NativeCallable for Method {
|
||||
} else {
|
||||
node.children()
|
||||
.iter()
|
||||
.map(|child| {
|
||||
.enumerate()
|
||||
.map(|(child_index, child)| {
|
||||
size(
|
||||
child,
|
||||
layout::arranged_child_constraints(
|
||||
layout::occurrence_constraints(
|
||||
&node,
|
||||
[0.0, 0.0, width, height],
|
||||
child,
|
||||
layout::arranged_child_constraints(
|
||||
&node,
|
||||
[0.0, 0.0, width, height],
|
||||
Size { width, height },
|
||||
),
|
||||
Size { width, height },
|
||||
child_index,
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -268,8 +290,25 @@ impl NativeCallable for Method {
|
||||
return Err(invalid("visual bounds exceed limits"));
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
if let Some(command) = layout::paint_native(&node, x, y, Size { width, height }) {
|
||||
for command in layout::paint_commands(&node, x, y, Size { width, height }) {
|
||||
let fields = match command {
|
||||
RenderCommand::Scene3D {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
view,
|
||||
} => {
|
||||
let snapshot = cx.allocate(crate::scene3d::PaintView(view))?;
|
||||
vec![
|
||||
("kind", Value::Text("scene3d".into())),
|
||||
("x", value(x)),
|
||||
("y", value(y)),
|
||||
("width", value(width)),
|
||||
("height", value(height)),
|
||||
("scene", Value::Object(snapshot)),
|
||||
]
|
||||
}
|
||||
RenderCommand::Text {
|
||||
x,
|
||||
y,
|
||||
@@ -328,15 +367,18 @@ impl NativeCallable for Method {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Point 的矩形/圆形命中辅助方法,保存结果类型和具体几何判定种类。
|
||||
struct PointMethod {
|
||||
expected: ObjectRef,
|
||||
circle: bool,
|
||||
signature: CallSignature,
|
||||
}
|
||||
impl NativeCallable for PointMethod {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(if self.circle {
|
||||
4
|
||||
@@ -345,6 +387,7 @@ impl NativeCallable for PointMethod {
|
||||
}))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let Some(Value::Object(point)) = args.first() else {
|
||||
return Err(invalid("Point method requires a Point"));
|
||||
@@ -395,6 +438,7 @@ impl NativeCallable for PointMethod {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。
|
||||
//! 新组合应优先使用普通布局、ScrollView 和独立场景视口;保留这里是为了兼容已有 window/vertical/horizontal/canvas 页面。
|
||||
//!
|
||||
//! Page space is independent of the native window. The camera is applied once
|
||||
//! for painting and inverted once for input; layout and Mounts stay in world units.
|
||||
use crate::{geometry::Point, layout::Size};
|
||||
use endor_core::VmError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
/// 旧 Page 的有限或自由空间模式,新场景视口不依赖它表达布局方向。
|
||||
pub enum Mode {
|
||||
#[default]
|
||||
Window,
|
||||
Vertical,
|
||||
Horizontal,
|
||||
Canvas,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Window => "window",
|
||||
Self::Vertical => "vertical",
|
||||
Self::Horizontal => "horizontal",
|
||||
Self::Canvas => "canvas",
|
||||
}
|
||||
}
|
||||
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(name: &str) -> Result<Self, VmError> {
|
||||
match name {
|
||||
"window" => Ok(Self::Window),
|
||||
"vertical" => Ok(Self::Vertical),
|
||||
"horizontal" => Ok(Self::Horizontal),
|
||||
"canvas" => Ok(Self::Canvas),
|
||||
_ => Err(VmError::InvalidArgument(
|
||||
"Page.mode expects window, vertical, horizontal or canvas",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 为旧页面模式生成有限轴和可延伸轴的根约束。
|
||||
pub fn constraints(self, viewport: Option<Size>) -> [f32; 4] {
|
||||
let Some(size) = viewport else {
|
||||
return crate::layout::root_constraints(None);
|
||||
};
|
||||
let limit = crate::ui_bridge::MAX_DIMENSION as f32;
|
||||
match self {
|
||||
Self::Window => [size.width, size.height, size.width, size.height],
|
||||
Self::Vertical => [size.width, size.height, size.width, limit],
|
||||
Self::Horizontal => [size.width, size.height, limit, size.height],
|
||||
Self::Canvas => [0.0, 0.0, limit, limit],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// 页面级兼容相机状态和有限内容范围,支持绘制与命中正逆变换。
|
||||
pub struct Surface {
|
||||
pub mode: Mode,
|
||||
pub page: String,
|
||||
pub pan: Point,
|
||||
#[serde(default = "unit")]
|
||||
pub zoom: f32,
|
||||
pub extent: Size,
|
||||
}
|
||||
|
||||
/// 生成页面相机计算所需的单位比例或边界默认值。
|
||||
fn unit() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Default for Surface {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: Mode::Window,
|
||||
page: String::new(),
|
||||
pan: Point::default(),
|
||||
zoom: 1.0,
|
||||
extent: Size::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(mode: Mode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
zoom: 1.0,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 把页面视图坐标反算为旧 Page 相机下的世界坐标。
|
||||
pub fn to_world(&self, point: Point) -> Point {
|
||||
Point::new(
|
||||
point.x / self.zoom + self.pan.x,
|
||||
point.y / self.zoom + self.pan.y,
|
||||
)
|
||||
}
|
||||
|
||||
/// 把旧页面世界坐标变换到可见视图坐标。
|
||||
pub fn to_view(&self, point: Point) -> Point {
|
||||
Point::new(
|
||||
(point.x - self.pan.x) * self.zoom,
|
||||
(point.y - self.pan.y) * self.zoom,
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
|
||||
pub fn contains(&self, point: [f32; 2], viewport: Option<Size>) -> bool {
|
||||
let point = self.to_view(Point::new(point[0], point[1]));
|
||||
point.x >= 0.0
|
||||
&& point.y >= 0.0
|
||||
&& viewport.is_none_or(|size| point.x < size.width && point.y < size.height)
|
||||
}
|
||||
|
||||
/// 只对有限页面按实际内容范围钳制偏移;自由画布不按内容边缘停止导航。
|
||||
pub fn clamp(&mut self, viewport: Size) {
|
||||
match self.mode {
|
||||
Mode::Window => {
|
||||
self.pan = Point::default();
|
||||
self.zoom = 1.0;
|
||||
}
|
||||
Mode::Vertical => {
|
||||
self.zoom = 1.0;
|
||||
self.pan.x = 0.0;
|
||||
self.pan.y = self
|
||||
.pan
|
||||
.y
|
||||
.clamp(0.0, (self.extent.height - viewport.height).max(0.0));
|
||||
}
|
||||
Mode::Horizontal => {
|
||||
self.zoom = 1.0;
|
||||
self.pan.y = 0.0;
|
||||
self.pan.x = self
|
||||
.pan
|
||||
.x
|
||||
.clamp(0.0, (self.extent.width - viewport.width).max(0.0));
|
||||
}
|
||||
Mode::Canvas => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn camera_inverse_and_cursor_anchored_zoom_preserve_world_position() {
|
||||
let mut view = Surface::new(Mode::Canvas);
|
||||
view.pan = Point::new(-240.0, 120.0);
|
||||
view.zoom = 0.75;
|
||||
let pointer = Point::new(300.0, 180.0);
|
||||
let world = view.to_world(pointer);
|
||||
assert_eq!(view.to_view(world), pointer);
|
||||
view.zoom = 2.0;
|
||||
view.pan = Point::new(
|
||||
world.x - pointer.x / view.zoom,
|
||||
world.y - pointer.y / view.zoom,
|
||||
);
|
||||
assert_eq!(view.to_view(world), pointer);
|
||||
assert!(view.contains(
|
||||
[world.x, world.y],
|
||||
Some(Size {
|
||||
width: 500.0,
|
||||
height: 400.0
|
||||
})
|
||||
));
|
||||
assert!(!view.contains(
|
||||
[-1000.0, -1000.0],
|
||||
Some(Size {
|
||||
width: 500.0,
|
||||
height: 400.0
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_pages_clamp_after_content_shrinks() {
|
||||
let mut view = Surface::new(Mode::Vertical);
|
||||
view.pan = Point::new(25.0, 900.0);
|
||||
view.extent = Size {
|
||||
width: 400.0,
|
||||
height: 800.0,
|
||||
};
|
||||
view.clamp(Size {
|
||||
width: 400.0,
|
||||
height: 600.0,
|
||||
});
|
||||
assert_eq!(view.pan, Point::new(0.0, 200.0));
|
||||
view.extent.height = 100.0;
|
||||
view.clamp(Size {
|
||||
width: 400.0,
|
||||
height: 600.0,
|
||||
});
|
||||
assert_eq!(view.pan.y, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
//! 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。
|
||||
//! 窗口逻辑尺寸与设备像素比例在这里衔接。页面内容先应用其视角并裁剪,宿主控件的区域和命中布局另外返回。
|
||||
//!
|
||||
use crate::{
|
||||
activity::Activity,
|
||||
developer_tools::{self, DeveloperToolsLayout, Workspace as DeveloperToolsState},
|
||||
display_list::DisplayList,
|
||||
display_list::{DisplayList, DrawCommand},
|
||||
document::RenderDocument,
|
||||
geometry::{Point, Rect},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
/// 宿主管理区域的几何布局,供窗口事件分辨页面与工具命中。
|
||||
pub struct SceneLayout {
|
||||
pub developer_tools: DeveloperToolsLayout,
|
||||
}
|
||||
|
||||
/// 组合页面、开发工具和侧栏的设备像素显示列表,同时返回供命中使用的宿主布局。
|
||||
pub fn build_scene(
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -17,7 +23,7 @@ pub fn build_scene(
|
||||
document: &RenderDocument,
|
||||
developer_tools_state: &mut DeveloperToolsState,
|
||||
activity: &Activity,
|
||||
tab_chrome: crate::tabs::Chrome,
|
||||
sidebar: &mut crate::sidebar::State,
|
||||
) -> DisplayScene {
|
||||
let tool = document
|
||||
.frame
|
||||
@@ -27,69 +33,54 @@ pub fn build_scene(
|
||||
.and_then(|tab| tab.developer_tool.as_deref());
|
||||
developer_tools_state.select(tool);
|
||||
developer_tools_state.document_updated(document);
|
||||
let mut display_list = document.display_list(scale).unwrap_or_else(|_| {
|
||||
let left = sidebar.left();
|
||||
let surface = &document.frame.surface;
|
||||
let content = Rect::new(
|
||||
left * scale,
|
||||
0.0,
|
||||
(width as f32 - left * scale).max(0.0),
|
||||
height as f32,
|
||||
);
|
||||
let zoom = if tool.is_some() { 1.0 } else { surface.zoom };
|
||||
let mut page = document.display_list(scale * zoom).unwrap_or_else(|_| {
|
||||
RenderDocument::default()
|
||||
.display_list(scale)
|
||||
.expect("default document is valid")
|
||||
});
|
||||
let offset = crate::tabs::height(&document.frame.tabs);
|
||||
let inset = crate::tabs::INSET;
|
||||
let left = crate::tabs::RAIL;
|
||||
let content = crate::geometry::Rect::new(
|
||||
left * scale,
|
||||
offset * scale,
|
||||
(width as f32 - (left + inset) * scale).max(0.0),
|
||||
(height as f32 - (offset + inset) * scale).max(0.0),
|
||||
);
|
||||
display_list.translate(crate::geometry::Point::new(left * scale, offset * scale));
|
||||
let mut framed = DisplayList::default();
|
||||
use crate::display_list::DrawCommand;
|
||||
framed.push(DrawCommand::Clear(crate::system_ui::theme::BAR));
|
||||
framed.push(DrawCommand::PushClip(content));
|
||||
for command in display_list.commands() {
|
||||
if let DrawCommand::Clear(color) = command {
|
||||
framed.push(DrawCommand::FillRoundedRect {
|
||||
rect: content,
|
||||
radius: 10.0 * scale,
|
||||
color: *color,
|
||||
});
|
||||
} else {
|
||||
framed.push(command.clone());
|
||||
page.translate(Point::new(
|
||||
(left - surface.pan.x * zoom) * scale,
|
||||
-surface.pan.y * zoom * scale,
|
||||
));
|
||||
let mut display_list = DisplayList::default();
|
||||
display_list.push(DrawCommand::Clear(
|
||||
crate::document::parse_color(&document.frame.background)
|
||||
.unwrap_or(crate::system_ui::theme::BG),
|
||||
));
|
||||
display_list.push(DrawCommand::PushClip(content));
|
||||
for command in page.commands() {
|
||||
if !matches!(command, DrawCommand::Clear(_)) {
|
||||
display_list.push(command.clone());
|
||||
}
|
||||
}
|
||||
framed.push(DrawCommand::PopClip);
|
||||
let mut display_list = framed;
|
||||
crate::tabs::draw(
|
||||
tab_chrome.offset,
|
||||
&mut display_list,
|
||||
&document.frame.tabs,
|
||||
width as f32 / scale,
|
||||
scale,
|
||||
tab_chrome.pointer,
|
||||
&tab_chrome.visual,
|
||||
);
|
||||
if let Some(index) = tab_chrome.insertion {
|
||||
crate::tabs::draw_insertion(
|
||||
tab_chrome.offset,
|
||||
&mut display_list,
|
||||
&document.frame.tabs,
|
||||
width as f32 / scale,
|
||||
index,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
display_list.push(DrawCommand::PopClip);
|
||||
let (mut developer_list, mut developer_layout) = developer_tools::build(
|
||||
width.saturating_sub(((left + inset) * scale) as u32),
|
||||
height.saturating_sub(((offset + inset) * scale) as u32),
|
||||
width.saturating_sub((left * scale) as u32),
|
||||
height,
|
||||
scale,
|
||||
document,
|
||||
developer_tools_state,
|
||||
activity,
|
||||
);
|
||||
developer_list.translate(crate::geometry::Point::new(left * scale, offset * scale));
|
||||
developer_layout.translate(left, offset);
|
||||
developer_list.translate(Point::new(left * scale, 0.0));
|
||||
developer_layout.translate(left, 0.0);
|
||||
display_list.extend(developer_list);
|
||||
|
||||
let mut chrome = sidebar.draw(
|
||||
&document.frame.tabs,
|
||||
height as f32 / scale,
|
||||
developer_tools_state.accent,
|
||||
);
|
||||
chrome.scale(scale);
|
||||
display_list.extend(chrome);
|
||||
DisplayScene {
|
||||
display_list,
|
||||
layout: SceneLayout {
|
||||
@@ -98,6 +89,7 @@ pub fn build_scene(
|
||||
}
|
||||
}
|
||||
|
||||
/// 整窗合成结果:显示列表与同帧管理交互几何一起返回。
|
||||
pub struct DisplayScene {
|
||||
pub display_list: DisplayList,
|
||||
pub layout: SceneLayout,
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
//! 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。
|
||||
//! 不可变 View 可以缓存光栅结果;相机变化会产生新视图。当前不是 GPU 后端,像素和光栅工作预算用于限制分配与循环工作量。
|
||||
//! 面板 UI 先渲染为有限纹理,再以透视校正 UV 映射进世界。与相机相关的投影宽高比由具体视口提供。
|
||||
//!
|
||||
//! Software 3D surfaces share the application's existing CPU compositor. Mesh
|
||||
//! visibility and picking use the same camera and transformed world triangles.
|
||||
pub mod math;
|
||||
pub mod panel;
|
||||
pub mod spatial;
|
||||
use math::{Camera, Matrix, Projection, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const MAX_TRIANGLES: usize = 65_536;
|
||||
pub const MAX_VERTICES: usize = 32_768;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 不可变顶点、三角形和可选 UV;多个网格可共享同一数值缓冲。
|
||||
pub struct Geometry {
|
||||
pub vertices: Vec<Vec3>,
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
#[serde(default)]
|
||||
pub uv: Vec<[f32; 2]>,
|
||||
}
|
||||
impl Geometry {
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(&self) -> bool {
|
||||
self.vertices.len() <= MAX_VERTICES
|
||||
&& (self.uv.is_empty()
|
||||
|| self.uv.len() == self.vertices.len()
|
||||
&& self.uv.iter().flatten().all(|value| value.is_finite()))
|
||||
&& self.triangles.len() <= MAX_TRIANGLES
|
||||
&& self.vertices.iter().all(|point| point.valid())
|
||||
&& self
|
||||
.triangles
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|&index| (index as usize) < self.vertices.len())
|
||||
}
|
||||
/// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。
|
||||
pub fn cuboid(width: f32, height: f32, depth: f32) -> Self {
|
||||
let (x, y, z) = (width / 2., height / 2., depth / 2.);
|
||||
Self {
|
||||
uv: vec![],
|
||||
vertices: vec![
|
||||
Vec3::new(-x, -y, -z),
|
||||
Vec3::new(x, -y, -z),
|
||||
Vec3::new(x, y, -z),
|
||||
Vec3::new(-x, y, -z),
|
||||
Vec3::new(-x, -y, z),
|
||||
Vec3::new(x, -y, z),
|
||||
Vec3::new(x, y, z),
|
||||
Vec3::new(-x, y, z),
|
||||
],
|
||||
triangles: vec![
|
||||
[0, 2, 1],
|
||||
[0, 3, 2],
|
||||
[4, 5, 6],
|
||||
[4, 6, 7],
|
||||
[0, 1, 5],
|
||||
[0, 5, 4],
|
||||
[3, 7, 6],
|
||||
[3, 6, 2],
|
||||
[0, 4, 7],
|
||||
[0, 7, 3],
|
||||
[1, 2, 6],
|
||||
[1, 6, 5],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 一次场景实例:几何、世界矩阵、颜色与可选 UI 面板,身份用于拾取回关联。
|
||||
pub struct Mesh {
|
||||
pub identity: String,
|
||||
pub geometry: Arc<Geometry>,
|
||||
pub world: Matrix,
|
||||
pub color: [f32; 3],
|
||||
pub panel: Option<Panel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。
|
||||
pub struct UiSurface {
|
||||
pub commands: Vec<crate::document::RenderCommand>,
|
||||
pub clips: Vec<Option<[f32; 4]>>,
|
||||
pub views: Vec<Option<crate::viewport::Transform>>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 世界平面的有限 UI 纹理描述,包含像素尺寸、背景与内部显示内容。
|
||||
pub struct Panel {
|
||||
pub key: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub background: [f32; 3],
|
||||
pub surface: Option<Arc<UiSurface>>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 方向光的原生数值,方向和强度在场景快照阶段校验。
|
||||
pub struct Light {
|
||||
pub direction: Vec3,
|
||||
pub color: [f32; 3],
|
||||
pub intensity: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// 不含摄像机的世界实例和光照集合;BVH 是可丢弃的派生缓存。
|
||||
pub struct Scene {
|
||||
pub meshes: Vec<Mesh>,
|
||||
pub lights: Vec<Light>,
|
||||
pub ambient: f32,
|
||||
pub background: [f32; 3],
|
||||
#[serde(skip)]
|
||||
pub acceleration: Arc<spatial::Index>,
|
||||
}
|
||||
impl PartialEq for Scene {
|
||||
/// 比较会影响场景结果的数据;加速索引属于派生缓存,不参与场景语义相等。
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.meshes == other.meshes
|
||||
&& self.lights == other.lights
|
||||
&& self.ambient == other.ambient
|
||||
&& self.background == other.background
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// 显式相机与共享世界的组合,一个 Scene 可由多个独立 View 观察。
|
||||
pub struct View {
|
||||
pub scene: Arc<Scene>,
|
||||
pub camera: Camera,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。
|
||||
pub struct PaintView(pub Arc<View>);
|
||||
impl endor_core::ObjectPayload for PaintView {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"immutable-3d-paint"
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 射线与实际三角形的精确交点,含实例身份、距离、法线和重心坐标。
|
||||
pub struct Hit {
|
||||
pub instance: usize,
|
||||
pub identity: String,
|
||||
pub point: Vec3,
|
||||
pub normal: Vec3,
|
||||
pub distance: f32,
|
||||
pub triangle: usize,
|
||||
pub barycentric: [f32; 3],
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(&self) -> bool {
|
||||
thread_local! { static DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) }; }
|
||||
// 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。
|
||||
struct Guard;
|
||||
impl Drop for Guard {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
fn drop(&mut self) {
|
||||
DEPTH.with(|depth| depth.set(depth.get() - 1));
|
||||
}
|
||||
}
|
||||
if !DEPTH.with(|depth| {
|
||||
if depth.get() >= 8 {
|
||||
false
|
||||
} else {
|
||||
depth.set(depth.get() + 1);
|
||||
true
|
||||
}
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let _guard = Guard;
|
||||
let color_valid = |color: [f32; 3]| {
|
||||
color
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && (0.0..=1.0).contains(value))
|
||||
};
|
||||
self.camera.valid()
|
||||
&& self.scene.ambient.is_finite()
|
||||
&& (0.0..=1.0).contains(&self.scene.ambient)
|
||||
&& color_valid(self.scene.background)
|
||||
&& self.scene.meshes.len() <= 4096
|
||||
&& self.scene.lights.len() <= 32
|
||||
&& self
|
||||
.scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|mesh| mesh.geometry.vertices.len())
|
||||
.sum::<usize>()
|
||||
<= 262_144
|
||||
&& self
|
||||
.scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|mesh| mesh.geometry.triangles.len())
|
||||
.sum::<usize>()
|
||||
<= MAX_TRIANGLES
|
||||
&& self.scene.meshes.iter().all(|mesh| {
|
||||
mesh.geometry.valid()
|
||||
&& mesh.world.0.iter().all(|v| v.is_finite())
|
||||
&& color_valid(mesh.color)
|
||||
&& mesh
|
||||
.geometry
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|point| mesh.world.point(*point).valid())
|
||||
&& mesh.panel.as_ref().is_none_or(|panel| {
|
||||
panel.width > 0
|
||||
&& panel.height > 0
|
||||
&& panel.width <= 2048
|
||||
&& panel.height <= 2048
|
||||
&& color_valid(panel.background)
|
||||
&& panel.surface.as_ref().is_none_or(|surface| {
|
||||
surface.commands.len() <= 10001
|
||||
&& surface.commands.len() == surface.views.len()
|
||||
&& surface.commands.len() == surface.clips.len()
|
||||
&& surface.views.iter().flatten().all(|view| view.valid())
|
||||
&& surface.clips.iter().flatten().all(|clip| {
|
||||
clip.iter().all(|value| value.is_finite())
|
||||
&& clip[2] >= 0.
|
||||
&& clip[3] >= 0.
|
||||
})
|
||||
&& surface
|
||||
.commands
|
||||
.iter()
|
||||
.all(|command| command.validate().is_ok())
|
||||
})
|
||||
})
|
||||
})
|
||||
&& self.scene.lights.iter().all(|light| {
|
||||
color_valid(light.color)
|
||||
&& light.direction.valid()
|
||||
&& (light.direction.length() - 1.).abs() < 0.0001
|
||||
&& light.intensity.is_finite()
|
||||
&& (0.0..=32.0).contains(&light.intensity)
|
||||
})
|
||||
}
|
||||
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
|
||||
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
|
||||
if !self.camera.valid()
|
||||
|| !(0.0..=1.0).contains(&u)
|
||||
|| !(0.0..=1.0).contains(&v)
|
||||
|| !aspect.is_finite()
|
||||
|| aspect <= 0.
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let ray = self.camera.ray(u, v, aspect)?;
|
||||
let basis = self.camera.basis()?;
|
||||
let mut hit: Option<Hit> = None;
|
||||
let temporary;
|
||||
let index = if self.scene.acceleration.matches(self.scene.meshes.len()) {
|
||||
&*self.scene.acceleration
|
||||
} else {
|
||||
temporary = spatial::Index::build(&self.scene.meshes);
|
||||
&temporary
|
||||
};
|
||||
for (entry, instance) in index.ray(ray) {
|
||||
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
|
||||
break;
|
||||
}
|
||||
let mesh = &self.scene.meshes[instance];
|
||||
for (triangle, indices) in mesh.geometry.triangles.iter().enumerate() {
|
||||
let [a, b, c] =
|
||||
indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
|
||||
let Some((distance, barycentric)) = ray.triangle(a, b, c) else {
|
||||
continue;
|
||||
};
|
||||
let point = ray.origin + ray.direction * distance;
|
||||
let depth = self.camera.view(basis, point).z;
|
||||
if depth < self.camera.near
|
||||
|| depth > self.camera.far
|
||||
|| hit.as_ref().is_some_and(|hit| {
|
||||
hit.distance < distance
|
||||
|| hit.distance == distance && hit.instance <= instance
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(normal) = (b - a).cross(c - a).normalized() else {
|
||||
continue;
|
||||
};
|
||||
hit = Some(Hit {
|
||||
instance,
|
||||
identity: mesh.identity.clone(),
|
||||
point,
|
||||
normal,
|
||||
distance,
|
||||
triangle,
|
||||
barycentric,
|
||||
});
|
||||
}
|
||||
}
|
||||
hit
|
||||
}
|
||||
}
|
||||
|
||||
/// 把有限颜色通道转换成最终像素使用的字节。
|
||||
fn channel(value: f32) -> u32 {
|
||||
(value.clamp(0., 1.) * 255.).round() as u32
|
||||
}
|
||||
/// 将三个颜色通道打包为软件像素缓冲使用的 RGB 值。
|
||||
pub fn rgb(color: [f32; 3]) -> u32 {
|
||||
channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2])
|
||||
}
|
||||
|
||||
/// 某个不可变视图在特定分辨率和宽高比下的光栅结果。
|
||||
struct CachedSurface {
|
||||
view: Arc<View>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
aspect: f32,
|
||||
pixels: Arc<[u32]>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
/// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。
|
||||
struct SurfaceCache {
|
||||
entries: std::collections::VecDeque<CachedSurface>,
|
||||
pixels: usize,
|
||||
}
|
||||
|
||||
/// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。
|
||||
pub fn surface(
|
||||
view: &Arc<View>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
aspect: f32,
|
||||
) -> Result<Arc<[u32]>, &'static str> {
|
||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<SurfaceCache>> = std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
{
|
||||
let cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
|
||||
if let Some(entry) = cache.entries.iter().find(|entry| {
|
||||
Arc::ptr_eq(&entry.view, view)
|
||||
&& entry.width == width
|
||||
&& entry.height == height
|
||||
&& entry.aspect == aspect
|
||||
}) {
|
||||
return Ok(entry.pixels.clone());
|
||||
}
|
||||
}
|
||||
// Do not hold a cache lock across rendering: world UI can contain another
|
||||
// 3D viewport and recurse through the compositor.
|
||||
let pixels: Arc<[u32]> = render_aspect(view, width, height, aspect)?.into();
|
||||
let mut cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
|
||||
while cache.entries.len() >= 8 || cache.pixels + pixels.len() > 8_388_608 {
|
||||
let Some(entry) = cache.entries.pop_front() else {
|
||||
break;
|
||||
};
|
||||
cache.pixels -= entry.pixels.len();
|
||||
}
|
||||
cache.pixels += pixels.len();
|
||||
cache.entries.push_back(CachedSurface {
|
||||
view: view.clone(),
|
||||
width,
|
||||
height,
|
||||
aspect,
|
||||
pixels: pixels.clone(),
|
||||
});
|
||||
Ok(pixels)
|
||||
}
|
||||
|
||||
/// Homogeneous frustum planes expressed in view space. Clipping before division
|
||||
/// handles triangles crossing the near plane without huge screen coordinates.
|
||||
#[derive(Clone, Copy)]
|
||||
/// 裁剪过程中的相机空间顶点,位置与 UV 必须共同插值。
|
||||
struct Vertex {
|
||||
position: Vec3,
|
||||
uv: [f32; 2],
|
||||
}
|
||||
impl Vertex {
|
||||
/// 按裁剪交点比例同时插值位置和 UV,保持新顶点的几何与纹理坐标一致。
|
||||
fn lerp(self, other: Self, t: f32) -> Self {
|
||||
Self {
|
||||
position: self.position + (other.position - self.position) * t,
|
||||
uv: [
|
||||
self.uv[0] + (other.uv[0] - self.uv[0]) * t,
|
||||
self.uv[1] + (other.uv[1] - self.uv[1]) * t,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 生成相机空间六个视锥平面,供三角形裁剪与 BVH 可见性查询共用。
|
||||
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
|
||||
let (x, y, slope) = match camera.projection {
|
||||
Projection::Perspective { fov } => {
|
||||
let y = (fov.to_radians() / 2.).tan();
|
||||
(y * aspect, y, 1.)
|
||||
}
|
||||
Projection::Orthographic { height } => (height * aspect / 2., height / 2., 0.),
|
||||
};
|
||||
[
|
||||
[0., 0., 1., -camera.near],
|
||||
[0., 0., -1., camera.far],
|
||||
[1., 0., x * slope, x * (1. - slope)],
|
||||
[-1., 0., x * slope, x * (1. - slope)],
|
||||
[0., 1., y * slope, y * (1. - slope)],
|
||||
[0., -1., y * slope, y * (1. - slope)],
|
||||
]
|
||||
}
|
||||
/// 在透视除法前逐平面裁剪多边形,并同步插值 UV,避免跨近面顶点投影到极大坐标。
|
||||
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
|
||||
for [a, b, c, d] in frustum_planes(camera, aspect) {
|
||||
if polygon.is_empty() {
|
||||
break;
|
||||
}
|
||||
let distance = |vertex: Vertex| {
|
||||
let p = vertex.position;
|
||||
a * p.x + b * p.y + c * p.z + d
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut previous = *polygon.last().unwrap();
|
||||
let mut before = distance(previous);
|
||||
for current in polygon {
|
||||
let after = distance(current);
|
||||
if (before >= 0.) != (after >= 0.) {
|
||||
output.push(previous.lerp(current, before / (before - after)));
|
||||
}
|
||||
if after >= 0. {
|
||||
output.push(current);
|
||||
}
|
||||
previous = current;
|
||||
before = after;
|
||||
}
|
||||
polygon = output;
|
||||
}
|
||||
polygon
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。
|
||||
pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static str> {
|
||||
render_aspect(view, width, height, width as f32 / height as f32)
|
||||
}
|
||||
/// 先校验并预留颜色/深度缓冲预算,再剔除、裁剪、投影和光栅化三角形;超预算返回错误。
|
||||
fn render_aspect(
|
||||
view: &View,
|
||||
width: u32,
|
||||
height: u32,
|
||||
aspect: f32,
|
||||
) -> Result<Vec<u32>, &'static str> {
|
||||
let _scope = crate::software_renderer::RenderScope::enter()?;
|
||||
let area = width as usize * height as usize;
|
||||
// Offscreen targets have a hard per-surface resource bound.
|
||||
if area > 4_194_304
|
||||
|| width == 0
|
||||
|| height == 0
|
||||
|| !aspect.is_finite()
|
||||
|| aspect <= 0.
|
||||
|| !view.valid()
|
||||
{
|
||||
return Err("invalid 3D surface or resource budget exceeded");
|
||||
}
|
||||
crate::software_renderer::reserve_pixels(area.saturating_mul(2))?;
|
||||
let mut pixels = vec![rgb(view.scene.background); area];
|
||||
let mut depths = vec![f32::INFINITY; area];
|
||||
let camera = view.camera;
|
||||
let basis = camera.basis().unwrap();
|
||||
let perspective = matches!(camera.projection, Projection::Perspective { .. });
|
||||
let project = |point: Vec3| {
|
||||
let half = match camera.projection {
|
||||
Projection::Perspective { fov } => point.z * (fov.to_radians() / 2.).tan(),
|
||||
Projection::Orthographic { height } => height / 2.,
|
||||
};
|
||||
Vec3::new(
|
||||
(point.x / (half * aspect) + 1.) * width as f32 / 2.,
|
||||
(1. - point.y / half) * height as f32 / 2.,
|
||||
point.z,
|
||||
)
|
||||
};
|
||||
let mut work = 0usize;
|
||||
let temporary;
|
||||
let index = if view.scene.acceleration.matches(view.scene.meshes.len()) {
|
||||
&*view.scene.acceleration
|
||||
} else {
|
||||
temporary = spatial::Index::build(&view.scene.meshes);
|
||||
&temporary
|
||||
};
|
||||
// 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。
|
||||
for instance in index.visible(camera, aspect) {
|
||||
let mesh = &view.scene.meshes[instance];
|
||||
let texture = mesh.panel.as_ref().map(panel::texture).transpose()?;
|
||||
for indices in &mesh.geometry.triangles {
|
||||
let [a, b, c] = indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
|
||||
let Some(normal) = (b - a).cross(c - a).normalized() else {
|
||||
continue;
|
||||
};
|
||||
let mut light = [view.scene.ambient; 3];
|
||||
for source in &view.scene.lights {
|
||||
let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity;
|
||||
for (channel, color) in light.iter_mut().zip(source.color) {
|
||||
*channel += intensity * color;
|
||||
}
|
||||
}
|
||||
let color = rgb(std::array::from_fn(|i| mesh.color[i] * light[i]));
|
||||
// 在透视除法之前裁剪,并插入具有正确 UV 的交点;不能先投影再裁剪近面。
|
||||
let polygon = clip(
|
||||
indices
|
||||
.map(|index| Vertex {
|
||||
position: camera.view(
|
||||
basis,
|
||||
mesh.world.point(mesh.geometry.vertices[index as usize]),
|
||||
),
|
||||
uv: mesh
|
||||
.geometry
|
||||
.uv
|
||||
.get(index as usize)
|
||||
.copied()
|
||||
.unwrap_or([0., 0.]),
|
||||
})
|
||||
.to_vec(),
|
||||
camera,
|
||||
aspect,
|
||||
);
|
||||
if polygon.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
// 裁剪后可能多于三个顶点,用扇形重新三角化,保持同一平面的纹理参数。
|
||||
for index in 1..polygon.len() - 1 {
|
||||
let vertices = [polygon[0], polygon[index], polygon[index + 1]];
|
||||
let [a, b, c] = vertices.map(|vertex| project(vertex.position));
|
||||
let min_x = a.x.min(b.x).min(c.x).floor().max(0.) as u32;
|
||||
let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32) as u32;
|
||||
let min_y = a.y.min(b.y).min(c.y).floor().max(0.) as u32;
|
||||
let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32) as u32;
|
||||
work += max_x.saturating_sub(min_x) as usize * max_y.saturating_sub(min_y) as usize;
|
||||
if work > 32_000_000 {
|
||||
return Err("3D raster work budget exceeded");
|
||||
}
|
||||
let edge = |a: Vec3, b: Vec3, x: f32, y: f32| {
|
||||
(x - a.x) * (b.y - a.y) - (y - a.y) * (b.x - a.x)
|
||||
};
|
||||
let area = edge(a, b, c.x, c.y);
|
||||
if area.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
let px = x as f32 + 0.5;
|
||||
let py = y as f32 + 0.5;
|
||||
let weights = [
|
||||
edge(b, c, px, py) / area,
|
||||
edge(c, a, px, py) / area,
|
||||
edge(a, b, px, py) / area,
|
||||
];
|
||||
if weights.iter().any(|weight| *weight < -1e-5) {
|
||||
continue;
|
||||
}
|
||||
// 透视投影下使用倒深度插值,正交投影下可直接线性插值。
|
||||
let depth = if perspective {
|
||||
1. / (weights[0] / a.z + weights[1] / b.z + weights[2] / c.z)
|
||||
} else {
|
||||
weights[0] * a.z + weights[1] * b.z + weights[2] * c.z
|
||||
};
|
||||
let index = y as usize * width as usize + x as usize;
|
||||
// 只让最近表面写像素;等深度保留先绘制实例,与拾取的排序约定一致。
|
||||
if depth < depths[index] {
|
||||
depths[index] = depth;
|
||||
pixels[index] =
|
||||
if let (Some(texture), Some(panel)) = (&texture, &mesh.panel) {
|
||||
// UV 也要除以深度再插值,否则倾斜面板会出现纹理拉伸失真。
|
||||
let interpolated = |axis| {
|
||||
if perspective {
|
||||
depth
|
||||
* (weights[0] * vertices[0].uv[axis] / a.z
|
||||
+ weights[1] * vertices[1].uv[axis] / b.z
|
||||
+ weights[2] * vertices[2].uv[axis] / c.z)
|
||||
} else {
|
||||
weights[0] * vertices[0].uv[axis]
|
||||
+ weights[1] * vertices[1].uv[axis]
|
||||
+ weights[2] * vertices[2].uv[axis]
|
||||
}
|
||||
};
|
||||
let u = (interpolated(0).clamp(0., 1.) * panel.width as f32)
|
||||
.min((panel.width - 1) as f32)
|
||||
as usize;
|
||||
let v = (interpolated(1).clamp(0., 1.) * panel.height as f32)
|
||||
.min((panel.height - 1) as f32)
|
||||
as usize;
|
||||
texture[v * panel.width as usize + u]
|
||||
} else {
|
||||
color
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(pixels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
|
||||
let mut view = view(Geometry::cuboid(1., 1., 1.));
|
||||
let scene = Arc::make_mut(&mut view.scene);
|
||||
let mesh = scene.meshes[0].clone();
|
||||
for index in 1..100 {
|
||||
let mut far = mesh.clone();
|
||||
far.identity = format!("far{index}");
|
||||
far.world = Matrix::trs(
|
||||
Vec3::new(index as f32 * 100., 0., 0.),
|
||||
Vec3::default(),
|
||||
Vec3::new(1., 1., 1.),
|
||||
);
|
||||
scene.meshes.push(far);
|
||||
}
|
||||
scene.acceleration = Arc::new(spatial::Index::build(&scene.meshes));
|
||||
assert_eq!(scene.acceleration.visible(view.camera, 1.), vec![0]);
|
||||
let ray = view.camera.ray(0.5, 0.5, 1.).unwrap();
|
||||
assert_eq!(scene.acceleration.ray(ray).len(), 1);
|
||||
let indexed = view.pick(0.5, 0.5, 1.).unwrap();
|
||||
let mut plain = view.clone();
|
||||
Arc::make_mut(&mut plain.scene).acceleration = Arc::default();
|
||||
let fallback = plain.pick(0.5, 0.5, 1.).unwrap();
|
||||
assert_eq!(indexed.identity, fallback.identity);
|
||||
assert_eq!(indexed.distance, fallback.distance);
|
||||
}
|
||||
fn view(geometry: Geometry) -> View {
|
||||
View {
|
||||
camera: Camera::default(),
|
||||
scene: Arc::new(Scene {
|
||||
meshes: vec![Mesh {
|
||||
panel: None,
|
||||
identity: "mesh".into(),
|
||||
geometry: Arc::new(geometry),
|
||||
world: Matrix::default(),
|
||||
color: [1., 0., 0.],
|
||||
}],
|
||||
lights: vec![],
|
||||
ambient: 1.,
|
||||
background: [1., 1., 1.],
|
||||
acceleration: Arc::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() {
|
||||
let mut view = view(Geometry {
|
||||
uv: vec![],
|
||||
vertices: vec![
|
||||
Vec3::new(-1., -1., 0.5),
|
||||
Vec3::new(1., -1., 2.),
|
||||
Vec3::new(0., 1., 2.),
|
||||
],
|
||||
triangles: vec![[0, 1, 2]],
|
||||
});
|
||||
view.camera.position = Vec3::default();
|
||||
view.camera.target = Vec3::new(0., 0., 1.);
|
||||
view.camera.near = 1.;
|
||||
view.camera.far = 10.;
|
||||
view.camera.projection = Projection::Perspective { fov: 90. };
|
||||
let pixels = render(&view, 64, 64).unwrap();
|
||||
assert!(pixels.contains(&0xFF0000));
|
||||
let hit = view.pick(0.5, 0.5, 1.).unwrap();
|
||||
assert!(hit.point.z >= view.camera.near && hit.point.z <= view.camera.far);
|
||||
view.camera.far = 1.1;
|
||||
assert!(view.pick(0.5, 0.5, 1.).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn group_transform_order_and_orthographic_rays_are_consistent() {
|
||||
let local = Matrix::trs(
|
||||
Vec3::new(1., 0., 0.),
|
||||
Vec3::new(0., 90., 0.),
|
||||
Vec3::new(1., 1., 1.),
|
||||
);
|
||||
let parent = Matrix::trs(
|
||||
Vec3::new(10., 0., 0.),
|
||||
Vec3::default(),
|
||||
Vec3::new(1., 1., 1.),
|
||||
);
|
||||
let transformed = parent.compose(local).point(Vec3::new(0., 0., 1.));
|
||||
assert!((transformed.x - 12.).abs() < 1e-5 && transformed.z.abs() < 1e-5);
|
||||
let camera = Camera {
|
||||
projection: Projection::Orthographic { height: 4. },
|
||||
..Camera::default()
|
||||
};
|
||||
let left = camera.ray(0.25, 0.5, 1.).unwrap();
|
||||
let right = camera.ray(0.75, 0.5, 1.).unwrap();
|
||||
assert_eq!(left.direction, right.direction);
|
||||
assert_eq!(right.origin.x - left.origin.x, 2.);
|
||||
}
|
||||
#[test]
|
||||
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
|
||||
let view = Arc::new(view(Geometry::cuboid(1., 1., 1.)));
|
||||
let first = surface(&view, 64, 64, 1.).unwrap();
|
||||
let second = surface(&view, 64, 64, 1.).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
let wide = surface(&view, 64, 64, 2.).unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &wide));
|
||||
assert_ne!(first, wide);
|
||||
assert!(render(&view, 4000, 4000).is_err());
|
||||
let mut invalid = (*view).clone();
|
||||
invalid.camera.target = invalid.camera.position;
|
||||
assert!(!invalid.valid());
|
||||
assert!(render(&invalid, 64, 64).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。
|
||||
//! 矩阵组合顺序决定局部变换如何进入父坐标系;接近退化的逆矩阵、法线和射线使用 Option 拒绝无效计算。
|
||||
//!
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::{Add, Mul, Sub};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
/// 三个有限浮点分量的数学向量,不是托管 Vec3 对象。
|
||||
pub struct Vec3 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
}
|
||||
impl Vec3 {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub const fn new(x: f32, y: f32, z: f32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
/// 计算向量点积,用于方向投影、光照和相交判断。
|
||||
pub fn dot(self, b: Self) -> f32 {
|
||||
self.x * b.x + self.y * b.y + self.z * b.z
|
||||
}
|
||||
/// 计算右手叉积,用于摄像机基向量和三角形法线。
|
||||
pub fn cross(self, b: Self) -> Self {
|
||||
Self::new(
|
||||
self.y * b.z - self.z * b.y,
|
||||
self.z * b.x - self.x * b.z,
|
||||
self.x * b.y - self.y * b.x,
|
||||
)
|
||||
}
|
||||
/// 计算向量长度;归一化前还需检查是否接近零。
|
||||
pub fn length(self) -> f32 {
|
||||
self.dot(self).sqrt()
|
||||
}
|
||||
/// 仅在长度有效且不退化时返回单位向量,避免除零产生非有限结果。
|
||||
pub fn normalized(self) -> Option<Self> {
|
||||
let length = self.length();
|
||||
(length.is_finite() && length > 1e-6).then(|| self * (1.0 / length))
|
||||
}
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(self) -> bool {
|
||||
[self.x, self.y, self.z]
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
}
|
||||
}
|
||||
impl Add for Vec3 {
|
||||
type Output = Self;
|
||||
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
|
||||
fn add(self, b: Self) -> Self {
|
||||
Self::new(self.x + b.x, self.y + b.y, self.z + b.z)
|
||||
}
|
||||
}
|
||||
impl Sub for Vec3 {
|
||||
type Output = Self;
|
||||
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
|
||||
fn sub(self, b: Self) -> Self {
|
||||
Self::new(self.x - b.x, self.y - b.y, self.z - b.z)
|
||||
}
|
||||
}
|
||||
impl Mul<f32> for Vec3 {
|
||||
type Output = Self;
|
||||
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
|
||||
fn mul(self, b: f32) -> Self {
|
||||
Self::new(self.x * b, self.y * b, self.z * b)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
/// 仿射 4×4 变换矩阵,支持局部到世界的组合与求逆。
|
||||
pub struct Matrix(pub [f32; 16]);
|
||||
impl Default for Matrix {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self([
|
||||
1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
|
||||
])
|
||||
}
|
||||
}
|
||||
impl Matrix {
|
||||
/// 求仿射世界矩阵的逆;退化或不可逆时返回空值。
|
||||
pub fn inverse(self) -> Option<Self> {
|
||||
let m = self.0;
|
||||
let determinant = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8])
|
||||
+ m[2] * (m[4] * m[9] - m[5] * m[8]);
|
||||
if !determinant.is_finite() || determinant.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let d = 1. / determinant;
|
||||
let mut out = Self([
|
||||
(m[5] * m[10] - m[6] * m[9]) * d,
|
||||
(m[2] * m[9] - m[1] * m[10]) * d,
|
||||
(m[1] * m[6] - m[2] * m[5]) * d,
|
||||
0.,
|
||||
(m[6] * m[8] - m[4] * m[10]) * d,
|
||||
(m[0] * m[10] - m[2] * m[8]) * d,
|
||||
(m[2] * m[4] - m[0] * m[6]) * d,
|
||||
0.,
|
||||
(m[4] * m[9] - m[5] * m[8]) * d,
|
||||
(m[1] * m[8] - m[0] * m[9]) * d,
|
||||
(m[0] * m[5] - m[1] * m[4]) * d,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
1.,
|
||||
]);
|
||||
let translation = out.point(Vec3::new(m[3], m[7], m[11])) * -1.;
|
||||
out.0[3] = translation.x;
|
||||
out.0[7] = translation.y;
|
||||
out.0[11] = translation.z;
|
||||
Some(out)
|
||||
}
|
||||
/// 组合父子仿射变换,调用顺序决定局部坐标最终落在哪个世界位置。
|
||||
pub fn compose(self, local: Self) -> Self {
|
||||
let mut out = [0.; 16];
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
out[row * 4 + col] = (0..4)
|
||||
.map(|k| self.0[row * 4 + k] * local.0[k * 4 + col])
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
Self(out)
|
||||
}
|
||||
/// 对一个位置应用矩阵,包括平移项;不要用它代替纯方向变换。
|
||||
pub fn point(self, p: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
self.0[0] * p.x + self.0[1] * p.y + self.0[2] * p.z + self.0[3],
|
||||
self.0[4] * p.x + self.0[5] * p.y + self.0[6] * p.z + self.0[7],
|
||||
self.0[8] * p.x + self.0[9] * p.y + self.0[10] * p.z + self.0[11],
|
||||
)
|
||||
}
|
||||
/// 由平移、角度旋转和缩放构造局部仿射矩阵。
|
||||
pub fn trs(position: Vec3, rotation: Vec3, scale: Vec3) -> Self {
|
||||
let (sx, cx) = rotation.x.to_radians().sin_cos();
|
||||
let (sy, cy) = rotation.y.to_radians().sin_cos();
|
||||
let (sz, cz) = rotation.z.to_radians().sin_cos();
|
||||
// Local X, then Y, then Z rotation; scale first, translation last.
|
||||
Self([
|
||||
cz * cy * scale.x,
|
||||
(cz * sy * sx - sz * cx) * scale.y,
|
||||
(cz * sy * cx + sz * sx) * scale.z,
|
||||
position.x,
|
||||
sz * cy * scale.x,
|
||||
(sz * sy * sx + cz * cx) * scale.y,
|
||||
(sz * sy * cx - cz * sx) * scale.z,
|
||||
position.y,
|
||||
-sy * scale.x,
|
||||
cy * sx * scale.y,
|
||||
cy * cx * scale.z,
|
||||
position.z,
|
||||
0.,
|
||||
0.,
|
||||
0.,
|
||||
1.,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
/// 投影策略:透视视场角或正交可见高度。
|
||||
pub enum Projection {
|
||||
Perspective { fov: f32 },
|
||||
Orthographic { height: f32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
/// 原生摄像机描述,包括观察基、近远面和投影参数;宽高比由视口提供。
|
||||
pub struct Camera {
|
||||
pub position: Vec3,
|
||||
pub target: Vec3,
|
||||
pub up: Vec3,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
pub projection: Projection,
|
||||
}
|
||||
impl Default for Camera {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
position: Vec3::new(0., 0., 5.),
|
||||
target: Vec3::default(),
|
||||
up: Vec3::new(0., 1., 0.),
|
||||
near: 0.1,
|
||||
far: 1000.,
|
||||
projection: Projection::Perspective { fov: 50. },
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 摄像机的右、上、前单位基向量。
|
||||
pub struct Basis {
|
||||
pub right: Vec3,
|
||||
pub up: Vec3,
|
||||
pub forward: Vec3,
|
||||
}
|
||||
impl Camera {
|
||||
/// 从观察位置、目标和上方向建立正交相机基;方向退化时拒绝。
|
||||
pub fn basis(self) -> Option<Basis> {
|
||||
let forward = (self.target - self.position).normalized()?;
|
||||
let right = forward.cross(self.up).normalized()?;
|
||||
let up = right.cross(forward).normalized()?;
|
||||
Some(Basis { right, up, forward })
|
||||
}
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(self) -> bool {
|
||||
self.position.valid()
|
||||
&& self.target.valid()
|
||||
&& self.up.valid()
|
||||
&& self.basis().is_some()
|
||||
&& self.near.is_finite()
|
||||
&& self.far.is_finite()
|
||||
&& self.near > 0.
|
||||
&& self.far > self.near
|
||||
&& self.far <= 1_000_000.
|
||||
&& match self.projection {
|
||||
Projection::Perspective { fov } => fov.is_finite() && (1.0..=175.0).contains(&fov),
|
||||
Projection::Orthographic { height } => {
|
||||
height.is_finite() && height > 0. && height <= 1_000_000.
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 把世界点投影到相机的右/上/前基向量坐标。
|
||||
pub fn view(self, basis: Basis, p: Vec3) -> Vec3 {
|
||||
let p = p - self.position;
|
||||
Vec3::new(p.dot(basis.right), p.dot(basis.up), p.dot(basis.forward))
|
||||
}
|
||||
/// 从视口归一化坐标生成透视或正交射线;宽高比由具体视口提供。
|
||||
pub fn ray(self, u: f32, v: f32, aspect: f32) -> Option<Ray> {
|
||||
let basis = self.basis()?;
|
||||
let (x, y) = (u * 2. - 1., 1. - v * 2.);
|
||||
match self.projection {
|
||||
Projection::Perspective { fov } => {
|
||||
let half = (fov.to_radians() / 2.).tan();
|
||||
Some(Ray {
|
||||
origin: self.position,
|
||||
direction: (basis.forward
|
||||
+ basis.right * (x * half * aspect)
|
||||
+ basis.up * (y * half))
|
||||
.normalized()?,
|
||||
})
|
||||
}
|
||||
Projection::Orthographic { height } => Some(Ray {
|
||||
origin: self.position
|
||||
+ basis.right * (x * height * aspect / 2.)
|
||||
+ basis.up * (y * height / 2.),
|
||||
direction: basis.forward,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 世界坐标中的射线原点与方向,供网格和面板求交。
|
||||
pub struct Ray {
|
||||
pub origin: Vec3,
|
||||
pub direction: Vec3,
|
||||
}
|
||||
impl Ray {
|
||||
/// 求射线与三角形相交距离和重心坐标,拒绝退化三角形及射线起点后方的交点。
|
||||
pub fn triangle(self, a: Vec3, b: Vec3, c: Vec3) -> Option<(f32, [f32; 3])> {
|
||||
let ab = b - a;
|
||||
let ac = c - a;
|
||||
let p = self.direction.cross(ac);
|
||||
let determinant = ab.dot(p);
|
||||
if determinant.abs() < 1e-7 {
|
||||
return None;
|
||||
}
|
||||
let inverse = 1.0 / determinant;
|
||||
let t = self.origin - a;
|
||||
let u = t.dot(p) * inverse;
|
||||
if !(0.0..=1.0).contains(&u) {
|
||||
return None;
|
||||
}
|
||||
let q = t.cross(ab);
|
||||
let v = self.direction.dot(q) * inverse;
|
||||
if v < 0. || u + v > 1. {
|
||||
return None;
|
||||
}
|
||||
let distance = ac.dot(q) * inverse;
|
||||
(distance >= 0.).then_some((distance, [1. - u - v, u, v]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 连接 3D 平面与普通 UI 的坐标和绘制桥梁。
|
||||
//! Mapping 沿嵌套面板逐层把页面指针变成纹理坐标;可见命中还须检查最近射线交点,防止点穿遮挡物。
|
||||
//! compose 把各 Mount 的绘制命令按所属面板分组并递归装配;texture 再调用普通软件渲染器绘制面板内容。
|
||||
//!
|
||||
//! Perspective UI mapping. The same world plane supplies texture coordinates
|
||||
//! during rendering and inverse pointer coordinates during event dispatch.
|
||||
use super::{
|
||||
View,
|
||||
math::{Projection, Vec3},
|
||||
};
|
||||
use crate::{
|
||||
geometry::{Point, Rect},
|
||||
viewport::Transform,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
/// 一次面板出现的正逆投影信息,可以链接父面板形成嵌套映射。
|
||||
pub struct Mapping {
|
||||
pub owner: u64,
|
||||
pub key: u64,
|
||||
pub view: Arc<View>,
|
||||
pub rect: [f32; 4],
|
||||
pub outer: Transform,
|
||||
pub clip: Option<[f32; 4]>,
|
||||
pub parent: Option<Arc<Mapping>>,
|
||||
}
|
||||
impl Mapping {
|
||||
/// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。
|
||||
pub fn texture_point(&self, point: Point, clipped: bool) -> Option<Point> {
|
||||
let point = if let Some(parent) = &self.parent {
|
||||
parent.texture_point(point, clipped)?
|
||||
} else {
|
||||
point
|
||||
};
|
||||
if clipped
|
||||
&& self
|
||||
.clip
|
||||
.is_some_and(|clip| !Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let point = self.outer.inverse(point);
|
||||
let [x, y, width, height] = self.rect;
|
||||
if width <= 0. || height <= 0. {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = ((point.x - x) / width, (point.y - y) / height);
|
||||
let mesh = self.view.scene.meshes.iter().find(|mesh| {
|
||||
mesh.panel
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.key == self.key)
|
||||
})?;
|
||||
let panel = mesh.panel.as_ref()?;
|
||||
if clipped {
|
||||
// 指针落在平面投影内还不够:最近三角形必须属于本面板,不能点穿前方网格。
|
||||
let hit = self.view.pick(u, v, width / height)?;
|
||||
if self.view.scene.meshes[hit.instance]
|
||||
.panel
|
||||
.as_ref()
|
||||
.is_none_or(|panel| panel.key != self.key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// 捕获拖动可以不要求指针仍位于面板内,但仍使用原面板平面的连续逆映射。
|
||||
let ray = self.view.camera.ray(u, v, width / height)?;
|
||||
let center = mesh.world.point(Vec3::default());
|
||||
let normal = (mesh.world.point(Vec3::new(1., 0., 0.)) - center)
|
||||
.cross(mesh.world.point(Vec3::new(0., 1., 0.)) - center);
|
||||
let denominator = normal.dot(ray.direction);
|
||||
if denominator.abs() < 1e-7 {
|
||||
return None;
|
||||
}
|
||||
let distance = (center - ray.origin).dot(normal) / denominator;
|
||||
if !distance.is_finite() || distance <= 0. {
|
||||
return None;
|
||||
}
|
||||
let local = mesh
|
||||
.world
|
||||
.inverse()?
|
||||
.point(ray.origin + ray.direction * distance);
|
||||
let world_width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
|
||||
let world_height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
|
||||
let point = Point::new(
|
||||
(local.x / world_width + 0.5) * panel.width as f32,
|
||||
(0.5 - local.y / world_height) * panel.height as f32,
|
||||
);
|
||||
(point.x.is_finite() && point.y.is_finite()).then_some(point)
|
||||
}
|
||||
|
||||
/// 把当前面板的 UI 像素正向投影到外层页面空间,供坐标查询和交互测试使用。
|
||||
pub fn project_point(&self, point: Point) -> Option<Point> {
|
||||
let mesh = self.view.scene.meshes.iter().find(|mesh| {
|
||||
mesh.panel
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.key == self.key)
|
||||
})?;
|
||||
let panel = mesh.panel.as_ref()?;
|
||||
let width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
|
||||
let height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
|
||||
let world = mesh.world.point(Vec3::new(
|
||||
(point.x / panel.width as f32 - 0.5) * width,
|
||||
(0.5 - point.y / panel.height as f32) * height,
|
||||
0.,
|
||||
));
|
||||
let camera = self.view.camera;
|
||||
let local = camera.view(camera.basis()?, world);
|
||||
if local.z <= camera.near || local.z >= camera.far {
|
||||
return None;
|
||||
}
|
||||
let [x, y, w, h] = self.rect;
|
||||
let half = match camera.projection {
|
||||
Projection::Perspective { fov } => local.z * (fov.to_radians() / 2.).tan(),
|
||||
Projection::Orthographic { height } => height / 2.,
|
||||
};
|
||||
let point = self.outer.point(Point::new(
|
||||
x + (local.x / (half * w / h) + 1.) * w / 2.,
|
||||
y + (1. - local.y / half) * h / 2.,
|
||||
));
|
||||
if let Some(parent) = &self.parent {
|
||||
parent.project_point(point)
|
||||
} else {
|
||||
Some(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// 归属明确的一条内部 UI 绘制命令,装配时还保留裁剪、变换和 owner。
|
||||
struct Entry {
|
||||
command: crate::document::RenderCommand,
|
||||
clip: Option<[f32; 4]>,
|
||||
view: Option<Transform>,
|
||||
owner: u64,
|
||||
}
|
||||
|
||||
/// Collect a panel's ordinary paint subtree into its texture. Nested 3D
|
||||
/// commands are assembled recursively before their containing texture.
|
||||
/// 按面板所属视口和稳定 key 分组绘制命令,递归将内部 UI 装配进 3D 面板表面。
|
||||
pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
|
||||
use crate::document::RenderCommand;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
|
||||
let mut groups = HashMap::<(u64, u64), Vec<Entry>>::new();
|
||||
let mut roots = Vec::new();
|
||||
// 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。
|
||||
for (index, command) in frame.commands.iter().enumerate() {
|
||||
let owner = frame.command_mounts[index];
|
||||
let mount = mounts[&owner];
|
||||
let entry = Entry {
|
||||
command: command.clone(),
|
||||
clip: frame.command_clips[index],
|
||||
view: frame.command_views[index],
|
||||
owner,
|
||||
};
|
||||
if let Some(panel) = &mount.panel {
|
||||
groups
|
||||
.entry((panel.owner, panel.key))
|
||||
.or_default()
|
||||
.push(entry);
|
||||
} else {
|
||||
roots.push(entry);
|
||||
}
|
||||
}
|
||||
if let Some(mount) = frame.focus.and_then(|id| mounts.get(&id))
|
||||
&& let Some(panel) = &mount.panel
|
||||
{
|
||||
groups
|
||||
.entry((panel.owner, panel.key))
|
||||
.or_default()
|
||||
.push(Entry {
|
||||
command: RenderCommand::StrokeRoundedRect {
|
||||
x: mount.x,
|
||||
y: mount.y,
|
||||
width: mount.width,
|
||||
height: mount.height,
|
||||
radius: 7.,
|
||||
stroke_width: 2.,
|
||||
color: "#4F6FDB".into(),
|
||||
},
|
||||
clip: mount.clip,
|
||||
view: mount.view,
|
||||
owner: mount.id,
|
||||
});
|
||||
}
|
||||
/// 递归装配一个绘制条目包含的世界 UI,检查嵌套环与深度,再生成独立不可变视图。
|
||||
fn assemble(
|
||||
entry: Entry,
|
||||
groups: &HashMap<(u64, u64), Vec<Entry>>,
|
||||
path: &mut HashSet<u64>,
|
||||
depth: usize,
|
||||
) -> anyhow::Result<Entry> {
|
||||
let mut entry = entry;
|
||||
if let RenderCommand::Scene3D { view, .. } = &mut entry.command
|
||||
&& view.scene.meshes.iter().any(|mesh| mesh.panel.is_some())
|
||||
{
|
||||
anyhow::ensure!(
|
||||
depth < 8 && path.insert(entry.owner),
|
||||
"3D UI paint nesting cycle or depth budget exceeded"
|
||||
);
|
||||
// 只构造新的原生快照,不写回源 Scene3D;同一业务场景仍可被其他视口共享。
|
||||
let mut scene = (*view.scene).clone();
|
||||
for mesh in &mut scene.meshes {
|
||||
if let Some(panel) = &mut mesh.panel {
|
||||
let mut surface = super::UiSurface {
|
||||
commands: Vec::new(),
|
||||
clips: Vec::new(),
|
||||
views: Vec::new(),
|
||||
};
|
||||
for child in groups.get(&(entry.owner, panel.key)).into_iter().flatten() {
|
||||
let child = assemble(child.clone(), groups, path, depth + 1)?;
|
||||
surface.commands.push(child.command);
|
||||
surface.clips.push(child.clip);
|
||||
surface.views.push(child.view);
|
||||
}
|
||||
panel.surface = Some(Arc::new(surface));
|
||||
}
|
||||
}
|
||||
*view = Arc::new(View {
|
||||
scene: Arc::new(scene),
|
||||
camera: view.camera,
|
||||
});
|
||||
path.remove(&entry.owner);
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
let mut path = HashSet::new();
|
||||
frame.commands.clear();
|
||||
frame.command_clips.clear();
|
||||
frame.command_views.clear();
|
||||
frame.command_mounts.clear();
|
||||
for entry in roots {
|
||||
let entry = assemble(entry, &groups, &mut path, 0)?;
|
||||
frame.commands.push(entry.command);
|
||||
frame.command_clips.push(entry.clip);
|
||||
frame.command_views.push(entry.view);
|
||||
frame.command_mounts.push(entry.owner);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。
|
||||
pub fn texture(panel: &super::Panel) -> Result<Vec<u32>, &'static str> {
|
||||
crate::software_renderer::reserve_pixels(panel.width as usize * panel.height as usize)?;
|
||||
let mut pixels =
|
||||
vec![super::rgb(panel.background); panel.width as usize * panel.height as usize];
|
||||
if let Some(surface) = &panel.surface {
|
||||
let document = crate::document::RenderDocument {
|
||||
frame: crate::document::Frame {
|
||||
background: format!("#{:06X}", super::rgb(panel.background)),
|
||||
commands: surface.commands.clone(),
|
||||
command_views: surface.views.clone(),
|
||||
command_clips: surface.clips.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let list = document
|
||||
.display_list(1.)
|
||||
.map_err(|_| "invalid 3D UI texture commands")?;
|
||||
crate::software_renderer::SoftwareRenderer::render(
|
||||
&list,
|
||||
&mut pixels,
|
||||
panel.width,
|
||||
panel.height,
|
||||
)?;
|
||||
}
|
||||
Ok(pixels)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。
|
||||
//! 空间索引只做宽阶段筛选;最终拾取仍检查真实三角形。相机变化可复用索引,几何或变换变化需要新索引。
|
||||
//!
|
||||
//! Immutable world-space BVH. Camera changes reuse it; it contains no VM data.
|
||||
use super::{
|
||||
Mesh,
|
||||
math::{Basis, Camera, Ray, Vec3},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 变换后网格的世界空间 AABB,做保守筛选而不是精确表面描述。
|
||||
pub struct Bounds {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
impl Bounds {
|
||||
/// 变换网格顶点并求世界 AABB,作为空间索引的保守外包围。
|
||||
fn mesh(mesh: &Mesh) -> Option<Self> {
|
||||
let mut points = mesh
|
||||
.geometry
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|point| mesh.world.point(*point));
|
||||
let first = points.next()?;
|
||||
Some(points.fold(
|
||||
Self {
|
||||
min: first,
|
||||
max: first,
|
||||
},
|
||||
|bounds, point| Self {
|
||||
min: Vec3::new(
|
||||
bounds.min.x.min(point.x),
|
||||
bounds.min.y.min(point.y),
|
||||
bounds.min.z.min(point.z),
|
||||
),
|
||||
max: Vec3::new(
|
||||
bounds.max.x.max(point.x),
|
||||
bounds.max.y.max(point.y),
|
||||
bounds.max.z.max(point.z),
|
||||
),
|
||||
},
|
||||
))
|
||||
}
|
||||
/// 求两个包围盒的最小共同外包围,供 BVH 父节点使用。
|
||||
fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
min: Vec3::new(
|
||||
self.min.x.min(other.min.x),
|
||||
self.min.y.min(other.min.y),
|
||||
self.min.z.min(other.min.z),
|
||||
),
|
||||
max: Vec3::new(
|
||||
self.max.x.max(other.max.x),
|
||||
self.max.y.max(other.max.y),
|
||||
self.max.z.max(other.max.z),
|
||||
),
|
||||
}
|
||||
}
|
||||
/// 取指定轴中心位置,供最长轴排序和分割使用。
|
||||
fn center(self, axis: usize) -> f32 {
|
||||
let min = [self.min.x, self.min.y, self.min.z];
|
||||
let max = [self.max.x, self.max.y, self.max.z];
|
||||
(min[axis] + max[axis]) / 2.
|
||||
}
|
||||
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
|
||||
fn ray(self, ray: Ray) -> Option<f32> {
|
||||
let mut near = 0f32;
|
||||
let mut far = f32::INFINITY;
|
||||
for (origin, direction, min, max) in [
|
||||
(ray.origin.x, ray.direction.x, self.min.x, self.max.x),
|
||||
(ray.origin.y, ray.direction.y, self.min.y, self.max.y),
|
||||
(ray.origin.z, ray.direction.z, self.min.z, self.max.z),
|
||||
] {
|
||||
if direction.abs() < 1e-12 {
|
||||
if origin < min || origin > max {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = (min - origin) / direction;
|
||||
let b = (max - origin) / direction;
|
||||
near = near.max(a.min(b));
|
||||
far = far.min(a.max(b));
|
||||
if far < near {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(near)
|
||||
}
|
||||
/// 用相机六平面保守判断包围盒可见性,返回保持原场景顺序的候选。
|
||||
fn visible(self, camera: Camera, basis: Basis, planes: &[[f32; 4]; 6]) -> bool {
|
||||
let corners: [Vec3; 8] = std::array::from_fn(|index| {
|
||||
camera.view(
|
||||
basis,
|
||||
Vec3::new(
|
||||
if index & 1 == 0 {
|
||||
self.min.x
|
||||
} else {
|
||||
self.max.x
|
||||
},
|
||||
if index & 2 == 0 {
|
||||
self.min.y
|
||||
} else {
|
||||
self.max.y
|
||||
},
|
||||
if index & 4 == 0 {
|
||||
self.min.z
|
||||
} else {
|
||||
self.max.z
|
||||
},
|
||||
),
|
||||
)
|
||||
});
|
||||
!planes.iter().any(|[a, b, c, d]| {
|
||||
corners
|
||||
.iter()
|
||||
.all(|point| a * point.x + b * point.y + c * point.z + d < 0.)
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// BVH 叶或分支;叶保存实例索引,分支保存两个子节点索引。
|
||||
enum Node {
|
||||
Leaf {
|
||||
bounds: Bounds,
|
||||
items: Vec<usize>,
|
||||
},
|
||||
Branch {
|
||||
bounds: Bounds,
|
||||
left: usize,
|
||||
right: usize,
|
||||
},
|
||||
}
|
||||
impl Node {
|
||||
/// 读取叶/分支统一的包围盒,隐藏节点种类差异。
|
||||
fn bounds(&self) -> Bounds {
|
||||
match self {
|
||||
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// 由不可变场景建立的包围盒树,供相机视锥和射线查询复用。
|
||||
pub struct Index {
|
||||
bounds: Vec<Option<Bounds>>,
|
||||
nodes: Vec<Node>,
|
||||
root: Option<usize>,
|
||||
}
|
||||
impl Index {
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub fn build(meshes: &[Mesh]) -> Self {
|
||||
let bounds: Vec<_> = meshes.iter().map(Bounds::mesh).collect();
|
||||
let mut items: Vec<_> = bounds
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, bounds)| bounds.map(|_| index))
|
||||
.collect();
|
||||
let mut index = Self {
|
||||
bounds,
|
||||
nodes: Vec::new(),
|
||||
root: None,
|
||||
};
|
||||
if !items.is_empty() {
|
||||
index.root = Some(index.split(&mut items));
|
||||
}
|
||||
index
|
||||
}
|
||||
/// 按包围盒最长轴的中心排序并递归二分,叶节点保留少量网格候选。
|
||||
fn split(&mut self, items: &mut [usize]) -> usize {
|
||||
let bounds = items
|
||||
.iter()
|
||||
.map(|&index| self.bounds[index].unwrap())
|
||||
.reduce(Bounds::union)
|
||||
.unwrap();
|
||||
let index = self.nodes.len();
|
||||
self.nodes.push(Node::Leaf {
|
||||
bounds,
|
||||
items: Vec::new(),
|
||||
});
|
||||
if items.len() <= 4 {
|
||||
self.nodes[index] = Node::Leaf {
|
||||
bounds,
|
||||
items: items.to_vec(),
|
||||
};
|
||||
} else {
|
||||
let extent = bounds.max - bounds.min;
|
||||
let axis = if extent.x >= extent.y && extent.x >= extent.z {
|
||||
0
|
||||
} else if extent.y >= extent.z {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
items.sort_unstable_by(|&a, &b| {
|
||||
self.bounds[a]
|
||||
.unwrap()
|
||||
.center(axis)
|
||||
.total_cmp(&self.bounds[b].unwrap().center(axis))
|
||||
});
|
||||
let mid = items.len() / 2;
|
||||
let (left, right) = items.split_at_mut(mid);
|
||||
let left = self.split(left);
|
||||
let right = self.split(right);
|
||||
self.nodes[index] = Node::Branch {
|
||||
bounds,
|
||||
left,
|
||||
right,
|
||||
};
|
||||
}
|
||||
index
|
||||
}
|
||||
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
|
||||
pub fn matches(&self, meshes: usize) -> bool {
|
||||
self.bounds.len() == meshes
|
||||
}
|
||||
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
|
||||
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
|
||||
let mut stack: Vec<_> = self.root.into_iter().collect();
|
||||
let mut output = Vec::new();
|
||||
while let Some(index) = stack.pop() {
|
||||
if self.nodes[index].bounds().ray(ray).is_none() {
|
||||
continue;
|
||||
}
|
||||
match &self.nodes[index] {
|
||||
Node::Leaf { items, .. } => {
|
||||
for &item in items {
|
||||
if let Some(distance) = self.bounds[item].unwrap().ray(ray) {
|
||||
output.push((distance, item));
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Branch { left, right, .. } => {
|
||||
stack.push(*left);
|
||||
stack.push(*right);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
output
|
||||
}
|
||||
/// 用相机六平面保守判断包围盒可见性,返回保持原场景顺序的候选。
|
||||
pub fn visible(&self, camera: Camera, aspect: f32) -> Vec<usize> {
|
||||
let Some(basis) = camera.basis() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let planes = super::frustum_planes(camera, aspect);
|
||||
let mut stack: Vec<_> = self.root.into_iter().collect();
|
||||
let mut output = Vec::new();
|
||||
while let Some(index) = stack.pop() {
|
||||
if !self.nodes[index].bounds().visible(camera, basis, &planes) {
|
||||
continue;
|
||||
}
|
||||
match &self.nodes[index] {
|
||||
Node::Leaf { items, .. } => output.extend(
|
||||
items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|item| self.bounds[*item].unwrap().visible(camera, basis, &planes)),
|
||||
),
|
||||
Node::Branch { left, right, .. } => {
|
||||
stack.push(*left);
|
||||
stack.push(*right);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.sort_unstable();
|
||||
output
|
||||
}
|
||||
}
|
||||