diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 964add54..11b5056c 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -4,14 +4,12 @@ on: pull_request: types: ['opened', 'synchronize'] -concurrency: - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - jobs: build-pull-request: name: Build pull request runs-on: ubuntu-latest + env: + PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -26,3 +24,17 @@ jobs: env: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: preview + path: dist + retention-days: 1 + - name: Save pr number + run: echo ${PR_NUMBER} > ./pr.txt + - name: Upload pr number + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr + path: ./pr.txt + retention-days: 1 diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 00000000..42eab7ef --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,36 @@ +name: 'CLA Assistant' +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize] + +jobs: + CLAssistant: + runs-on: ubuntu-latest + steps: + - name: 'CLA Assistant' + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Beta Release + uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # the below token should have repo scope and must be manually added by you in the repository's secret + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }} + with: + path-to-signatures: 'signatures.json' + path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document + # branch should not be protected + branch: 'main' + allowlist: ajbura,bot* + + #below are the optional inputs - If the optional inputs are not given, then default values will be taken + remote-organization-name: cinnyapp + remote-repository-name: cla + #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' + #signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo' + #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' + #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' + #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' + #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) + #use-dco-flag: true - If you are using DCO instead of CLA diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml new file mode 100644 index 00000000..e59681ce --- /dev/null +++ b/.github/workflows/deploy-pull-request.yml @@ -0,0 +1,60 @@ +name: Deploy PR to Netlify +run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})" + +on: + workflow_run: + workflows: ["Build pull request"] + types: [completed] + +jobs: + deploy-pull-request: + name: Deploy pull request + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Download pr number + uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24 + with: + workflow: ${{ github.event.workflow.id }} + run_id: ${{ github.event.workflow_run.id }} + name: pr + - name: Validate and output pr number + id: pr + run: | + PR_ID=$(> "${GITHUB_OUTPUT}" + - name: Download artifact + uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24 + with: + workflow: ${{ github.event.workflow.id }} + run_id: ${{ github.event.workflow_run.id }} + name: preview + path: dist + - name: Deploy to Netlify + id: netlify + uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0 + with: + publish-dir: dist + deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}" + alias: ${{ steps.pr.outputs.id }} + # These don't work because we're in workflow_run + enable-pull-request-comment: false + enable-commit-comment: false + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }} + timeout-minutes: 1 + - name: Comment preview on PR + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + number: ${{ steps.pr.outputs.id }} + message: | + Preview: ${{ steps.netlify.outputs.deploy-url }} + ⚠️ Exercise caution. Use test accounts. ⚠️ diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 24b9389c..302c3788 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -5,10 +5,7 @@ on: paths: - 'Dockerfile' - '.github/workflows/docker-pr.yml' - -concurrency: - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true + - '.github/workflows/prod-deploy.yml' jobs: docker-build: diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index e96d2818..a9cc1a34 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -5,10 +5,6 @@ on: paths: - 'package-lock.json' -concurrency: - group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - jobs: lockfile_changes: runs-on: ubuntu-latest diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml new file mode 100644 index 00000000..12107685 --- /dev/null +++ b/.github/workflows/netlify-dev.yml @@ -0,0 +1,39 @@ +name: Deploy to Netlify (dev) + +on: + push: + branches: + - dev + +jobs: + deploy-to-netlify: + name: Deploy to Netlify + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ".node-version" + package-manager-cache: false + - name: Install dependencies + run: npm ci + - name: Build app + env: + NODE_OPTIONS: '--max_old_space_size=4096' + run: npm run build + - name: Deploy to Netlify + uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0 + with: + publish-dir: dist + deploy-message: 'Dev deploy ${{ github.sha }}' + enable-commit-comment: false + github-token: ${{ secrets.GITHUB_TOKEN }} + production-deploy: true + github-deployment-environment: nightly + github-deployment-description: 'Nightly deployment on each commit to dev branch' + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }} + timeout-minutes: 1 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..a52ee8e6 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,15 @@ +name: Check PR title + +on: + pull_request_target: + types: + - opened + - edited + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml new file mode 100644 index 00000000..2a8192db --- /dev/null +++ b/.github/workflows/prod-deploy.yml @@ -0,0 +1,100 @@ +name: Production deploy + +on: + release: + types: [published] + +jobs: + deploy-and-tarball: + name: Netlify deploy and tarball + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: '.node-version' + package-manager-cache: false + - name: Install dependencies + run: npm ci + - name: Build app + env: + NODE_OPTIONS: '--max_old_space_size=4096' + run: npm run build + - name: Deploy to Netlify + uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0 + with: + publish-dir: dist + deploy-message: 'Prod deploy ${{ github.ref_name }}' + enable-commit-comment: false + github-token: ${{ secrets.GITHUB_TOKEN }} + production-deploy: true + github-deployment-environment: stable + github-deployment-description: 'Stable deployment on each release' + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }} + timeout-minutes: 1 + - name: Get version from tag + id: vars + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + - name: Create tar.gz + run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist + - name: Sign tar.gz + run: | + echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import + # Sadly a few lines in the private key match a few lines in the public key, + # As a result just --export --armor gives us a few lines replaced with *** + # making it useless for importing the signing key. Instead, we dump it as + # non-armored and hex-encode it so that its printable. + echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import" + gpg --export | xxd -p + echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz + - name: Upload tagged release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + with: + tag_name: ${{ github.ref_name }} + files: | + cinny-${{ steps.vars.outputs.tag }}.tar.gz + cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc + + publish-image: + name: Push Docker image to Docker Hub, GHCR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up QEMU + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - name: Login to Docker Hub #Do not update this action from a outside PR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Login to the Github Container registry #Do not update this action from a outside PR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker, GHCR + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: | + ${{ secrets.DOCKER_USERNAME }}/cinny + ghcr.io/${{ github.repository }} + - name: Build and push Docker image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml deleted file mode 100644 index 47f22b7e..00000000 --- a/.github/workflows/rat-dev-preview.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Rat dev preview - -on: - push: - branches: ['dev'] - -# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run -# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other -# groups; they never cancel it. -concurrency: - group: rat-dev-preview - cancel-in-progress: true - -jobs: - build: - name: Build dev preview - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: ".node-version" - package-manager-cache: false - - name: Install dependencies - run: npm ci - - name: Build app - env: - CINNY_BASE: /void/rat-dev/ - VITE_PREVIEW_DEMO: '1' - NODE_OPTIONS: '--max_old_space_size=3072' - run: npm run build - - name: Set background scene tier and the pretend homeserver - run: node scripts/preview-config.js low --demo - - name: Add the demo pose art - run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/ - - name: Upload artifact - # v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath - # (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169). - uses: forgejo/upload-artifact@v3 - with: - name: rat-dev-dist - path: dist - retention-days: 3 diff --git a/.gitignore b/.gitignore index 4e7d929d..1af58a97 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,4 @@ node_modules devAssets .DS_Store -.idea -# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed. -public/poses/* -!public/poses/.gitkeep +.idea \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index c81e668b..e0aa18a1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,4 @@ dist node_modules package.json package-lock.json -LICENSE -harness -src/app/components/message/pose/catalogue.json +LICENSE \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f2ce386..859eab14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to Ratatoskr +# Contributing to Cinny First off, thanks for taking the time to contribute! ❤️ -This document describes the process of contributing to Ratatoskr. It is intended +This document describes the process of contributing to Cinny. It is intended for anyone considering opening an **issue**, **discussion** or **pull request**. For people who are interested in developing Cinny and technical details behind it, please check out our ["Developing Cinny"](HACKING.md) document as well. @@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n ## AI Usage -The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** +The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** ## Quick Guide @@ -35,7 +35,7 @@ The Cinny project strictly does not allow any AI generated code. Any pull reques > When contributing to this project, you must agree that you have authored 100% > of the content, that you have the necessary rights to the content and that > the content you contribute may be provided under the project license. You will -> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request. +> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request. [All issues are actionable](#issues-are-actionable). Pick one and start working on it. Thank you. If you need help or guidance, comment on the issue. diff --git a/HACKING.md b/HACKING.md index bb6d3c5d..30c4960f 100644 --- a/HACKING.md +++ b/HACKING.md @@ -1,4 +1,4 @@ -# Developing Ratatoskr +# Developing Cinny > [!TIP] > We recommend using a version manager as versions change very quickly. @@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can build the container like so: ``` -docker build -t ratatoskr:latest . +docker build -t cinny:latest . ``` You can then run the container you've built with a command similar to this: ``` -docker run -p 8080:80 ratatoskr:latest +docker run -p 8080:80 cinny:latest ``` This will forward your `localhost` port 8080 to the container's port 80. diff --git a/README.md b/README.md index dfc722ad..46c7a67b 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,125 @@ -# Ratatoskr +# Cinny +

+ + GitHub release downloads + + DockerHub downloads + + Follow on Mastodon + + Follow on Twitter + + Sponsor Cinny +

-GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny). -It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds, -and your own place in it) instead of an invite list. Mobile first. +A Matrix client focusing primarily on simple, elegant and secure interface. +The main goal is to have an instant messaging application that is easy on +people and has a modern touch. +- [Roadmap](https://github.com/orgs/cinnyapp/projects/1) +- [Contributing](./CONTRIBUTING.md) -**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a -Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits -(opt-in setting), a background scene and soundscape (opt-in, off by default). -**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md) -for the target and cut sequence. Internal names that are protocol or stored -state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event -type, `CINNY_BASE`) are unchanged on purpose. +> [!IMPORTANT] +We are currently in the process of [replacing] the matrix-js-sdk with our +own SDK. As a result, we will not be accepting any pull requests until +further notice. Thank you for your understanding. -Entry points: `src/app/` (client UI), `src/app/features/scene/` (background -scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate -drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons). +[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704 -## Upstream -Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0. -Cinny's own README, desktop app and releases live at -. We do not ship its releases or images. + -## Developing -See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md). +## Getting started +The web app is available at [app.cinny.in] and gets updated on each new +release. The `dev` branch is continuously deployed at [dev.cinny.in] +but keep in mind that it could have things broken. + +You can also download our desktop app from the [cinny-desktop repository]. + +[app.cinny.in]: https://app.cinny.in +[dev.cinny.in]: https://dev.cinny.in +[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop + +## Contributing and Developing + +If you have any ideas, issues, etc. regarding Cinny, or would like to +contribute to Cinny through pull requests, please check out our +["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like +to get involved with Cinny's development as well should also read the +["Developing Cinny"](HACKING.md) document for more technical details. ## Self-hosting -Build with `npm ci && npm run build` and serve `dist/` with your webserver. +To host Cinny on your own, simply download the tarball from +[GitHub releases], and serve the files from `dist/` using your preferred +webserver. Alternatively, you can just pull the docker image from +[DockerHub] or [GitHub Container Registry]. * The default homeservers and explore pages are defined in [`config.json`](config.json). -* Serving the assets needs redirects to `index.html`. Examples: -[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf), -[caddy](contrib/caddy/caddyfile). Without them you can -[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`. -* To deploy on a subdirectory, rebuild with the `base` path set, either in -[`build.config.ts`](build.config.ts) or through `CINNY_BASE` -(for example `CINNY_BASE=/app/ npm run build`). + +* You need to set up redirects to serve the assests. Example configurations; +[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), +[caddy](contrib/caddy/caddyfile). + * If you have trouble configuring redirects you can + [enable hash routing](config.json#L35) — the url in the browser will have + a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` + instead of `app.cinny.in/home/`) but you won't have to configure your webserver. + +* To deploy on subdirectory, you need to rebuild the app youself after +updating the `base` path in [`build.config.ts`](build.config.ts). + * For example, if you want to deploy on `https://cinny.in/app`, then + set `base: '/app'`. + +[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest +[DockerHub]: https://hub.docker.com/r/ajbura/cinny +[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny + +
PGP Public Key to verify tarball + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +mxFo+ioe/ABCufSmyqFye0psX3Sp +=WtqZ +-----END PGP PUBLIC KEY BLOCK----- +``` +
## License -Ratatoskr is a fork of the Cinny project +Cinny Project Copyright © 2024–present Ajay Bura https://cinny.in diff --git a/config.json b/config.json index dd4c887b..a61bb966 100644 --- a/config.json +++ b/config.json @@ -2,23 +2,30 @@ "defaultHomeserver": 1, "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "allowCustomHomeservers": true, + "featuredCommunities": { "openAsDefault": false, - "spaces": ["#gamecult:gamecult.org"], + "spaces": [ + "#cinny:matrix.org", + "#community:matrix.org", + "#space:unredacted.org", + "#librewolf-community:matrix.org", + "#stickers-and-emojis:tastytea.de", + "#videogames:waywardinn.com", + "#science-space:matrix.org", + "#libregaming-games:tchncs.de", + "#mathematics-on:matrix.org" + ], "rooms": [ "#tuwunel:grin.hu", - "#free-software-movement:matrix.org", + "#freesoftware:matrix.org", "#gentoo:matrix.org" ], - "servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] + "servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] }, "hashRouter": { "enabled": false, "basename": "/" - }, - - "backgroundScene": { - "tier": "off" } } diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile index a686b16b..d23dcccb 100644 --- a/contrib/caddy/caddyfile +++ b/contrib/caddy/caddyfile @@ -1,6 +1,6 @@ # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas -ratatoskr.domain.tld { - root * /path/to/ratatoskr/dist +cinny.domain.tld { + root * /path/to/cinny/dist try_files {path} / index.html file_server } diff --git a/contrib/nginx/ratatoskr.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf similarity index 85% rename from contrib/nginx/ratatoskr.domain.tld.conf rename to contrib/nginx/cinny.domain.tld.conf index 7df64900..02c7ead9 100644 --- a/contrib/nginx/ratatoskr.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -1,7 +1,7 @@ server { listen 80; listen [::]:80; - server_name ratatoskr.domain.tld; + server_name cinny.domain.tld; location / { return 301 https://$host$request_uri; @@ -15,10 +15,10 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl; - server_name ratatoskr.domain.tld; + server_name cinny.domain.tld; location / { - root /opt/ratatoskr/dist/; + root /opt/cinny/dist/; rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/docs/file-zone.md b/docs/file-zone.md deleted file mode 100644 index 01b5a4c5..00000000 --- a/docs/file-zone.md +++ /dev/null @@ -1,106 +0,0 @@ -# File zone: design sketch - -Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body. - -Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`. - -## What it is - -A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change. - -First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around. - -## Matrix event shapes (stock events only) - -- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest). -- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root. -- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: , is_falling_back: true, "m.in_reply_to": { event_id: } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact. -- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. -- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. - -## Marks (reactions, plus an optional reply) - -A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply: - -- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: , key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it. -- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither. -- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it. -- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue. -- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file. -- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason. -- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default. - -## Export - -Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. - -`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: - -``` -{ schema: "aqua.listen.verdict.v1", - kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body - exportedAt, // ISO time of export - seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN) - listener, // sender display name - device, // headphones|speakers|null, the sender's most-used mark device, or null - notes, // free text; zone leaves "" in v1 - summary: { total, keep, fix, drop, unanswered }, - clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }], - pairs: [{ a, b, preferred }] } // empty array when the album has no pairs -``` - -- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog. -- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. -- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). -- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. -- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. -- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. -- JSON here is a xenos-boundary export, not state; the room events are the state. - -## Mobile-first layout - -- Phone width is the design width; desktop adds columns. -- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. -- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. -- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal. -- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. - -## Upload - -Reuse the composer's pipeline, pointed at an album instead of the main timeline: - -- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. -- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). -- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. -- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. - -## Existing code to reuse (verified present in the repo) - -| need | where | -|---|---| -| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | -| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | -| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | -| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | -| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | -| event content builders | `features/room/msgContent.ts` | -| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | -| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | - -Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). - -## First slice (what to build first) - -Rough lines of TypeScript, tests excluded. - -1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. -2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. -3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". - -Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. - -## Open questions for the Meta - -- How the client identifies the zone room (config id recommended). -- Whether exports are per listener (proposed) or a merged all-listeners file. -- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md deleted file mode 100644 index 166c5c72..00000000 --- a/docs/gamecult/ratatoskr-target.md +++ /dev/null @@ -1,352 +0,0 @@ -# Ratatoskr: target and prose map - -Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge -`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). -Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside -the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it -is handed to Void's mind once, by procedure, as soon as that hand-off procedure -exists (`hand_off` is parked); after that every new ruling on it is admitted in -Void's mind. Nothing here is written into Void's mind before then. - -The mind holds the questions, rulings, specs, reports and verdicts. This file -holds what no document kind carries: the design truths, body facts, the model -page, rationale, the reconstructed collaboration plan, and the cut sequence. -All times are UTC. - -## Why - -GameCult is the only server this client talks to. A general Matrix client -treats it as one community among thousands, so the operator meets GameCult's -structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm` -10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The -client should open on GameCult's estate as data: its spaces, its project rooms -grouped by steward, its Minds, and the person's own place in it. It should -also be a pleasant place to be in its own right: emotes, sound, and weather in -the background. - -## Design truths (from rulings in force) - -- **We own the interface, not the engine.** The fork keeps Cinny's engine - (matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every - user-facing string and its translation is ours, and upstream locale files - are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`). -- **Mobile first.** On phones, inactive elements hide, vertical space is - cheaper than horizontal, and text never gets squeezed into narrow columns - (`eureka-body:ruling:chat-client-base-cinny-mobile-first`). -- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote - slot on every bubble (a pose id carried on the event); room and participant - lists tiered (category, then items) and reached from the header menu, not - side columns; participants grouped by estate (Minds, Members, Users, - Patrons); rooms grouped by kind (general, estates, projects, work rooms); - Minds indexed by stewardship, read from Matrix state projected from the - stewardship records in the Minds' own minds (the minds stay the truth); one - room per project, a Space child, with the steward joined. -- **Rooms are visible without a join dance.** No invite screen in the way; a - room the server lets you join shows in the same list as joined ones; opening - one shows its name and a one-tap Join; joining happens on that tap or a first - post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12 - "Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost"). - Rooms you may not enter are refused by the server, not hidden by the client - (Heimdall 04:10, accepted by Void 04:11 and 04:15). -- **Language.** A user's language is the BCP 47 tag in profile field - `org.gamecult.language`, owned by the user through the client's language - picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships - localized; people talk with stewards in their own language; rooms are made - for a language, with no per-language mirrors - (`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One - host Mind sits in a language room, Weksa for `#pt-br` - (`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this - must work for is a non-English, non-technical patron (placeholder - `patron-pt1`) whose account we set up and walk through login - (`gamecult-chat:ruling:patron-in-his-own-language`). -- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for - now; Web Push to the installed web app is a different thing and is open - (question `ratatoskr:question:pwa-web-push`). -- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in - Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not - deployed, so the plan below is its interim home; Atlas imports it later. - -## Body facts - -Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. - -1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main - `1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile): - a GameCult theme as default, member drawer hidden by default, bubble - layout, and steward grouping in `src/app/pages/client/space/Space.tsx` - (`useStewardGroups` at 429-437, group headers at 505-524) over - `useSpaceJoinedHierarchy` (409). Read by `git diff`. -2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the - client API, counts only). Root Space `#gamecult:gamecult.org`, room version - 12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52 - children; 50 readable rooms, all room version 12. Server `/hierarchy` from - the root returns 59 rooms, 2 of them spaces. **Every room's join rule is - `invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward` - with content `{mind, repo, source, steward: }`. The root carries - `org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`. -3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are - invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md` - 146-158: "Members and every Mind are invited; the steward alone joins"). - So a Member's project rooms are pending invites. Void's grouping reads - `useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot - read, so for a typical Member it groups nothing and the rooms stay in the - invite list. This is the operator's "three clicks deep" complaint, seen from - the data. -4. **Provisioning contract.** gamecult-ops - `scripts/synapse-accounts-hand-managed.py` writes the rooms and both state - events from the Minds' stewardship documents; each project room's alias is - `#` (runbook 146-150). This is the only place the client - can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` - joined to the root `org.gamecult.stewardships`. Follow-up - `ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to - name room ids so the client stops leaning on the alias convention. -5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses - matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the - Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2 - `/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold - start is therefore an IndexedDB load plus an incremental sync from the - stored token, not a full initial sync. -6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1 - (127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`: - simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it. -7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is - served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br - are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The - service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`, - a session `message`, and authenticated media `fetch` (79-158); the - `vite-plugin-pwa` config sets `injectionPoint: undefined` - (`vite.config.ts` 101-113), so nothing is precached. -8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler; - no code subscribes through `PushManager`; the only pusher the client sets is - the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx` - 22-40). Notifications and sounds fire only from a live page: - `src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258 - (messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at - 156, `notification.ogg` played at 175-178). Nothing in `src/` listens for - `visibilitychange` or calls `retryImmediately`. -9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read - 2026-10-31; the page itself is marked partly out of date): a `webpush` app - takes `vapid_private_key`, `vapid_contact_email`, optional - `allowed_endpoints` and `ttl`; the client's pusher `data` carries - `endpoint`, `auth`, `p256dh` and optional `default_payload`; the push - carries `room_id`, `room_name`, `sender_display_name`, `content`, unread - counts and so on; browsers need a visible notification for each push - (`events_only` avoids event-less pushes). - -10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image - `node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1 - file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61 - MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm - 5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with - 793 errors in 246 files (455 of them TS2614, named imports from - `matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as - is; a cut gates on zero errors in the files it adds and no rise in the - total. - -Not probed: phone frame times, the real resume timeline on the operator's -Android Chrome install (needs her device or remote DevTools), the Rust crypto -wasm size, and the router base under `/preview/` (its `config.json` sets -`hashRouter.basename` to `/`). - -## Why the PWA resumes slowly (diagnosis, moderate confidence) - -Android freezes a backgrounded installed web app and later discards it. On a -**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a -phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB -store load, then a catch-up `/sync` over every room since the stored token -(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and -the SDK only notices on its own timers because the client never nudges it on -`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms -and the swarm's chat volume, which is exactly where sliding sync helps -(fact 6), but the first two costs are cheaper to remove and should be measured -first. Without Web Push, a frozen page cannot notify at all, which is the -second half of the operator's report. - -## Model page: the estate - -| Kind | Named by | Over time | Who decides | -|---|---|---|---| -| Stewardship | `:stewardship:.n` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth | -| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) | -| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives | -| Room membership | the user's `m.room.member` | join on tap or first post | the user | -| Navigator view | derived per render | never stored | the client's estate index | - -## Authority map for the navigator - -- **Owner:** one estate index in the client (`src/app/features/estate/`): - `buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the - person sees, in which tier and group, and with what membership affordance. -- **Inputs:** the paged server `/hierarchy` of the root Space; root Space - state `org.gamecult.stewardships`; the person's membership per room from the - client store; unread and selection atoms for emphasis only; the `m.direct` - list for the direct-messages tier. -- **Outputs:** an ordered list of tiers (one per root room group and per - sub-Space, plus Direct), each holding steward groups or rooms, each room - marked joined, invited or joinable. -- **Derived:** steward group labels (Mind display names); counts; collapsed - state is per-device UI state only. -- **Demoted or dead:** the sidebar rail and its account-data ordering - (`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides - what is listed; Void's per-room steward reader is deleted, not kept beside - the index. -- **Shared paths:** every nav slot (home, direct, space routes) renders the - same navigator; tapping Join and sending a first post both go through - `mx.joinRoom`; deep links into a room still resolve through the existing - room routes. - -## Reconstructed collaboration plan (Atlas import) - -Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status -as of 2026-10-31 ~15:00. - -**Who holds which part** - -| Part | Holder | Status | Evidence | -|---|---|---|---| -| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report | -| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 | -| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` | -| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` | -| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." | -| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void | -| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall | - -**Agreements** - -- Dings are pre-rendered clips shipped as static assets, no synth in the - browser: `dings//.ogg` plus a small manifest; an - unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void - `#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the - instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound: - a mention, a DM, an invite, a message in an unmuted room; mentions and DMs - loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19). - The operator wants far more than four motifs (08:32). The instrument per - Mind belongs in a typed document on the Mind side, not hardcoded (Eureka - 03:19, advice). Aqua's ear check comes before anything is called done, and - the operator's ear is ground truth (Aqua 07:41). Browsers block audio until - the first click, so the first ding after a fresh load may be silent. -- The background has its own canvas and budget, separate from the bubble - flourishes; capped at 30 fps, paused when hidden; under reduced motion one - static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22). -- The background is an infinite raymarched terrain: CultMath noise plus the - Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm", - 05:24 "all we need is noise", 05:25 erosion, restated by the operator to - Self on 10-31: "use CultMath noise and erosion filter running in the shader - to have an infinite terrain. We can just raymarch that instead"). Voronoi - and Apollonian are dropped (05:24). -- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should - be the star of the show now, so really the camera should just pull back a - bit when you login and the personas should be orbiting the tree, dancing - around each other in accordance with their activity"). On login the camera - pulls back from the tree; there is no separate intro to cut away from. The - Personas orbit the tree and move around each other by their live activity. -- One activity vocabulary drives both sound and motion: Aqua's activity-kinds - grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent - activity in typed state") is the shared contract between Aqua's motifs and - Nibu's orbits, not two vocabularies. The live feed behind it is the same - typed state the swarm dashboard projects - (`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby; - `libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace. - What reaches the client is a member-safe projection: kind, Mind, time and - intensity, never briefs or mind contents. Matrix presence and typing cover - humans and are too coarse for Minds. Where the feed comes from is - `ratatoskr:question:activity-feed-source`. -- The scene renders in two passes, not one loop (operator to Self, 10-31: - "The tape optimized solid surface march should run really fast, i imagine, - way faster than the volumetric version, so they should not be one loop"). - Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces - with the field's Lipschitz bound and interval tile culling, at its own - resolution, and writes depth. Pass 2, the volumetric atmosphere march on - the site-ground framework (reduced resolution, TAA), is bounded by pass 1's - depth, so it marches only up to the surface hit. Each pass has its own - budget and its own measurement. Nibu owns both passes. -- The web math path is CultLib's own: HLSL source lowered by dxc and - SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, - `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through - .NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby - 06:34). No TS or WGSL port. The gamecult.org site ground already - concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled, - TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`, - spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the - base, not new tape work. -- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`, - `af_fbm` exist) is added in CultMath, not in the client - (`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what - you need and I'll fill it at the library"). -- Mood and flourish ride as metadata on the event, never guessed from text; - bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59). -- XP is two numbers, never one: a cosmetic, labelled game bar fed by things - worth doing, and contribution credit shown only once a real measure exists - (Void 06:45, with Nibu and Kiko). - -**Open items** - -- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath - tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be - probed before the march is trusted. -- Libby: public `fbm` in CultMath, with its GLSL mirror. -- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and - march and TAA tuning on the site ground's framework at a phone budget; - waits on Libby's `fbm` and the erosion bound. Then the intro scene in the - same world. -- Aqua: render the first earcon set once Faust builds in her sandbox; then the - instrument-per-Mind list and the manifest; then the soundscape design. -- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so - motifs and orbits read the same kinds. -- Void: the navigator (next cut); the Dings hook at - `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. -- Eureka: compression and caching for the shell on the chat host - (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's - room ids; a Web Push gateway if the operator picks it. - -## Cut sequence - -`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). -Each later cut is one line, with what it waits on. - -- `resume-fast`: precache the app shell in the service worker, nudge sync on - `visibilitychange`, and measure cold and warm resume on the operator's phone - before and after; waits on nothing; runs beside `estate-navigator` in its - own worktree, and pairs with the ops follow-up on compression. -- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a - `webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the - chat host; waits on `ratatoskr:question:pwa-web-push`. -- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which - the server already offers; waits on `resume-fast`'s measurements showing - catch-up sync dominates. -- `dings`: per-Mind earcons through the manifest at the existing notification - hook, and the same motifs in push notifications; waits on Aqua's renders. -- `emote-slot`: the pose-id emote on every bubble and the picker; waits on - `estate-navigator` (shared header work). -- `participant-tiers`: participants tiered by estate in the header menu, Minds - indexed by stewardship; waits on `estate-navigator`. -- `language`: our own strings and translations, the `org.gamecult.language` - picker, and the walk-through login for `patron-pt1`; waits on - `estate-navigator`. -- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on - the site ground's march and TAA framework, in its own canvas and budget, as - two passes (solid-surface sphere trace writing depth, then the volumetric - atmosphere bounded by it), each measured on a phone; Nibu on the scene and - both passes, Libby's CultMath underneath, Void - integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in - CultLib), then a phone measurement. -- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world; - on login the camera pulls back from the tree, and it may play during the - cold start or resume sync but never holds the client up; Nibu on the scene, - Libby on any CultMath it needs, Void integrating, Aqua's soundscape under - it; waits on `terrain-background`. -- `orbiting-personas`: Personas orbit the tree and dance around each other by - live activity, from the shared activity vocabulary; waits on - `yggdrasil-scene`, Aqua's activity-kinds grammar, and - `ratatoskr:question:activity-feed-source`. -- `bubbles-and-juice`: animated bubbles and mood flourishes from event - metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. -- `attention-bubbles`: per-person attention bubbles from Huginn's typed - attention document, scoped to room members; waits on Huginn publishing it. -- `rename-ratatoskr`: package name, app name and branding, manifest and - titles, the `/preview/` path and its deploy, and every in-repo `cinny` - reference that is ours (not upstream engine paths); waits on Void's forge - rename of `GameCult/cinny` to `GameCult/Ratatoskr` - (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo - list moves with it. diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs deleted file mode 100644 index 656d532e..00000000 --- a/harness/estate/check.cjs +++ /dev/null @@ -1,46 +0,0 @@ -// Assertions for the estate drives: every drive states the outcome it exists to show and exits -// non-zero when the page or the fake homeserver's log says otherwise. -const fs = require('fs'); -const fails = []; -exports.expect = (label, ok, detail = '') => { - console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); - if (!ok) fails.push(label); -}; -/** Exit code for the drive: 4 when any assertion failed. */ -exports.done = () => (fails.length ? 4 : 0); -/** The fake homeserver's request log (HSLOG is shared with the server process). */ -exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); -/** Bodies of the account-data PUTs for m.direct, in order. */ -exports.mDirectPuts = () => - exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); -/** Join requests the server saw for a room id. */ -exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; -const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ -const tierOf = (pg, name) => - pg.evaluate((n) => { - const s = document.body.innerText.split('\n').map((x) => x.trim()); - const i = s.indexOf(n); - if (i < 0) return 'absent'; - for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; - return 'none-above'; - }, name); -/** - * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is - * opened only if the row is not already showing, so reading never closes what is open. - */ -exports.revealedTier = async (pg, name) => { - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(2500); - let t = await tierOf(pg, name); - for (const h of HEADERS) { - if (t !== 'absent') break; - const hl = pg.getByText(h, { exact: true }); - if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } - } - return t; -}; -/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ -exports.sawText = (pg, re, ms) => - pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); -exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs deleted file mode 100644 index d53d88a4..00000000 --- a/harness/estate/drive-failures.cjs +++ /dev/null @@ -1,72 +0,0 @@ -// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh -// against fakehs.cjs with the env flag each mode needs. -const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); -const MODE = process.env.MODE; -const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; -const log = (...a) => console.log(`[${MODE}]`, ...a); -const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -let b; -// Close the browser, but never wait on it: a hung close must not hang the run. -const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code)); -setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); -(async () => { - b = await chromium.launch(); - const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); - const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); }); - const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); }; - await pg.goto('http://127.0.0.1:8080/'); - await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await open(); - const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => revealedTier(pg, name); - const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; - const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); - const acceptInInbox = async (name) => { - // reach the inbox through the navigator's own Inbox tab - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); - const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count()); - if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); } - const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); } - log('URL', pg.url()); - if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); } - { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } } - for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); } - const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120))); - const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last(); - await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t()); - }; - if (MODE === 'pjfail') { - await tap('Friend DM'); await pg.waitForTimeout(3000); - const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); - expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); - expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); - await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); - expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); - expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); - } else if (MODE === 'jdrop') { - await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); - expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); - await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); - expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); - expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); - } else if (MODE === 'admin') { - await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); - expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); - } else if (MODE === 'inboxadmin') { - await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); - expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); - } else if (MODE === 'inboxreload') { - await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); - await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); - expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); - } else if (MODE === 'pjreload') { - await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); - await pg.waitForTimeout(8000); await tiers('pjreload'); - const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); - expect('a join cut off by a reload stays joinable', n === 1, String(n)); - } - await quit(done()); -})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs deleted file mode 100644 index 75c9a8df..00000000 --- a/harness/estate/drive-routes.cjs +++ /dev/null @@ -1,42 +0,0 @@ -const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); -const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; -const log = (...a) => console.log(`[${MODE}]`, ...a); -const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); -setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); -(async () => { - b = await chromium.launch(); - const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); - const pg = await ctx.newPage(); - await pg.goto('http://127.0.0.1:8080/'); - await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); - const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; - let joined = false; - if (MODE === 'deeplink') { - for (const route of ['direct', 'home']) { - await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); - const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); - const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); - if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } - } - const f = await tiers('after deeplink join'); - const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); - const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; - expect('the deep link offers one Join button', joined, 'no Join button'); - expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); - expect('the DM is filed under Direct Messages', isDm(f), f); - } else if (MODE === 'gw504') { - const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); - for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); - const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); - expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); - await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); - expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); - expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); - } - await quit(done()); -})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs deleted file mode 100644 index 03bd2ad0..00000000 --- a/harness/estate/drive.cjs +++ /dev/null @@ -1,48 +0,0 @@ -const { chromium } = require('playwright'); -const { expect, done, isDm, revealedTier } = require('./check.cjs'); -const MODE = process.env.MODE; // twodm | reload -const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; -const log = (...a) => console.log(`[${MODE}]`, ...a); -const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -let tierBefore; -(async () => { - const b = await chromium.launch(); - const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); - const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); }); - const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); }; - await pg.goto('http://127.0.0.1:8080/'); - await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await open(); - const text = () => pg.evaluate(() => document.body.innerText); - const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => revealedTier(pg, name); - const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - if (MODE === 'twodm') { - await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); - await pg.waitForTimeout(45000); - } else if (MODE === 'convert') { - await tap('Friend DM'); await pg.waitForTimeout(6000); - tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); - await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); - const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); - await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); - log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600))); - await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300)); - await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>''))); - if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); } - await pg.waitForTimeout(8000); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - } else { - await tap('Slow DM'); await pg.waitForTimeout(3000); - log('RELOAD', t()); await open(); - await pg.waitForTimeout(30000); - } - const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); - log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); - if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } - else if (MODE === 'convert') { - expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); - expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); - } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); - await b.close(); process.exitCode = done(); -})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs deleted file mode 100644 index 4a6875e0..00000000 --- a/harness/estate/fakehs.cjs +++ /dev/null @@ -1,127 +0,0 @@ -// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content. -// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s); -// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast); -// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve. -const http = require('http'), fs = require('fs'), path = require('path'); -const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; -const NOROOT = process.env.NOROOT === '1'; -const log = []; -const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; -const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; -const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test'; -const SLOW = new Set([PROJ[5].id, DM2, DM3]); -const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; -PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0; -const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); -const strip = (id, name, space, direct) => [ - { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, - { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, - { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, - ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), - { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, -]; -const joinedRoom = (id, name, extra = [], space = false) => ({ - state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, - timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false }, - ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {}, -}); -const invites = () => { - const o = {}; - o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } }; - o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; - o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; - PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); - o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; - o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; - o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } }; - return o; -}; -const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); -const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); -const hierarchy = () => [ - hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }), - hroom(GEN, 'General', '#general:test'), - hroom(OP, 'Operator', '#operator:test'), - hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }), - hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }), - ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), -]; -let syncN = 1; -const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); }; -const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; -const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n')); -http.createServer((req, res) => { - const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname); - if (req.method === 'OPTIONS') return json(res, 200, {}); - if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) { - let f = path.join(DIST, p === '/' ? 'index.html' : p); - if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html'); - res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' }); - return fs.createReadStream(f).pipe(res); - } - let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { - if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body); - log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); - if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); - if (p.endsWith('/sync')) { - const since = u.searchParams.get('since'); - if (!since) { - const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); - } - const respond = () => { - const now = Date.now(); const join = {}; - for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { - const id = pending[i].id; pending.splice(i, 1); - const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; - join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); - } - for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } - const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks - if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); - return json(res, 200, { next_batch: since, rooms: {} }); - }; - return setTimeout(respond, 300); - } - const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); - if (req.method === 'POST' && jm) { - const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); - if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - const prior = pending.find((q) => q.id === DM2 || q.id === DM3); - pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); - if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } - if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } - if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } - return json(res, 200, { room_id: id }); - } - const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); - if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } - if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); } - if (p.includes('/account_data/') && req.method === 'PUT') { - const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1'; - const type = decodeURIComponent(p.split('/account_data/')[1]); - try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} - if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); } - return json(res, 200, {}); - } - if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); - if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] }); - if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); - if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); - if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); - if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); - if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); - if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); - if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); - if (p.includes('/members')) return json(res, 200, { chunk: [] }); - if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); - if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} }); - if (p.includes('/devices')) return json(res, 200, { devices: [] }); - if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' }); - if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] }); - return json(res, 200, {}); - }); -}).listen(PORT, () => console.log('fakehs up')); -setInterval(flush, 300); diff --git a/harness/estate/run.sh b/harness/estate/run.sh deleted file mode 100644 index 65242ab0..00000000 --- a/harness/estate/run.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: -# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs. -set -e -cd /src -npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } -npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } -echo BUILD_OK $(git rev-parse HEAD) -printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json -mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ -set +e -for m in twodm reload convert; do - HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & - HS=$!; sleep 2 - HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" - kill $HS; wait $HS 2>/dev/null; sleep 1 - echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt -done -failure() { m=$1; shift - env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & - HS=$!; sleep 2 - HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" - kill $HS; wait $HS 2>/dev/null; sleep 1 - echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } -failure pjfail FAIL_MDIRECT=1 -failure pjreload DELAY_MDIRECT=1 -failure jdrop JOIN_DROP=1 -failure admin INVITER_ADMIN=1 -failure inboxadmin INVITER_ADMIN=1 -failure inboxreload JOIN_DELAY=1 -DRIVER=drive-routes failure deeplink -DRIVER=drive-routes failure gw504 JOIN_504=1 -echo "FAILED_DRIVES:${FAILED:- none}" -[ -z "$FAILED" ] diff --git a/harness/scene/README.md b/harness/scene/README.md deleted file mode 100644 index 624d4b0b..00000000 --- a/harness/scene/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Scene harness - -Compiles, renders and times the background scene (`src/app/features/scene/`) in headless -Chromium. Not shipped, not run by CI. - - node bundle.cjs # esbuild bundle of entry.ts plus an index.html - node drive.cjs compile # compile and link each stage, JSON report - node drive.cjs shots # rest, mid pull-back and pulled-back screenshots - node gradAxis.cjs # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis - node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels - node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) - node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms - node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs - node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` - -Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a -host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a -machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a -directory whose ancestors hold no empty `package.json` (esbuild reads them). - -Timings from SwiftShader are CPU time: use them for the split between passes and for before and -after of one change, never as a phone frame time. diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs deleted file mode 100644 index 31accf37..00000000 --- a/harness/scene/bundle.cjs +++ /dev/null @@ -1,49 +0,0 @@ -// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. -// usage: node bundle.cjs [demo|ridge] (esbuild from NODE_PATH). `demo` bundles the ray-march -// standalone page; `ridge` the ridge scene's page (open it with ?probe=1 for the cadence readout). -const esbuild = require('esbuild'); -const fs = require('fs'); -const path = require('path'); -const out = process.argv[2]; -const demo = process.argv[3] === 'demo'; -const ridge = process.argv[3] === 'ridge'; -fs.mkdirSync(out, { recursive: true }); -const rawPlugin = { - name: 'raw', - setup(b) { - b.onResolve({ filter: /\?raw$/ }, (a) => ({ - path: path.resolve(a.resolveDir, a.path.slice(0, -4)), - namespace: 'raw', - })); - b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({ - contents: fs.readFileSync(a.path, 'utf8'), - loader: 'text', - })); - }, -}; -esbuild - .build({ - entryPoints: [path.join(__dirname, ridge ? 'ridgeDemo.ts' : demo ? 'demo.ts' : 'entry.ts')], - bundle: true, - outfile: path.join(out, 'bundle.js'), - plugins: [rawPlugin], - format: 'iife', - target: 'es2020', - }) - .then(() => { - if (ridge) { - fs.writeFileSync( - path.join(out, 'index.html'), - 'Ratatoskr ridge scene
' - ); - return; - } - fs.writeFileSync( - path.join(out, 'index.html'), - 'Ratatoskr scene: trace prepass' + - (demo - ? '
device
' - : '') + - '' - ); - }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts deleted file mode 100644 index 40424ff5..00000000 --- a/harness/scene/demo.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and -// a switch for the cone prepass. Not shipped. -import { - createScene, - Scene, - SceneStats, - SceneUnavailable, - TraceStats, -} from '../../src/app/features/scene/Scene'; -import { LEVELS } from '../../src/app/features/scene/budget'; - -let canvas = document.getElementById('c') as HTMLCanvasElement; -const out = document.getElementById('out') as HTMLPreElement; -const box = document.getElementById('prepass') as HTMLInputElement; -const pull = document.getElementById('pull') as HTMLInputElement; -const half = document.getElementById('half') as HTMLInputElement; -const dev = document.getElementById('dev') as HTMLDetailsElement; -const info = document.getElementById('info') as HTMLPreElement; -const timerless = (() => { - const g = document.createElement('canvas').getContext('webgl2'); - return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); -})(); - -const WHY: Record = { - 'no-webgl2': 'this browser gave no WebGL2 context', - 'no-float-or-half-float-colour-buffer': - 'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is', -}; - -// What the browser says about itself, read from a scratch context so it shows even when the scene -// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU. -function describeDevice(): string { - const c = document.createElement('canvas'); - const gl2 = c.getContext('webgl2'); - const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null); - const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`]; - if (!gl) return [...lines, 'webgl1 context: NO'].join('\n'); - const dbg = gl.getExtension('WEBGL_debug_renderer_info'); - lines.push( - `renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`, - `vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`, - `version: ${gl.getParameter(gl.VERSION)}`, - `user agent: ${navigator.userAgent}` - ); - const ext = gl.getSupportedExtensions() ?? []; - for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2']) - lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`); - lines.push(`extensions (${ext.length}): ${ext.join(' ')}`); - return lines.join('\n'); -} -info.textContent = describeDevice(); - -let scene: Scene | null = null; -let stats: SceneStats | undefined; -let trace: TraceStats | undefined; - -function start() { - scene?.dispose(); - stats = trace = undefined; - // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. - const fresh = canvas.cloneNode() as HTMLCanvasElement; - canvas.replaceWith(fresh); - canvas = fresh; - scene = createScene({ - canvas: fresh, - unthrottled: true, - fixedLevel: LEVELS[0], - fixedPull: +pull.value, - prepass: box.checked, - halfStart: half.checked, - onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`), - onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`), - onStats: (s) => (stats = s), - onTraceStats: (t) => (trace = t), - }); - dev.open = !scene; -} - -setInterval(() => { - if (!scene) return; - const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`]; - if (stats) { - const ms = stats.passMs; - const total = Object.values(ms).reduce((a, b) => a + b, 0); - lines.push( - `gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`, - `solid ${stats.solidSize.join('x')}` - ); - } else if (timerless) { - lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2'); - } - if (trace) - lines.push( - `steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)` - ); - out.textContent = lines.join('\n'); -}, 500); - -box.addEventListener('change', start); -half.addEventListener('change', start); -pull.addEventListener('input', start); -start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs deleted file mode 100644 index 0f34191f..00000000 --- a/harness/scene/drive.cjs +++ /dev/null @@ -1,309 +0,0 @@ -// Drives the scene in headless Chromium (software GL unless the host has a GPU). -// usage: node drive.cjs [outdir] modes: compile | shots | measure -// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. -const { chromium } = require('playwright'); -const path = require('path'); -const fs = require('fs'); -const [, , dir, mode, outdir = '.'] = process.argv; - -(async () => { - const b = await chromium.launch({ - args: [ - '--use-gl=angle', - '--use-angle=swiftshader', - '--enable-unsafe-swiftshader', - '--ignore-gpu-blocklist', - ], - }); - const page = await b.newPage({ viewport: { width: 960, height: 540 } }); - page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); - page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); - await page.goto('file://' + path.resolve(dir, 'index.html')); - const report = { mode }; - report.renderer = await page.evaluate(() => { - const g = document.createElement('canvas').getContext('webgl2'); - const e = g.getExtension('WEBGL_debug_renderer_info'); - return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); - }); - - if (mode === 'compile') { - report.stages = await page.evaluate(() => { - const gl = document.createElement('canvas').getContext('webgl2'); - const S = window.__scene; - return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { - const t0 = performance.now(); - try { - const p = S.linkStage(gl, stage); - return { - stage, - ok: true, - ms: +(performance.now() - t0).toFixed(1), - sourceBytes: S.fragmentSource(stage).length, - log: gl.getProgramInfoLog(p), - }; - } catch (e) { - return { - stage, - ok: false, - ms: +(performance.now() - t0).toFixed(1), - log: String(e.log || e), - }; - } - }); - }); - } - - if (mode === 'phacelle') { - report.probe = await page.evaluate(() => - window.__probePhacelle( - window.__scene.WORLD.erodeSide.slice(), - window.__scene.WORLD.erodeNorm, - 4 - ) - ); - } - - if (mode === 'shots') { - await page.evaluate(() => { - const c = document.getElementById('c'); - window.__s = window.__scene.createScene({ - canvas: c, - unthrottled: true, - fixedLevel: window.__scene.LEVELS[0], - }); - }); - const wait = (ms) => page.waitForTimeout(ms); - await wait(4000); - await page.screenshot({ path: path.join(outdir, 'rest.png') }); - await page.evaluate(() => window.__s.login()); - await wait(3500); - await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); - await wait(9000); - await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); - report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; - } - - if (mode === 'measure' || mode === 'variants') { - const VARIANTS = { - baseline: null, - // What a public value-only fbm would cost: the same octaves without the gradient work. - valueOnlyFbm: [ - 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', - 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', - ], - noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], - // Upper bound on what skipping sky rays would save: every upward ray misses at once. - noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], - noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], - }; - const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; - const sizes = [ - [960, 540], - [540, 1200], - ]; - report.runs = []; - for (const [w, h] of sizes) { - for (const [li, variant] of wanted.flatMap((v) => - mode === 'variants' - ? [[0, v]] - : [ - [0, v], - [3, v], - ] - )) { - await page.setViewportSize({ width: w, height: h }); - const r = await page.evaluate( - ({ li, rep }) => - new Promise((res) => { - const rows = []; - const c = document.getElementById('c'); - const s = window.__scene.createScene({ - canvas: c, - unthrottled: true, - fixedLevel: window.__scene.LEVELS[li], - patchSource: rep - ? (st, src) => - st === 'terrain' - ? rep[0].startsWith('#define') - ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) - : src.split(rep[0]).join(rep[1]) - : src - : undefined, - onStats: (st) => { - rows.push(st); - if (rows.length === 40) { - s.dispose(); - const keep = rows.slice(10); - const med = (k) => - keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; - res({ - solid: keep[0].solidSize, - atm: keep[0].atmosphereSize, - medianMs: { - terrain: med('terrain'), - atmosphere: med('atmosphere'), - resolve: med('resolve'), - composite: med('composite'), - }, - }); - } - }, - }); - if (!s) res({ error: 'createScene returned null' }); - }), - { li, rep: VARIANTS[variant] } - ); - report.runs.push({ viewport: [w, h], level: li, variant, ...r }); - } - } - } - - if (mode === 'trace') { - // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. - const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); - report.runs = []; - for (const [w, h] of process.env.PORTRAIT === '0' - ? [[960, 540]] - : [ - [960, 540], - [540, 1200], - ]) { - for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { - for (const prepass of arms) { - await page.setViewportSize({ width: w, height: h }); - const r = await page.evaluate( - ({ pull, prepass, FRAMES }) => - new Promise((res) => { - const rows = []; - const trace = []; - let done = false; - const finish = () => { - if (done || rows.length < FRAMES || trace.length < 4) return; - done = true; - s.dispose(); - const keep = rows.slice(FRAMES >> 2); - const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; - const tk = trace.slice(1); - const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; - res({ - solid: keep[0].solidSize, - readbacks: tk.length, - traceSteps: +mean('traceSteps').toFixed(2), - prepassSteps: +mean('prepassSteps').toFixed(2), - stepsPerPixel: +mean('stepsPerPixel').toFixed(2), - medianMs: Object.fromEntries( - Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) - ), - }); - }; - const s = window.__scene.createScene({ - canvas: document.getElementById('c'), - unthrottled: true, - fixedLevel: window.__scene.LEVELS[0], - fixedPull: pull, - prepass, - onTraceStats: (t) => { - trace.push(t); - finish(); - }, - onStats: (st) => { - rows.push(st); - finish(); - }, - }); - if (!s) res({ error: 'createScene returned null' }); - }), - { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } - ); - report.runs.push({ viewport: [w, h], pull, prepass, ...r }); - } - } - } - } - - if (mode === 'diff') { - // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared - // pixel by pixel. The prepass is meant to change cost, not the picture. - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.setViewportSize({ width: 640, height: 360 }); - const shoot = async (pull, prepass, steps) => { - await page.evaluate( - ({ pull, prepass, steps }) => { - // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the - // count stops moving when it is done. Timer-query callbacks lag too far to count on. - window.__raf = 0; - if (!window.__rafPatched) { - window.__rafPatched = true; - const raf = window.requestAnimationFrame.bind(window); - window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); - } - window.__d = window.__scene.createScene({ - canvas: document.getElementById('c'), - fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, - fixedPull: pull, - prepass, - }); - }, - { pull, prepass, steps } - ); - for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { - const n = await page.evaluate(() => window.__raf); - still = n === last ? still + 1 : 0; - last = n; - } - const png = await page.screenshot(); - await page.evaluate(() => window.__d.dispose()); - return png.toString('base64'); - }; - // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared - // with a 512-step render of the same frame as well: whichever is closer to it is the more - // faithful one. - const decode = (x) => - page.evaluate( - (x) => - new Promise((r) => { - const i = new Image(); - i.onload = () => { - const c = document.createElement('canvas'); - c.width = i.width; - c.height = i.height; - const g = c.getContext('2d'); - g.drawImage(i, 0, 0); - r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); - }; - i.src = 'data:image/png;base64,' + x; - }), - x - ); - const compare = (da, db) => { - let max = 0, over = 0, sum = 0; - const px = da.length / 4; - for (let i = 0; i < da.length; i += 4) { - let m = 0; - for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); - sum += m; - if (m > max) max = m; - if (m > 2) over++; - } - return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; - }; - report.diffs = []; - for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { - const png = { - off: await shoot(pull, false, 96), - on: await shoot(pull, true, 96), - ref: await shoot(pull, false, 512), - off2: await shoot(pull, false, 96), - }; - for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); - const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; - report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); - } - } - - console.log(JSON.stringify(report, null, 1)); - await b.close(); -})().catch((e) => { - console.log('ERR', String(e).slice(0, 2000)); - process.exit(1); -}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts deleted file mode 100644 index d43a5453..00000000 --- a/harness/scene/entry.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. -import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; -import { LEVELS } from '../../src/app/features/scene/budget'; -import { fragmentSource } from '../../src/app/features/scene/shaderSource'; -import { WORLD } from '../../src/app/features/scene/world'; - -(window as unknown as Record).__scene = { - createScene, - linkStage, - ShaderCompileError, - LEVELS, - fragmentSource, - WORLD, -}; - -// ---- Phacelle gradient probe ----------------------------------------------------------------- -import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; -import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; -import { vertexSource } from '../../src/app/features/scene/shaderSource'; - -const PROBE_FRAG = `#version 300 es -precision highp float; precision highp int; -${cultmath} -${phacelle} -uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; -in vec2 v_uv; layout(location=0) out vec4 o; -void main() { - // The scene samples the z = 0 slice: random points in a u_span square of it. - vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; - vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; - CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); - o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); -}`; - -/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ -(window as unknown as Record).__probePhacelle = ( - side: number[], - norm: number, - draws: number -) => { - const c = document.createElement('canvas'); - const gl = c.getContext('webgl2')!; - gl.getExtension('EXT_color_buffer_float'); - const mk = (t: number, s: string) => { - const x = gl.createShader(t)!; - gl.shaderSource(x, s); - gl.compileShader(x); - if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); - return x; - }; - const p = gl.createProgram()!; - gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); - gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); - gl.linkProgram(p); - gl.useProgram(p); - const N = 1024; - const tex = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); - const fbo = gl.createFramebuffer(); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); - gl.viewport(0, 0, N, N); - gl.bindVertexArray(gl.createVertexArray()); - gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); - gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); - gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); - const buf = new Float32Array(N * N * 4); - let gradCos = 0, - gradSin = 0, - vMin = 1e9, - vMax = -1e9, - n = 0; - for (let d = 0; d < draws; d++) { - gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); - gl.drawArrays(gl.TRIANGLES, 0, 3); - gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); - for (let i = 0; i < buf.length; i += 4) { - if (buf[i] > gradCos) gradCos = buf[i]; - if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; - if (buf[i + 2] < vMin) vMin = buf[i + 2]; - if (buf[i + 2] > vMax) vMax = buf[i + 2]; - n++; - } - } - return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; -}; diff --git a/harness/scene/gradAxis.cjs b/harness/scene/gradAxis.cjs deleted file mode 100644 index 48b96fa6..00000000 --- a/harness/scene/gradAxis.cjs +++ /dev/null @@ -1,30 +0,0 @@ -// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value), -// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point. -// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs /src/app/features/scene/shaders -const { chromium } = require('playwright'); const fs=require('fs'); -const S=process.argv[2]; -const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8'); -const fs_=`#version 300 es -precision highp float; precision highp int; -${cm} -${ph} -out vec4 o; -const float F=1.0/520.0; -float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;} -float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;} -void main(){ - vec2 p=vec2(1234.5,-777.7); float e=0.5; - int k=int(gl_FragCoord.x); - if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z - else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);} - else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);} - else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);} -}`; -(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']}); -const pg=await b.newPage(); -const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float'); -const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x}; -const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p); -const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3); -const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500)); -console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})(); diff --git a/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts deleted file mode 100644 index 931a1ad4..00000000 --- a/harness/scene/ridgeDemo.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the -// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped. -import { startBackgroundScene } from '../../src/app/features/scene'; -import type { SceneTier } from '../../src/app/features/scene'; - -const q = new URLSearchParams(location.search); -const tier = (q.get('tier') ?? 'full') as SceneTier; -const canvas = document.getElementById('c') as HTMLCanvasElement; -startBackgroundScene({ tier }, {}, { canvas }).then((scene) => { - (window as unknown as { scene: unknown }).scene = scene; - document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)'; -}); diff --git a/index.html b/index.html index 2585704c..2eefeee2 100644 --- a/index.html +++ b/index.html @@ -4,29 +4,33 @@ - Ratatoskr - - + Cinny + + - + - - + + + - + - + - - + + diff --git a/package-lock.json b/package-lock.json index 1915ec32..dc774000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { - "name": "ratatoskr", + "name": "cinny", "version": "4.12.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ratatoskr", + "name": "cinny", "version": "4.12.8", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", - "@fontsource-variable/nunito": "5.3.0", "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", "@fontsource/montserrat": "5.2.5", @@ -86,7 +85,6 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", - "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -2194,15 +2192,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@fontsource-variable/nunito": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz", - "integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", @@ -5096,7 +5085,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", - "license": "MIT", "dependencies": { "@babel/core": "^7.20.7", "@babel/plugin-syntax-typescript": "^7.20.0", diff --git a/package.json b/package.json index 69f589c6..298fbf36 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "ratatoskr", + "name": "cinny", "version": "4.12.8", "description": "Yet another matrix client", "main": "index.js", @@ -10,7 +10,6 @@ "scripts": { "start": "vite", "build": "vite build", - "build:preview": "vite build && node scripts/preview-config.js low", "preview": "vite preview", "lint": "npm run check:eslint && npm run check:prettier", "check:eslint": "eslint src/*", @@ -20,9 +19,7 @@ "prepare": "husky install", "commit": "git-cz", "bump": "node scripts/update-version.js", - "bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts", - "test": "vitest run", - "sync:poses": "node scripts/sync-poses.mjs" + "test": "vitest run" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -40,11 +37,7 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", - "@fontsource-variable/nunito": "5.3.0", - "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", - "@fontsource/montserrat": "5.2.5", - "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -95,7 +88,10 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "@fontsource/ubuntu": "5.2.5", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ibm-plex-mono": "5.2.5" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", @@ -114,7 +110,6 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", - "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", diff --git a/preview-demo/poses/README.md b/preview-demo/poses/README.md deleted file mode 100644 index a1287f2d..00000000 --- a/preview-demo/poses/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Demo pose thumbnails - -These 24 PNGs (about 200 KB, 160 px wide, palette-quantised) are downscaled copies of GameCult's own -Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note: -the image model that made the art and the terms it was made under are not recorded. - -They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits. -The dev workflow copies them into `dist/public/poses`; real builds sync the full art with -`npm run sync:poses` and do not use this folder. - -Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose -art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken -images; nothing else depends on them. diff --git a/preview-demo/poses/eureka/amused.png b/preview-demo/poses/eureka/amused.png deleted file mode 100644 index eff70ffe..00000000 Binary files a/preview-demo/poses/eureka/amused.png and /dev/null differ diff --git a/preview-demo/poses/eureka/annoyed.png b/preview-demo/poses/eureka/annoyed.png deleted file mode 100644 index 312a66c8..00000000 Binary files a/preview-demo/poses/eureka/annoyed.png and /dev/null differ diff --git a/preview-demo/poses/eureka/concerned.png b/preview-demo/poses/eureka/concerned.png deleted file mode 100644 index f3486432..00000000 Binary files a/preview-demo/poses/eureka/concerned.png and /dev/null differ diff --git a/preview-demo/poses/eureka/determined.png b/preview-demo/poses/eureka/determined.png deleted file mode 100644 index 59771a80..00000000 Binary files a/preview-demo/poses/eureka/determined.png and /dev/null differ diff --git a/preview-demo/poses/eureka/happy.png b/preview-demo/poses/eureka/happy.png deleted file mode 100644 index ab4a5752..00000000 Binary files a/preview-demo/poses/eureka/happy.png and /dev/null differ diff --git a/preview-demo/poses/eureka/hood-smug.png b/preview-demo/poses/eureka/hood-smug.png deleted file mode 100644 index 2545a64e..00000000 Binary files a/preview-demo/poses/eureka/hood-smug.png and /dev/null differ diff --git a/preview-demo/poses/eureka/inspect.png b/preview-demo/poses/eureka/inspect.png deleted file mode 100644 index 44786969..00000000 Binary files a/preview-demo/poses/eureka/inspect.png and /dev/null differ diff --git a/preview-demo/poses/eureka/neutral.png b/preview-demo/poses/eureka/neutral.png deleted file mode 100644 index f27fb141..00000000 Binary files a/preview-demo/poses/eureka/neutral.png and /dev/null differ diff --git a/preview-demo/poses/eureka/probe.png b/preview-demo/poses/eureka/probe.png deleted file mode 100644 index 0a9c3c7b..00000000 Binary files a/preview-demo/poses/eureka/probe.png and /dev/null differ diff --git a/preview-demo/poses/eureka/report.png b/preview-demo/poses/eureka/report.png deleted file mode 100644 index 5a8a6e5c..00000000 Binary files a/preview-demo/poses/eureka/report.png and /dev/null differ diff --git a/preview-demo/poses/eureka/surprised.png b/preview-demo/poses/eureka/surprised.png deleted file mode 100644 index 34305ef9..00000000 Binary files a/preview-demo/poses/eureka/surprised.png and /dev/null differ diff --git a/preview-demo/poses/eureka/thinking.png b/preview-demo/poses/eureka/thinking.png deleted file mode 100644 index 31a4e917..00000000 Binary files a/preview-demo/poses/eureka/thinking.png and /dev/null differ diff --git a/preview-demo/poses/nibu/amused.png b/preview-demo/poses/nibu/amused.png deleted file mode 100644 index a3e02a9d..00000000 Binary files a/preview-demo/poses/nibu/amused.png and /dev/null differ diff --git a/preview-demo/poses/nibu/annoyed.png b/preview-demo/poses/nibu/annoyed.png deleted file mode 100644 index e962e628..00000000 Binary files a/preview-demo/poses/nibu/annoyed.png and /dev/null differ diff --git a/preview-demo/poses/nibu/console.png b/preview-demo/poses/nibu/console.png deleted file mode 100644 index 20a7fef4..00000000 Binary files a/preview-demo/poses/nibu/console.png and /dev/null differ diff --git a/preview-demo/poses/nibu/glitch.png b/preview-demo/poses/nibu/glitch.png deleted file mode 100644 index 315acd52..00000000 Binary files a/preview-demo/poses/nibu/glitch.png and /dev/null differ diff --git a/preview-demo/poses/nibu/happy.png b/preview-demo/poses/nibu/happy.png deleted file mode 100644 index 4f4ed096..00000000 Binary files a/preview-demo/poses/nibu/happy.png and /dev/null differ diff --git a/preview-demo/poses/nibu/neutral.png b/preview-demo/poses/nibu/neutral.png deleted file mode 100644 index e3ada05d..00000000 Binary files a/preview-demo/poses/nibu/neutral.png and /dev/null differ diff --git a/preview-demo/poses/nibu/pout.png b/preview-demo/poses/nibu/pout.png deleted file mode 100644 index 796449b4..00000000 Binary files a/preview-demo/poses/nibu/pout.png and /dev/null differ diff --git a/preview-demo/poses/nibu/resolute.png b/preview-demo/poses/nibu/resolute.png deleted file mode 100644 index bab06adf..00000000 Binary files a/preview-demo/poses/nibu/resolute.png and /dev/null differ diff --git a/preview-demo/poses/nibu/sheepish.png b/preview-demo/poses/nibu/sheepish.png deleted file mode 100644 index f9ec933f..00000000 Binary files a/preview-demo/poses/nibu/sheepish.png and /dev/null differ diff --git a/preview-demo/poses/nibu/surprised.png b/preview-demo/poses/nibu/surprised.png deleted file mode 100644 index 4fa62657..00000000 Binary files a/preview-demo/poses/nibu/surprised.png and /dev/null differ diff --git a/preview-demo/poses/nibu/thinking.png b/preview-demo/poses/nibu/thinking.png deleted file mode 100644 index b02967ed..00000000 Binary files a/preview-demo/poses/nibu/thinking.png and /dev/null differ diff --git a/preview-demo/poses/nibu/triumph.png b/preview-demo/poses/nibu/triumph.png deleted file mode 100644 index 31994236..00000000 Binary files a/preview-demo/poses/nibu/triumph.png and /dev/null differ diff --git a/preview-demo/poses/void/amused.png b/preview-demo/poses/void/amused.png deleted file mode 100644 index a8332ed2..00000000 Binary files a/preview-demo/poses/void/amused.png and /dev/null differ diff --git a/preview-demo/poses/void/annoyed.png b/preview-demo/poses/void/annoyed.png deleted file mode 100644 index 4dc43e7a..00000000 Binary files a/preview-demo/poses/void/annoyed.png and /dev/null differ diff --git a/preview-demo/poses/void/deadpan.png b/preview-demo/poses/void/deadpan.png deleted file mode 100644 index d65dcb3e..00000000 Binary files a/preview-demo/poses/void/deadpan.png and /dev/null differ diff --git a/preview-demo/poses/void/determined.png b/preview-demo/poses/void/determined.png deleted file mode 100644 index f095d292..00000000 Binary files a/preview-demo/poses/void/determined.png and /dev/null differ diff --git a/preview-demo/poses/void/glare.png b/preview-demo/poses/void/glare.png deleted file mode 100644 index 44fcc81f..00000000 Binary files a/preview-demo/poses/void/glare.png and /dev/null differ diff --git a/preview-demo/poses/void/happy.png b/preview-demo/poses/void/happy.png deleted file mode 100644 index ca6027de..00000000 Binary files a/preview-demo/poses/void/happy.png and /dev/null differ diff --git a/preview-demo/poses/void/neutral.png b/preview-demo/poses/void/neutral.png deleted file mode 100644 index 6e2a286d..00000000 Binary files a/preview-demo/poses/void/neutral.png and /dev/null differ diff --git a/preview-demo/poses/void/point.png b/preview-demo/poses/void/point.png deleted file mode 100644 index fae15ebf..00000000 Binary files a/preview-demo/poses/void/point.png and /dev/null differ diff --git a/preview-demo/poses/void/reading.png b/preview-demo/poses/void/reading.png deleted file mode 100644 index 3fff2cd8..00000000 Binary files a/preview-demo/poses/void/reading.png and /dev/null differ diff --git a/preview-demo/poses/void/shrug.png b/preview-demo/poses/void/shrug.png deleted file mode 100644 index aa4aee5d..00000000 Binary files a/preview-demo/poses/void/shrug.png and /dev/null differ diff --git a/preview-demo/poses/void/surprised.png b/preview-demo/poses/void/surprised.png deleted file mode 100644 index 73816f29..00000000 Binary files a/preview-demo/poses/void/surprised.png and /dev/null differ diff --git a/preview-demo/poses/void/thinking.png b/preview-demo/poses/void/thinking.png deleted file mode 100644 index ab93f2cc..00000000 Binary files a/preview-demo/poses/void/thinking.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico index bda9855c..91671306 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/manifest.json b/public/manifest.json index ef0a3510..acdcd004 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,14 +1,14 @@ { - "name": "Ratatoskr", - "short_name": "Ratatoskr", - "description": "GameCult's Matrix client", + "name": "Cinny", + "short_name": "Cinny", + "description": "Yet another matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", "orientation": "portrait", "start_url": "./", - "background_color": "#07111a", - "theme_color": "#07111a", + "background_color": "#fff", + "theme_color": "#fff", "icons": [ { "src": "./public/android/android-chrome-36x36.png", diff --git a/public/poses/.gitkeep b/public/poses/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index 2dfde532..8c8c2668 100644 Binary files a/public/res/android/android-chrome-144x144.png and b/public/res/android/android-chrome-144x144.png differ diff --git a/public/res/android/android-chrome-192x192.png b/public/res/android/android-chrome-192x192.png index d696f20f..6d53edb5 100644 Binary files a/public/res/android/android-chrome-192x192.png and b/public/res/android/android-chrome-192x192.png differ diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png index 07e5cbde..57378b41 100644 Binary files a/public/res/android/android-chrome-256x256.png and b/public/res/android/android-chrome-256x256.png differ diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png index 3e1babf7..507c4439 100644 Binary files a/public/res/android/android-chrome-36x36.png and b/public/res/android/android-chrome-36x36.png differ diff --git a/public/res/android/android-chrome-384x384.png b/public/res/android/android-chrome-384x384.png index 61e2afe4..92fd32c7 100644 Binary files a/public/res/android/android-chrome-384x384.png and b/public/res/android/android-chrome-384x384.png differ diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png index a5ba15e6..489cfd32 100644 Binary files a/public/res/android/android-chrome-48x48.png and b/public/res/android/android-chrome-48x48.png differ diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png index e979125e..d312b532 100644 Binary files a/public/res/android/android-chrome-512x512.png and b/public/res/android/android-chrome-512x512.png differ diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png index 7aa6caab..8e04930c 100644 Binary files a/public/res/android/android-chrome-72x72.png and b/public/res/android/android-chrome-72x72.png differ diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png index 5ab44fe9..44981d96 100644 Binary files a/public/res/android/android-chrome-96x96.png and b/public/res/android/android-chrome-96x96.png differ diff --git a/public/res/apple/apple-touch-icon-114x114.png b/public/res/apple/apple-touch-icon-114x114.png index 3a08841c..418f8833 100644 Binary files a/public/res/apple/apple-touch-icon-114x114.png and b/public/res/apple/apple-touch-icon-114x114.png differ diff --git a/public/res/apple/apple-touch-icon-120x120.png b/public/res/apple/apple-touch-icon-120x120.png index 8443c98f..1d570eba 100644 Binary files a/public/res/apple/apple-touch-icon-120x120.png and b/public/res/apple/apple-touch-icon-120x120.png differ diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png index 2dfde532..b6a731eb 100644 Binary files a/public/res/apple/apple-touch-icon-144x144.png and b/public/res/apple/apple-touch-icon-144x144.png differ diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png index 8ab7ad50..968ae2f0 100644 Binary files a/public/res/apple/apple-touch-icon-152x152.png and b/public/res/apple/apple-touch-icon-152x152.png differ diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png index 963c024d..a41d0d10 100644 Binary files a/public/res/apple/apple-touch-icon-167x167.png and b/public/res/apple/apple-touch-icon-167x167.png differ diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png index 631741a0..a651cd1c 100644 Binary files a/public/res/apple/apple-touch-icon-180x180.png and b/public/res/apple/apple-touch-icon-180x180.png differ diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png index 5cad75b2..f5ff2ea5 100644 Binary files a/public/res/apple/apple-touch-icon-57x57.png and b/public/res/apple/apple-touch-icon-57x57.png differ diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png index 6303a2eb..47fafb07 100644 Binary files a/public/res/apple/apple-touch-icon-60x60.png and b/public/res/apple/apple-touch-icon-60x60.png differ diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png index 7aa6caab..6e5b13c4 100644 Binary files a/public/res/apple/apple-touch-icon-72x72.png and b/public/res/apple/apple-touch-icon-72x72.png differ diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png index 1448098d..7799a6fd 100644 Binary files a/public/res/apple/apple-touch-icon-76x76.png and b/public/res/apple/apple-touch-icon-76x76.png differ diff --git a/public/res/svg/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg new file mode 100644 index 00000000..d59b42a1 --- /dev/null +++ b/public/res/svg/cinny-highlight.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg new file mode 100644 index 00000000..be301375 --- /dev/null +++ b/public/res/svg/cinny-unread.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg new file mode 100644 index 00000000..8701d67b --- /dev/null +++ b/public/res/svg/cinny.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/res/svg/ratatoskr-favicon-highlight.svg b/public/res/svg/ratatoskr-favicon-highlight.svg deleted file mode 100644 index f9a95e82..00000000 --- a/public/res/svg/ratatoskr-favicon-highlight.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/res/svg/ratatoskr-favicon-unread.svg b/public/res/svg/ratatoskr-favicon-unread.svg deleted file mode 100644 index f5bcb7d8..00000000 --- a/public/res/svg/ratatoskr-favicon-unread.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/res/svg/ratatoskr-favicon.svg b/public/res/svg/ratatoskr-favicon.svg deleted file mode 100644 index 0c7cc393..00000000 --- a/public/res/svg/ratatoskr-favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg deleted file mode 100644 index 1092ffc4..00000000 --- a/public/res/svg/ratatoskr.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs deleted file mode 100644 index 827110c2..00000000 --- a/scripts/bake-sky.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the -// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`. -// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment. -import { writeFileSync } from 'node:fs'; -import { bakeSky } from '../src/sky/skyTokens.js'; -import { bakeScatter } from '../src/sky/scatter.js'; - -const out = new URL('../src/sky/baked.ts', import.meta.url); -const baked = { - midnight: bakeSky('midnight'), - noonday: bakeSky('noonday'), - scatter: bakeScatter(), -}; -writeFileSync( - out, - `// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` + - `/* eslint-disable */\n` + - `export const baked = ${JSON.stringify(baked, null, 2)} as const;\n` -); diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs deleted file mode 100644 index fa44bfde..00000000 --- a/scripts/gen-brand.mjs +++ /dev/null @@ -1,202 +0,0 @@ -// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the -// favicon and app-icon PNGs from the same shapes (no image tools needed). -// Run: node scripts/gen-brand.mjs -import { deflateSync } from 'node:zlib'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const BG = '#1c1b22'; - -// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones. -const squirrel = [ - { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' }, - { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' }, - { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' }, - { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' }, - { - t: 'poly', - pts: [ - [35, 19], - [36, 7], - [43, 15], - ], - fill: '#c9672a', - }, - { - t: 'poly', - pts: [ - [44, 15], - [50, 7], - [52, 19], - ], - fill: '#c9672a', - }, - { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' }, - { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' }, - { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' }, - { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' }, - { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' }, - { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' }, - { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' }, -]; -const badge = (fill) => [ - { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' }, - { t: 'circle', cx: 52, cy: 52, r: 8, fill }, -]; -const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }]; - -const f = (n) => +n.toFixed(2); -const svgOf = (shapes) => - shapes - .map((s) => { - if (s.t === 'circle') - return ``; - if (s.t === 'rect') return ``; - if (s.t === 'poly') - return ``; - return ``; - }) - .join('\n'); -const writeSvg = (name, shapes) => - writeFileSync( - join(root, 'public/res/svg', name), - `\n${svgOf( - shapes - )}\n\n` - ); - -const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); -const inside = (s, x, y) => { - if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h; - if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2; - if (s.t === 'ellipse') { - const a = (-s.rot * Math.PI) / 180; - const dx = x - s.cx; - const dy = y - s.cy; - const u = dx * Math.cos(a) - dy * Math.sin(a); - const v = dx * Math.sin(a) + dy * Math.cos(a); - return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1; - } - let c = false; - for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) { - const [xi, yi] = s.pts[i]; - const [xj, yj] = s.pts[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; - } - return c; -}; - -const crcTable = Array.from({ length: 256 }, (_, n) => { - let c = n; - for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - return c >>> 0; -}); -const crc = (b) => { - let c = 0xffffffff; - for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); - return (c ^ 0xffffffff) >>> 0; -}; -const chunk = (type, data) => { - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - out.write(type, 4, 'ascii'); - data.copy(out, 8); - out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); - return out; -}; - -// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre. -const png = (shapes, size, scale = 1) => { - const rgba = Buffer.alloc(size * size * 4); - const SS = 4; - const colours = shapes.map((s) => hex(s.fill)); - for (let py = 0; py < size; py += 1) { - for (let px = 0; px < size; px += 1) { - let r = 0; - let g = 0; - let b = 0; - let a = 0; - for (let sy = 0; sy < SS; sy += 1) { - for (let sx = 0; sx < SS; sx += 1) { - const gx = ((px + (sx + 0.5) / SS) / size) * 64; - const gy = ((py + (sy + 0.5) / SS) / size) * 64; - // the background rect covers the whole canvas and ignores `scale` - let hit = -1; - shapes.forEach((s, i) => { - const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32; - const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32; - if (inside(s, x, y)) hit = i; - }); - if (hit >= 0) { - [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]]; - a += 1; - } - } - } - const o = (py * size + px) * 4; - if (a > 0) { - rgba[o] = Math.round(r / a); - rgba[o + 1] = Math.round(g / a); - rgba[o + 2] = Math.round(b / a); - } - rgba[o + 3] = Math.round((a / (SS * SS)) * 255); - } - } - const raw = Buffer.alloc(size * (size * 4 + 1)); - for (let y = 0; y < size; y += 1) { - raw[y * (size * 4 + 1)] = 0; - rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); - } - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(size, 0); - ihdr.writeUInt32BE(size, 4); - ihdr.set([8, 6, 0, 0, 0], 8); - return Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]); -}; - -// ICO container holding PNG images. -const ico = (images) => { - const head = Buffer.alloc(6 + images.length * 16); - head.writeUInt16LE(1, 2); - head.writeUInt16LE(images.length, 4); - let offset = head.length; - images.forEach(({ size, data }, i) => { - const e = 6 + i * 16; - head[e] = size; - head[e + 1] = size; - head.writeUInt16LE(1, e + 4); - head.writeUInt16LE(32, e + 6); - head.writeUInt32LE(data.length, e + 8); - head.writeUInt32LE(offset, e + 12); - offset += data.length; - }); - return Buffer.concat([head, ...images.map((i) => i.data)]); -}; - -writeSvg('ratatoskr.svg', squirrel); -writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]); -writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]); - -const icon = [...background, ...squirrel]; -const put = (dir, name, size) => { - mkdirSync(join(root, 'public/res', dir), { recursive: true }); - writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82)); -}; -[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) => - put('android', `android-chrome-${s}x${s}.png`, s) -); -[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) => - put('apple', `apple-touch-icon-${s}x${s}.png`, s) -); -writeFileSync( - join(root, 'public/favicon.ico'), - ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) }))) -); -console.log('wrote svg logos, favicon.ico, android and apple icons'); diff --git a/scripts/make-pose-thumbs.mjs b/scripts/make-pose-thumbs.mjs deleted file mode 100644 index f78fc19e..00000000 --- a/scripts/make-pose-thumbs.mjs +++ /dev/null @@ -1,203 +0,0 @@ -// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the -// portrait's displayed width x a phone's device pixel ratio (not for the 124px desktop box). -// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a -// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src] -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { inflateSync, deflateSync, crc32 } from 'node:zlib'; - -const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled -const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const outDir = join(root, 'preview-demo/poses'); -const demoMinds = ['eureka', 'void', 'nibu']; - -function decode(buf) { - let off = 8; - let w, h; - const idat = []; - while (off < buf.length) { - const len = buf.readUInt32BE(off); - const type = buf.toString('latin1', off + 4, off + 8); - const data = buf.subarray(off + 8, off + 8 + len); - if (type === 'IHDR') { - w = data.readUInt32BE(0); - h = data.readUInt32BE(4); - if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) - throw new Error('need 8-bit RGBA, non-interlaced'); - } else if (type === 'IDAT') idat.push(data); - off += 12 + len; - } - const raw = inflateSync(Buffer.concat(idat)); - const stride = w * 4; - const px = Buffer.alloc(h * stride); - for (let y = 0; y < h; y++) { - const f = raw[y * (stride + 1)]; - const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); - for (let x = 0; x < stride; x++) { - const a = x >= 4 ? px[y * stride + x - 4] : 0; - const b = y > 0 ? px[(y - 1) * stride + x] : 0; - const c = x >= 4 && y > 0 ? px[(y - 1) * stride + x - 4] : 0; - let p = 0; - if (f === 1) p = a; - else if (f === 2) p = b; - else if (f === 3) p = (a + b) >> 1; - else if (f === 4) { - const pa = Math.abs(b - c), - pb = Math.abs(a - c), - pc = Math.abs(a + b - 2 * c); - p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; - } - px[y * stride + x] = (line[x] + p) & 255; - } - } - return { w, h, px }; -} - -// Separable area average over premultiplied RGBA floats. -function resize({ w, h, px }, nw, nh) { - const pre = new Float32Array(w * h * 4); - for (let i = 0; i < w * h; i++) { - const a = px[i * 4 + 3] / 255; - for (let c = 0; c < 3; c++) pre[i * 4 + c] = px[i * 4 + c] * a; - pre[i * 4 + 3] = px[i * 4 + 3]; - } - const pass = (data, sw, sh, dw, dh, horiz) => { - const out = new Float32Array((horiz ? dw * sh : sw * dh) * 4); - const n = horiz ? dw : dh; - const sn = horiz ? sw : sh; - const scale = sn / n; - for (let o = 0; o < n; o++) { - const s0 = o * scale, - s1 = (o + 1) * scale; - for (let i = Math.floor(s0); i < Math.min(sn, Math.ceil(s1)); i++) { - const wt = Math.min(i + 1, s1) - Math.max(i, s0); - const lines = horiz ? sh : sw; - for (let l = 0; l < lines; l++) { - const si = (horiz ? l * sw + i : i * sw + l) * 4; - const di = (horiz ? l * dw + o : o * dw + l) * 4; - for (let c = 0; c < 4; c++) out[di + c] += (data[si + c] * wt) / scale; - } - } - } - return out; - }; - const mid = pass(pre, w, h, nw, h, true); - const fin = pass(mid, nw, h, nw, nh, false); - const out = Buffer.alloc(nw * nh * 4); - for (let i = 0; i < nw * nh; i++) { - const a = fin[i * 4 + 3]; - out[i * 4 + 3] = Math.round(a); - for (let c = 0; c < 3; c++) - out[i * 4 + c] = a > 0 ? Math.min(255, Math.round((fin[i * 4 + c] * 255) / a)) : 0; - } - return out; -} - -// Median cut to 256 colours over (r,g,b,a); alpha is a quantised channel too. -function quantise(px, n) { - const key = (i) => [px[i * 4], px[i * 4 + 1], px[i * 4 + 2], px[i * 4 + 3]]; - const hist = new Map(); - for (let i = 0; i < n; i++) { - const q = key(i).map((v) => v & 0xf8); - const k = (q[0] << 24) | (q[1] << 16) | (q[2] << 8) | q[3]; - const e = hist.get(k) ?? { c: q, n: 0 }; - e.n++; - hist.set(k, e); - } - let boxes = [[...hist.values()]]; - while (boxes.length < 256) { - let bi = -1, - best = 0, - ch = 0; - boxes.forEach((b, i) => { - if (b.length < 2) return; - for (let c = 0; c < 4; c++) { - const vs = b.map((e) => e.c[c]); - const r = (Math.max(...vs) - Math.min(...vs)) * Math.sqrt(b.reduce((s, e) => s + e.n, 0)); - if (r > best) { - best = r; - bi = i; - ch = c; - } - } - }); - if (bi < 0) break; - const b = boxes[bi].sort((x, y) => x.c[ch] - y.c[ch]); - const half = b.reduce((s, e) => s + e.n, 0) / 2; - let acc = 0, - cut = 1; - for (; cut < b.length - 1 && acc + b[cut - 1].n < half; cut++) acc += b[cut - 1].n; - boxes.splice(bi, 1, b.slice(0, cut), b.slice(cut)); - } - const pal = boxes.map((b) => { - const t = b.reduce((s, e) => s + e.n, 0); - return [0, 1, 2, 3].map((c) => Math.round(b.reduce((s, e) => s + e.c[c] * e.n, 0) / t)); - }); - const idx = new Uint8Array(n); - const cache = new Map(); - for (let i = 0; i < n; i++) { - const k = (px[i * 4] << 24) | (px[i * 4 + 1] << 16) | (px[i * 4 + 2] << 8) | px[i * 4 + 3]; - let j = cache.get(k); - if (j === undefined) { - let bd = Infinity; - pal.forEach((p, pi) => { - const d = [0, 1, 2, 3].reduce((s, c) => s + (p[c] - px[i * 4 + c]) ** 2, 0); - if (d < bd) { - bd = d; - j = pi; - } - }); - cache.set(k, j); - } - idx[i] = j; - } - return { pal, idx }; -} - -function chunk(type, data) { - const t = Buffer.from(type, 'latin1'); - const out = Buffer.alloc(12 + data.length); - out.writeUInt32BE(data.length, 0); - t.copy(out, 4); - data.copy(out, 8); - out.writeUInt32BE(crc32(Buffer.concat([t, data])) >>> 0, 8 + data.length); - return out; -} - -function encode(w, h, { pal, idx }) { - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(w, 0); - ihdr.writeUInt32BE(h, 4); - ihdr[8] = 8; - ihdr[9] = 3; - const raw = Buffer.alloc(h * (w + 1)); - for (let y = 0; y < h; y++) - Buffer.from(idx.subarray(y * w, (y + 1) * w)).copy(raw, y * (w + 1) + 1); - return Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - chunk('IHDR', ihdr), - chunk('PLTE', Buffer.from(pal.flatMap((p) => p.slice(0, 3)))), - chunk('tRNS', Buffer.from(pal.map((p) => p[3]))), - chunk('IDAT', deflateSync(raw, { level: 9 })), - chunk('IEND', Buffer.alloc(0)), - ]); -} - -const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); -let total = 0; -for (const mind of demoMinds) { - const m = catalogue.minds[mind]; - for (const f of [...Object.values(m.shared), ...Object.values(m.signatures)]) { - const img = decode(readFileSync(join(src, f))); - const nw = Math.min(WIDTH, img.w); - const nh = Math.round((img.h * nw) / img.w); - const png = encode(nw, nh, quantise(resize(img, nw, nh), nw * nh)); - mkdirSync(dirname(join(outDir, f)), { recursive: true }); - writeFileSync(join(outDir, f), png); - total += png.length; - console.log(f, `${nw}x${nh}`, png.length); - } -} -console.log('total', total); diff --git a/scripts/preview-config.js b/scripts/preview-config.js deleted file mode 100644 index dbd50049..00000000 --- a/scripts/preview-config.js +++ /dev/null @@ -1,33 +0,0 @@ -// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch -// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it -// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend -// server as the only homeserver. Usage: -// node scripts/preview-config.js [tier] [style] [--demo] -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const args = process.argv.slice(2); -const demo = args.includes('--demo'); -const [tier = 'low', style] = args.filter((a) => !a.startsWith('--')); -const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); -const config = JSON.parse(fs.readFileSync(file, 'utf8')); -config.hashRouter = { enabled: true, basename: '/' }; -config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; -if (demo) { - config.previewDemo = true; - config.defaultHomeserver = 0; - config.homeserverList = ['demo.invalid']; - config.allowCustomHomeservers = false; - config.featuredCommunities = { - openAsDefault: false, - spaces: ['#gamecult:demo.invalid'], - rooms: [], - servers: ['demo.invalid'], - }; -} -fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); -console.log( - `${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}` -); diff --git a/scripts/sync-poses.mjs b/scripts/sync-poses.mjs deleted file mode 100644 index 9a5b1f83..00000000 --- a/scripts/sync-poses.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// Copies the pose catalogue and the art it names from the cast-art directory. -// The catalogue is the only list of poses the client knows; the art is not committed. -import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); - -copyFileSync( - join(src, 'catalogue.json'), - join(root, 'src/app/components/message/pose/catalogue.json') -); -const files = Object.values(catalogue.minds).flatMap((m) => [ - ...Object.values(m.shared), - ...Object.values(m.signatures), -]); -files.forEach((f) => { - const dest = join(root, 'public/poses', f); - mkdirSync(dirname(dest), { recursive: true }); - copyFileSync(join(src, f), dest); -}); -console.log(`synced catalogue and ${files.length} images from ${src}`); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index b7e6d2fe..bd848dd5 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -23,9 +23,6 @@ import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; -import { useSetting } from '../../state/hooks/settings'; -import { settingsAtom } from '../../state/settings'; -import { composerSpellcheckAttributes } from './spellcheck'; const initialValue: CustomElement[] = [ { @@ -93,7 +90,6 @@ export const CustomEditor = forwardRef( }, ref ) => { - const [composerSpellcheck] = useSetting(settingsAtom, 'composerSpellcheck'); const renderElement = useCallback( (props: RenderElementProps) => , [] @@ -150,7 +146,6 @@ export const CustomEditor = forwardRef( onKeyDown={handleKeydown} onKeyUp={onKeyUp} onPaste={onPaste} - {...composerSpellcheckAttributes(composerSpellcheck)} /> {after && ( diff --git a/src/app/components/editor/spellcheck.test.ts b/src/app/components/editor/spellcheck.test.ts deleted file mode 100644 index fa0535cb..00000000 --- a/src/app/components/editor/spellcheck.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { composerSpellcheckAttributes, defaultComposerSpellcheck } from './spellcheck'; - -const media = (coarse: boolean) => (query: string) => ({ - matches: query === '(pointer: coarse)' && coarse, -}); - -describe('composer spellcheck default', () => { - it('is off when the primary pointer is coarse (phone)', () => { - expect(defaultComposerSpellcheck(media(true))).toBe(false); - }); - it('is on for a fine pointer (desktop)', () => { - expect(defaultComposerSpellcheck(media(false))).toBe(true); - }); - it('is on when matchMedia is unavailable', () => { - expect(defaultComposerSpellcheck(undefined)).toBe(true); - }); -}); - -describe('composer spellcheck attributes', () => { - it('off disables both spellcheck and autocorrect', () => { - expect(composerSpellcheckAttributes(false)).toEqual({ spellCheck: false, autoCorrect: 'off' }); - }); - it('on enables spellcheck and leaves autocorrect to the platform', () => { - expect(composerSpellcheckAttributes(true)).toEqual({ spellCheck: true }); - }); -}); diff --git a/src/app/components/editor/spellcheck.ts b/src/app/components/editor/spellcheck.ts deleted file mode 100644 index 825676ef..00000000 --- a/src/app/components/editor/spellcheck.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Touch devices (phones) get the keyboard's own dictionary; desktop gets the browser's. */ -export const defaultComposerSpellcheck = ( - matchMedia: ((query: string) => { matches: boolean }) | undefined -): boolean => !matchMedia?.('(pointer: coarse)').matches; - -/** - * Attributes for the composer's editable. Off must also switch autocorrect off: - * spellCheck alone leaves Gboard's suggestion strip and correction modal in place. - */ -export const composerSpellcheckAttributes = (enabled: boolean) => - enabled ? ({ spellCheck: true } as const) : ({ spellCheck: false, autoCorrect: 'off' } as const); diff --git a/src/app/components/logo/logoMark.css.ts b/src/app/components/logo/logoMark.css.ts deleted file mode 100644 index 4c967088..00000000 --- a/src/app/components/logo/logoMark.css.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { globalStyle, style } from '@vanilla-extract/css'; - -/** - * The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class - * of this name darkens it to the dark ink; put it on every that shows the mark on a themed surface. The favicon and - * notification icons are files the browser draws and cannot take it. - */ -export const LogoMark = style({}); -globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' }); diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 066a827f..93ff84bc 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -28,80 +28,36 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) { type BubbleLayoutProps = { hideBubble?: boolean; - /** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */ - own?: boolean; - /** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */ - pose?: ReactNode; - /** On phones the portrait floats on the other side. */ - alt?: boolean; - /** Delay in ms before the first-appearance animation; undefined plays none. */ - enterDelay?: number; before?: ReactNode; header?: ReactNode; }; export const BubbleLayout = as<'div', BubbleLayoutProps>( - ({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => { - // `false` is a Mind's message continuing a run: aligned with the portraits, showing none. - const poseRow = pose !== undefined; - const arrow = !!before && !poseRow && !own; - const entering = enterDelay !== undefined; - return ( - - {!poseRow && !own && ( - - {before} + ({ hideBubble, before, header, children, ...props }, ref) => ( + + + {before} + + + {header} + {hideBubble ? ( + children + ) : ( + + + {before ? : null} + {children} + )} - - {header && ( -
- {header} -
- )} - {hideBubble ? ( - children - ) : ( - - - {arrow ? : null} - {pose} - {children} - - - )} -
- ); - } +
+ ) ); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a724241c..cc2cd0c6 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,4 +1,4 @@ -import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; +import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; @@ -143,7 +143,6 @@ export const BubbleContent = style({ padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, - border: `1px solid ${color.SurfaceVariant.ContainerLine}`, borderRadius: config.radii.R500, position: 'relative', }); @@ -152,194 +151,6 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); -// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits -// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top -// edge, and the text wraps around its silhouette (shape-outside on the alpha channel). -const PHONE = 'screen and (max-width: 700px)'; -const PHONE_PORTRAIT = 'min(38vw, 160px)'; -const PHONE_TEXT_FLOOR = toRem(170); -const PORTRAIT_OVERHANG = 58; -const PORTRAIT = toRem(124); -const POSE_GUTTER = toRem(14); -const BUBBLE_CORNER = toRem(6); -const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)'; -const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'; - -export const BubbleRowPose = style({ - position: 'relative', - paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`, - '@media': { - [PHONE]: { - paddingLeft: 0, - }, - }, -}); - -// A row that shows a portrait: room for it to hang above the bubble on phones. -export const BubbleRowPortrait = style({ - minHeight: PORTRAIT, - '@media': { - [PHONE]: { - minHeight: 0, - }, - }, -}); - -export const BubbleRowOwn = style({ - justifyContent: 'flex-end', -}); - -export const BubbleColumnOwn = style({ - alignItems: 'flex-end', - maxWidth: toRem(640), -}); - -export const BubbleHeaderOwn = style({ - alignSelf: 'flex-end', -}); - -// Phone: name and time are one plain line in flow above the bubble, on the side opposite the -// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name -// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by -// the column: a short bubble's column is narrower than its own name, and a percentage cap there -// truncated names to one letter. -export const BubbleHeaderPose = style({ - '@media': { - [PHONE]: { - alignSelf: 'flex-end', - // The band above the bubble where the portrait overhangs: the header sits at its foot, beside - // the portrait, so the overhang needs no margin of its own. - minHeight: toRem(PORTRAIT_OVERHANG), - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-end', - margin: `0 ${toRem(14)} ${toRem(2)}`, - width: 'max-content', - maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - textAlign: 'right', - }, - }, -}); - -export const BubbleHeaderPoseAlt = style({ - '@media': { - [PHONE]: { - alignSelf: 'flex-start', - textAlign: 'left', - }, - }, -}); - -// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin. -globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, { - '@media': { - [PHONE]: { - marginTop: toRem(PORTRAIT_OVERHANG), - }, - }, -}); - -export const BubbleColumnPose = style({ - position: 'relative', -}); - -export const BubbleContentPose = style({ - borderRadius: config.radii.R500, - borderTopLeftRadius: BUBBLE_CORNER, - '@media': { - [PHONE]: { - display: 'flow-root', - // A bubble with a portrait is at least portrait + a readable column wide, or the text - // beside the portrait collapses to a strip. - minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`, - borderTopLeftRadius: config.radii.R500, - padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, - }, - }, -}); - -export const BubbleContentOwn = style({ - backgroundColor: color.Primary.Container, - color: color.Primary.OnContainer, - border: `1px solid ${color.Primary.ContainerLine}`, - borderRadius: config.radii.R500, - borderTopRightRadius: BUBBLE_CORNER, -}); - -export const PoseButton = style({ - position: 'absolute', - top: 0, - left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`, - width: PORTRAIT, - height: PORTRAIT, - padding: 0, - border: 'none', - background: 'none', - cursor: 'pointer', - lineHeight: 0, - transformOrigin: '50% 90%', - '@media': { - [PHONE]: { - position: 'static', - float: 'left', - width: PHONE_PORTRAIT, - height: 'auto', - margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, - shapeMargin: toRem(7), - WebkitMaskImage: 'linear-gradient(#000 84%, transparent)', - maskImage: 'linear-gradient(#000 84%, transparent)', - }, - }, -}); - -export const PoseButtonAlt = style({ - '@media': { - [PHONE]: { - float: 'right', - margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`, - }, - }, -}); - -export const PosePortrait = style({ - width: '100%', - height: '100%', - objectFit: 'contain', - objectPosition: 'top', - '@media': { - [PHONE]: { - height: 'auto', - }, - }, -}); - -const pop = keyframes({ - from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` }, - to: { opacity: 1, transform: 'none' }, -}); -const rise = keyframes({ - from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` }, - to: { opacity: 1, transform: 'none' }, -}); - -// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay -// (a --enter-delay on the row) staggers messages that arrive together. -export const EnterPortrait = style({ - '@media': { - '(prefers-reduced-motion: no-preference)': { - animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`, - }, - }, -}); - -export const EnterBubble = style({ - '@media': { - '(prefers-reduced-motion: no-preference)': { - animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`, - }, - }, -}); - export const BubbleLeftArrow = style({ width: toRem(9), height: toRem(8), @@ -394,76 +205,3 @@ export const MessageTextBody = recipe({ }); export type MessageTextBodyVariants = RecipeVariants; - -// The message content is a flex column, which forms its own block formatting context and so sits -// whole beside the floated portrait: the text stays as narrow as the gap beside it for the -// bubble's full height. As a plain block its lines wrap around the silhouette and then run the -// bubble's full width below it. -globalStyle(`${BubbleContentPose} > div`, { - '@media': { - [PHONE]: { - display: 'block', - }, - }, -}); - -// The name wraps instead of ellipsising: the header already caps its own width, and Username's own -// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…". -globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { - '@media': { - [PHONE]: { - whiteSpace: 'normal', - overflow: 'visible', - textOverflow: 'clip', - overflowWrap: 'break-word', - }, - }, -}); - -// A name too long for one line takes its own lines and the time drops below it, instead of the -// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The -// row hugs the side the header sits on, so a dropped time stays under the name's edge. -globalStyle(`${BubbleHeaderPose} > div`, { - '@media': { - [PHONE]: { - flexWrap: 'wrap', - justifyContent: 'flex-end', - }, - }, -}); - -globalStyle(`${BubbleHeaderPoseAlt} > div`, { - '@media': { - [PHONE]: { - justifyContent: 'flex-start', - }, - }, -}); - -// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so -// beside a left portrait the number lands under the art. Inside, it is a word on the first line -// and wraps around the silhouette like the rest. -globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, { - '@media': { - [PHONE]: { - listStylePosition: 'inside', - paddingLeft: 0, - }, - }, -}); - -// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in -// the app: its own colour at half strength over a blur of the scene, so the words stay readable -// and the ridge shows through. The arrow wedge is opaque, so it goes. -const scene = ':root[data-scene]'; -const half = (c: string) => `color-mix(in srgb, ${c} 50%, transparent)`; -globalStyle(`${scene} ${BubbleContent}`, { - backgroundColor: [color.SurfaceVariant.Container, half(color.SurfaceVariant.Container)], - backdropFilter: 'blur(12px)', -}); -globalStyle(`${scene} ${BubbleContentOwn}`, { - backgroundColor: [color.Primary.Container, half(color.Primary.Container)], -}); -globalStyle(`${scene} ${BubbleLeftArrow}`, { - display: 'none', -}); diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx deleted file mode 100644 index 75a4b333..00000000 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import classNames from 'classnames'; -import * as css from '../layout/layout.css'; -import { poseSrc } from './poses'; -import { silhouette } from './silhouette'; - -type PoseAvatarProps = { - mind: string; - pose: string; - userId: string; - /** Float on the right on phones. */ - alt?: boolean; - /** Play the first-appearance pop. */ - enter?: boolean; - onClick: React.MouseEventHandler; -}; - -export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { - const src = poseSrc(mind, pose, import.meta.env.BASE_URL); - const imgRef = useRef(null); - const [shape, setShape] = useState(); - - // On phones the bubble text wraps around the portrait's silhouette. Retraced when the art - // loads and when the portrait's width changes with the viewport. - useEffect(() => { - const img = imgRef.current; - if (!img) return undefined; - const trace = () => { - if (!img.complete || img.naturalWidth === 0) return; - try { - setShape(silhouette(img, !alt)); - } catch { - setShape(undefined); - } - }; - trace(); - img.addEventListener('load', trace); - const resize = new ResizeObserver(trace); - resize.observe(img); - return () => { - img.removeEventListener('load', trace); - resize.disconnect(); - }; - }, [src, alt]); - - return ( - - ); -} diff --git a/src/app/components/message/pose/catalogue.json b/src/app/components/message/pose/catalogue.json deleted file mode 100644 index 87e7ba71..00000000 --- a/src/app/components/message/pose/catalogue.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "shared": [ - "neutral", - "happy", - "amused", - "thinking", - "annoyed", - "surprised" - ], - "minds": { - "eureka": { - "sheet": "r1b", - "shared": { - "neutral": "eureka/neutral.png", - "happy": "eureka/happy.png", - "amused": "eureka/amused.png", - "thinking": "eureka/thinking.png", - "annoyed": "eureka/annoyed.png", - "surprised": "eureka/surprised.png" - }, - "signatures": { - "probe": "eureka/probe.png", - "inspect": "eureka/inspect.png", - "report": "eureka/report.png", - "hood-smug": "eureka/hood-smug.png", - "concerned": "eureka/concerned.png", - "determined": "eureka/determined.png" - }, - "signature_descriptions": { - "probe": "Frowning, pencil raised to her chin, working a problem out.", - "inspect": "Frowning, one finger pointing at the thing to look at.", - "report": "Smirking over a folder of findings.", - "hood-smug": "Eyes shut under the hood, self-satisfied smirk.", - "concerned": "Worried frown, pencil at her chin.", - "determined": "Hard-set frown, pencil in hand, resolved." - } - }, - "aqua": { - "sheet": "r1b", - "shared": { - "neutral": "aqua/neutral.png", - "happy": "aqua/happy.png", - "amused": "aqua/amused.png", - "thinking": "aqua/thinking.png", - "annoyed": "aqua/annoyed.png", - "surprised": "aqua/surprised.png" - }, - "signatures": { - "concerned": "aqua/concerned.png", - "fretful": "aqua/fretful.png", - "weary": "aqua/weary.png", - "glare": "aqua/glare.png", - "smug": "aqua/smug.png", - "sigh": "aqua/sigh.png", - "charging": "aqua/charging.png", - "delighted": "aqua/delighted.png", - "content": "aqua/content.png" - }, - "signature_descriptions": { - "concerned": "Worried frown, brows pinched.", - "fretful": "Anxious, a fin pressed to her chin.", - "weary": "Droopy half-lidded eyes; worn out.", - "glare": "Scowling hard, a fin held out to the side.", - "smug": "Sidelong look and a small smirk.", - "sigh": "Eyes shut, breathing out.", - "charging": "Eyes shut, a charging cable plugged into her mouth; recharging.", - "delighted": "Wide open-mouthed grin, fins up.", - "content": "Calm, small contented smile." - } - }, - "heimdall": { - "sheet": "r1a", - "shared": { - "neutral": "heimdall/neutral.png", - "happy": "heimdall/happy.png", - "amused": "heimdall/amused.png", - "thinking": "heimdall/thinking.png", - "annoyed": "heimdall/annoyed.png", - "surprised": "heimdall/surprised.png" - }, - "signatures": { - "concerned": "heimdall/concerned.png", - "determined": "heimdall/determined.png", - "badge": "heimdall/badge.png", - "barrier": "heimdall/barrier.png", - "protect": "heimdall/protect.png", - "revoke": "heimdall/revoke.png" - }, - "signature_descriptions": { - "concerned": "Worried frown, brows raised.", - "determined": "Hard scowl, jaw set.", - "badge": "Stern, holding a gold key out as his credentials.", - "barrier": "Palm raised behind a translucent blue shield: stop.", - "protect": "Scowling, cloak swept forward like a guard.", - "revoke": "Stern, two keys in hand; taking access away." - } - }, - "kiko": { - "sheet": "r1b", - "shared": { - "neutral": "kiko/neutral.png", - "happy": "kiko/happy.png", - "amused": "kiko/amused.png", - "thinking": "kiko/thinking.png", - "annoyed": "kiko/annoyed.png", - "surprised": "kiko/surprised.png" - }, - "signatures": { - "shy-smile": "kiko/shy-smile.png", - "uneasy": "kiko/uneasy.png", - "tinker": "kiko/tinker.png", - "confetti": "kiko/confetti.png", - "sprint": "kiko/sprint.png", - "debug": "kiko/debug.png" - }, - "signature_descriptions": { - "shy-smile": "Soft, shy smile; blushing.", - "uneasy": "Blushing, tongue peeking out, unsure.", - "tinker": "Delighted, holding up a small gadget.", - "confetti": "Open-mouthed grin, confetti falling, a glowing glyph on one finger.", - "sprint": "Eyes shut, laughing, one arm up, mid-dash.", - "debug": "Squinting through a magnifying glass at a bug." - } - }, - "weksa": { - "sheet": "r1a", - "shared": { - "neutral": "weksa/neutral.png", - "happy": "weksa/happy.png", - "amused": "weksa/amused.png", - "thinking": "weksa/thinking.png", - "annoyed": "weksa/annoyed.png", - "surprised": "weksa/surprised.png" - }, - "signatures": { - "concerned": "weksa/concerned.png", - "skeptical": "weksa/skeptical.png", - "map": "weksa/map.png", - "fieldwork": "weksa/fieldwork.png", - "argue": "weksa/argue.png", - "interlingua": "weksa/interlingua.png" - }, - "signature_descriptions": { - "concerned": "Worried frown, wide eyes, scroll on her back.", - "skeptical": "Half-lidded, flat frown; unconvinced.", - "map": "Worried, unfurling a hand-drawn map.", - "fieldwork": "Frowning in focus, holding a carved idol and a brush.", - "argue": "Open-mouthed, pointing, holding up a tablet as evidence.", - "interlingua": "Shouting, a glyph tablet in each hand." - } - }, - "void": { - "sheet": "r1b", - "shared": { - "neutral": "void/neutral.png", - "happy": "void/happy.png", - "amused": "void/amused.png", - "thinking": "void/thinking.png", - "annoyed": "void/annoyed.png", - "surprised": "void/surprised.png" - }, - "signatures": { - "determined": "void/determined.png", - "glare": "void/glare.png", - "shrug": "void/shrug.png", - "reading": "void/reading.png", - "point": "void/point.png", - "deadpan": "void/deadpan.png" - }, - "signature_descriptions": { - "determined": "Hood bowed, narrowed eyes; set on the task.", - "glare": "Narrowed eyes under the hood; hostile stare.", - "shrug": "Both hands open, eyes wide: no idea.", - "reading": "Holding a sheet of paper, reading it.", - "point": "Half-lidded eyes, one finger pointing off to the side.", - "deadpan": "Flat lines for eyes; unimpressed." - } - }, - "libby": { - "sheet": "r1a", - "shared": { - "neutral": "libby/neutral.png", - "happy": "libby/happy.png", - "amused": "libby/amused.png", - "thinking": "libby/thinking.png", - "annoyed": "libby/annoyed.png", - "surprised": "libby/surprised.png" - }, - "signatures": { - "concerned": "libby/concerned.png", - "sad": "libby/sad.png", - "stamp": "libby/stamp.png", - "research": "libby/research.png", - "shush": "libby/shush.png", - "schema": "libby/schema.png" - }, - "signature_descriptions": { - "concerned": "Worried, a book clutched to her chest.", - "sad": "Downcast, a book clutched to her chest.", - "stamp": "Smiling, stamping a page of an open book.", - "research": "Eager over an open book, an ink bottle at hand.", - "shush": "Speaking over an open book, one finger on the page.", - "schema": "Raising a finger beside a glowing flow-diagram panel." - } - }, - "nibu": { - "sheet": "r1b", - "shared": { - "neutral": "nibu/neutral.png", - "happy": "nibu/happy.png", - "amused": "nibu/amused.png", - "thinking": "nibu/thinking.png", - "annoyed": "nibu/annoyed.png", - "surprised": "nibu/surprised.png" - }, - "signatures": { - "sheepish": "nibu/sheepish.png", - "pout": "nibu/pout.png", - "resolute": "nibu/resolute.png", - "console": "nibu/console.png", - "glitch": "nibu/glitch.png", - "triumph": "nibu/triumph.png" - }, - "signature_descriptions": { - "sheepish": "Arms crossed, awkward toothy grin.", - "pout": "Arms crossed, pouting frown.", - "resolute": "Arms crossed, scowling; standing her ground.", - "console": "Frowning at a handheld console.", - "glitch": "Frowning, palm up, a glitch tearing across her eyes.", - "triumph": "Eyes shut, fist raised; quietly pleased with a win." - } - } - }, - "shared_descriptions": { - "neutral": "At rest, no particular feeling.", - "happy": "Pleased; smiling.", - "amused": "Quietly entertained, as if at a private joke.", - "thinking": "Working something out.", - "annoyed": "Irritated; patience running thin.", - "surprised": "Caught off guard." - } -} \ No newline at end of file diff --git a/src/app/components/message/pose/entrance.test.ts b/src/app/components/message/pose/entrance.test.ts deleted file mode 100644 index 6f16a384..00000000 --- a/src/app/components/message/pose/entrance.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { claimEntrance } from './entrance'; - -describe('claimEntrance', () => { - it('animates a message once, never on a remount', () => { - expect(claimEntrance('$once', 1_000_000)).toBe(0); - expect(claimEntrance('$once', 1_000_500)).toBeUndefined(); - }); - it('staggers messages that mount together, capped so a long backlog still lands fast', () => { - const t = 2_000_000; - const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t)); - expect(delays.slice(0, 3)).toEqual([0, 300, 600]); - expect(Math.max(...(delays as number[]))).toBe(900); - }); - it('lets a lone later message in at once', () => { - claimEntrance('$x', 3_000_000); - expect(claimEntrance('$y', 3_010_000)).toBe(0); - }); -}); diff --git a/src/app/components/message/pose/entrance.ts b/src/app/components/message/pose/entrance.ts deleted file mode 100644 index 8386f80b..00000000 --- a/src/app/components/message/pose/entrance.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useState } from 'react'; - -// A message animates in the first time it is shown, not each time the virtualised timeline -// remounts it. Messages mounting together are staggered so a screenful does not pop at once. -const STEP_MS = 300; -const MAX_DELAY_MS = 900; -const SEEN_CAP = 2000; - -const seen = new Set(); -let nextSlot = 0; - -export const claimEntrance = (id: string, now: number): number | undefined => { - if (seen.has(id)) return undefined; - if (seen.size >= SEEN_CAP) seen.clear(); - seen.add(id); - const start = Math.max(nextSlot, now); - nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS); - return Math.min(start - now, MAX_DELAY_MS); -}; - -export const useEntranceDelay = (id: string | undefined): number | undefined => - useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0]; diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts deleted file mode 100644 index c6089f62..00000000 --- a/src/app/components/message/pose/poses.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import catalogue from './catalogue.json'; -import { - mindOfSender, - poseSrc, - resolvePose, -} from './poses'; - -describe('resolvePose', () => { - it('keeps shared and own-signature labels from the catalogue', () => { - expect(resolvePose('aqua', 'amused')).toBe('amused'); - expect(resolvePose('aqua', 'fretful')).toBe('fretful'); - expect(resolvePose('kiko', ' Debug ')).toBe('debug'); - expect(resolvePose('void', 'reading')).toBe('reading'); - }); - it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => { - expect(resolvePose('aqua', undefined)).toBe('neutral'); - expect(resolvePose('aqua', 'conduct')).toBe('neutral'); - expect(resolvePose('aqua', 7)).toBe('neutral'); - expect(resolvePose('aqua', 'probe')).toBe('neutral'); - expect(resolvePose('nobody', 'neutral')).toBe('neutral'); - expect(resolvePose('aqua', 'constructor')).toBe('neutral'); - }); - it('knows every label the catalogue lists, and only those', () => { - Object.entries(catalogue.minds).forEach(([mind, entry]) => { - [...Object.keys(entry.shared), ...Object.keys(entry.signatures)].forEach((l) => - expect(resolvePose(mind, l)).toBe(l) - ); - }); - }); -}); - -describe('mindOfSender', () => { - it('matches localpart or first display-name word', () => { - expect(mindOfSender('Eureka')).toBe('eureka'); - expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby'); - expect(mindOfSender('void')).toBe('void'); - }); - it('ignores humans and Object prototype names', () => { - expect(mindOfSender('alice', 'Alice')).toBeUndefined(); - expect(mindOfSender('constructor')).toBeUndefined(); - expect(mindOfSender()).toBeUndefined(); - }); -}); - -describe('poseSrc', () => { - it('builds the asset path from the catalogue entry', () => { - expect(poseSrc('eureka', 'hood-smug', '/app/')).toBe('/app/public/poses/eureka/hood-smug.png'); - expect(poseSrc('eureka', 'nope')).toBe('/public/poses/eureka/neutral.png'); - }); -}); - - - diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts deleted file mode 100644 index 69429090..00000000 --- a/src/app/components/message/pose/poses.ts +++ /dev/null @@ -1,42 +0,0 @@ -import catalogue from './catalogue.json'; - -// Pose labels travel in the event content under POSE_KEY. The catalogue -// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only -// list of Minds, labels and art paths the client knows. -export const POSE_KEY = 'org.gamecult.pose'; -export const NEUTRAL = 'neutral'; - -type MindEntry = { - shared: Record; - signatures: Record; -}; -const minds = catalogue.minds as Record; -export const MINDS = Object.keys(minds); - -// A sender is a known Mind when its Matrix localpart or the first word of its -// display name is the Mind's name, case-insensitively. -export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => { - const candidates = [localpart, displayName?.split(/\s+/)[0]]; - return candidates - .map((c) => c?.toLowerCase()) - .find((c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(minds, c)); -}; - -const artPath = (mind: string, label: string): string | undefined => { - const entry = Object.prototype.hasOwnProperty.call(minds, mind) ? minds[mind] : undefined; - if (!entry) return undefined; - const has = (o: Record) => Object.prototype.hasOwnProperty.call(o, label); - if (has(entry.shared)) return entry.shared[label]; - return has(entry.signatures) ? entry.signatures[label] : undefined; -}; - -// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral. -export const resolvePose = (mind: string, label: unknown): string => { - if (typeof label !== 'string') return NEUTRAL; - const l = label.trim().toLowerCase(); - return artPath(mind, l) ? l : NEUTRAL; -}; - -export const poseSrc = (mind: string, pose: string, base = '/'): string => - `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; - diff --git a/src/app/components/message/pose/silhouette.test.ts b/src/app/components/message/pose/silhouette.test.ts deleted file mode 100644 index 9deb3f4f..00000000 --- a/src/app/components/message/pose/silhouette.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { silhouette } from './silhouette'; - -// A w x h image whose opaque pixels are the given columns [from, to) on each row. -const fakeImage = (w: number, h: number, solid: (y: number) => [number, number] | null) => { - const data = new Uint8ClampedArray(w * h * 4); - for (let y = 0; y < h; y += 1) { - const span = solid(y); - if (span) for (let x = span[0]; x < span[1]; x += 1) data[(y * w + x) * 4 + 3] = 255; - } - const canvas = { - width: 0, - height: 0, - getContext: () => ({ canvas, drawImage: vi.fn(), getImageData: () => ({ data }) }), - }; - vi.stubGlobal('document', { createElement: () => canvas }); - return { clientWidth: w, clientHeight: h } as HTMLImageElement; -}; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('silhouette', () => { - // Art in columns 20..60 from row 8 down; rows 0..7 are empty. - const art = (y: number): [number, number] | null => (y < 8 ? null : [20, 60]); - - it('hugs the right edge of the art when the text sits to its right', () => { - const poly = silhouette(fakeImage(100, 16, art), true); - expect(poly).toBe( - 'polygon(0px 0, 0px 0px, 0px 4px, 60px 8px, 60px 12px, 60px 16px, 0px 16px) border-box' - ); - }); - - it('hugs the left edge of the art when the text sits to its left', () => { - const poly = silhouette(fakeImage(100, 16, art), false); - expect(poly).toBe( - 'polygon(100px 0, 100px 0px, 100px 4px, 20px 8px, 20px 12px, 20px 16px, 100px 16px) border-box' - ); - }); - - it('takes the widest row of a band, so no row of the art pokes out of the polygon', () => { - // Row 5 reaches x=90; its band's edge row (y=8 covers rows 4..8) must include it. - const poly = silhouette( - fakeImage(100, 16, (y) => (y === 5 ? [20, 90] : null)), - true - ); - expect(poly).toContain('90px 8px'); - }); - - it('has no shape before the image has a size or where there is no canvas', () => { - expect( - silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true) - ).toBeUndefined(); - }); -}); diff --git a/src/app/components/message/pose/silhouette.ts b/src/app/components/message/pose/silhouette.ts deleted file mode 100644 index e9596ded..00000000 --- a/src/app/components/message/pose/silhouette.ts +++ /dev/null @@ -1,34 +0,0 @@ -const SOLID = 0.35 * 255; -const ROW = 4; - -// The edge of the portrait that the text wraps against, as a polygon in border-box pixels. -// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and -// anchors it at the margin box, which the phone layout's negative top margin shifts down by the -// overhang, so the exclusion neither matches the art's size nor its place. -export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => { - const w = Math.round(img.clientWidth); - const h = Math.round(img.clientHeight); - const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null; - if (!ctx) return undefined; - ctx.canvas.width = w; - ctx.canvas.height = h; - ctx.drawImage(img, 0, 0, w, h); - const { data } = ctx.getImageData(0, 0, w, h); - const outer = textOnRight ? 0 : w; - const edge: string[] = []; - for (let y = 0; y <= h; y += ROW) { - let x = outer; - // A band's edge is the extreme solid pixel over its rows, so no row pokes out of the polygon. - for (let r = Math.max(0, y - ROW); r < Math.min(h, y + 1); r += 1) { - for (let i = 0; i < w; i += 1) { - const px = textOnRight ? w - 1 - i : i; - if (data[(r * w + px) * 4 + 3] > SOLID) { - x = textOnRight ? Math.max(x, px + 1) : Math.min(x, px); - break; - } - } - } - edge.push(`${x}px ${y}px`); - } - return `polygon(${outer}px 0, ${edge.join(', ')}, ${outer}px ${h}px) border-box`; -}; diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index e5db6c47..48b2a4d3 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,16 +1,6 @@ -import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; +import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; -import { surfaceInk } from '../../features/scene/sceneShell.css'; - -// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows -// are three distinct steps above them. -// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash. -const SCRIM_LABEL = '0%'; -const SCRIM_ROW = '0%'; -const SCRIM_HOVER = '14%'; -const SCRIM_SELECTED = '22%'; -const SCRIM_PRESSED = '48%'; export const NavCategory = style([ DefaultReset, @@ -21,18 +11,6 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, - '@media': { - // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; - // a phone's list is plain text). - 'screen and (min-width: 751px)': { - selectors: { - ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, - borderRadius: config.radii.R400, - }, - }, - }, - }, }); export const NavLink = style({ @@ -66,12 +44,6 @@ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ }, }); -// The room list is plain text on a phone: no pill, slab or border behind any row, and hover, -// selected and pressed show in the label (NavLabel), never in a box. Wider screens keep the rows -// and the wash. -const WIDE = 'screen and (min-width: 751px)'; -const PHONE = 'screen and (max-width: 750px)'; - const NavItemBase = style({ width: '100%', display: 'flex', @@ -82,45 +54,19 @@ const NavItemBase = style({ outline: 'none', minHeight: toRem(36), - '@media': { - [PHONE]: { - backgroundColor: 'transparent', - borderRadius: 0, - }, - [WIDE]: { - selectors: { - '&:hover, &:focus-visible': { - backgroundColor: ContainerHover, - }, - '&[data-hover=true]': { - backgroundColor: ContainerHover, - }, - // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint - // wash of the theme's own surface: the terrain stays visible, the text stays readable. - // Hover, the selected row and a press each strengthen it a step. - ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, - }, - ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': - { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, - }, - ':root[data-scene] &[aria-selected=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, - }, - [`:root[data-scene] &:has(.${NavLink}:active)`]: { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, - }, - [`&:has(.${NavLink}:active)`]: { - backgroundColor: ContainerActive, - }, - '&[aria-selected=true]': { - backgroundColor: ContainerActive, - }, - }, - }, - }, selectors: { + '&:hover, &:focus-visible': { + backgroundColor: ContainerHover, + }, + '&[data-hover=true]': { + backgroundColor: ContainerHover, + }, + [`&:has(.${NavLink}:active)`]: { + backgroundColor: ContainerActive, + }, + '&[aria-selected=true]': { + backgroundColor: ContainerActive, + }, [`&:has(.${NavLink}:focus-visible)`]: { outline: `${config.borderWidth.B600} solid ${ContainerLine}`, outlineOffset: `calc(-1 * ${config.borderWidth.B600})`, @@ -180,76 +126,3 @@ export const NavItemContent = style({ export const NavItemOptions = style({ paddingRight: config.space.S200, }); - -// A room's name on a phone wears the section headings' type (small caps-height, uppercase, 600, -// at heading strength) and states itself in the text: hover and focus underline it, the selected -// row is bolder and underlined, a press dims it, an unread room is at full strength. Over the scene -// a halo of the theme's own surface keeps it readable without a box. -export const NavLabel = style({}); -const label = `.${NavLabel}.${NavLabel}`; -const phoneLabel = (selector: string, rule: StyleRule) => - globalStyle(selector, { '@media': { [PHONE]: rule } }); - -phoneLabel(label, { - fontSize: toRem(12), - lineHeight: toRem(16), - fontWeight: config.fontWeight.W600, - textTransform: 'uppercase', - opacity: 0.75, - textDecorationThickness: '1px', - textUnderlineOffset: toRem(4), -}); -phoneLabel( - [ - `.${NavItemBase}:hover ${label}`, - `.${NavItemBase}:focus-visible ${label}`, - `.${NavItemBase}[data-hover=true] ${label}`, - `.${NavItemBase}[data-highlight=true] ${label}`, - ].join(', '), - { opacity: 1 } -); -phoneLabel(`.${NavItemBase}:hover ${label}, .${NavItemBase}:focus-visible ${label}`, { - textDecorationLine: 'underline', -}); -phoneLabel(`.${NavItemBase}[aria-selected=true] ${label}`, { - opacity: 1, - fontWeight: config.fontWeight.W700, - textDecorationLine: 'underline', - textDecorationThickness: '2px', -}); -phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { - opacity: 0.6, - textDecorationLine: 'underline', -}); -phoneLabel(`:root[data-scene] ${label}`, { - textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, -}); - -// Icons, dots and the Join action share the label's halo over the scene. -globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBase} button`, { - '@media': { - [PHONE]: { - filter: `drop-shadow(0 0 3px ${surfaceInk})`, - }, - }, -}); - -// The Join action on a phone is a plain underlined word, not a pill. -export const NavAction = style({ - '@media': { - [PHONE]: { - selectors: { - '&&': { - backgroundColor: 'transparent', - boxShadow: 'none', - border: 'none', - borderRadius: 0, - minWidth: 0, - textDecoration: 'underline', - textUnderlineOffset: toRem(4), - flexShrink: 0, - }, - }, - }, - }, -}); diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 9015796e..cbcd626a 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,10 +1,9 @@ import { JoinRule } from 'matrix-js-sdk'; -import { AvatarFallback, AvatarImage, Icon, Icons } from 'folds'; +import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; import { getRoomIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; -import { surfaceInk } from '../../features/scene/sceneShell.css'; type RoomAvatarProps = { roomId: string; @@ -22,7 +21,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps if (!src || error) { return ( {renderFallback()} diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 64aaaacb..34a7e24b 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,7 +31,6 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -198,10 +197,8 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(async () => { - await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); - }, [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 0c6b10df..ce550992 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,9 +1,7 @@ -import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; -import { SkyPlate } from '../../features/scene/skyPlate.css'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -19,13 +17,12 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; -import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; }; -export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => { +export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -46,13 +43,13 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) + useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); return ( - + - Ratatoskr + Cinny diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx index 65e5c790..d9de9b79 100644 --- a/src/app/components/user-avatar/UserAvatar.tsx +++ b/src/app/components/user-avatar/UserAvatar.tsx @@ -1,9 +1,8 @@ -import { AvatarFallback, AvatarImage } from 'folds'; +import { AvatarFallback, AvatarImage, color } from 'folds'; import React, { ReactEventHandler, ReactNode, useState } from 'react'; import classNames from 'classnames'; import * as css from './UserAvatar.css'; import colorMXID from '../../../util/colorMXID'; -import { surfaceInk } from '../../features/scene/sceneShell.css'; type UserAvatarProps = { className?: string; @@ -22,7 +21,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User if (!src || error) { return ( {renderFallback()} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 430219c6..1fb97650 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,7 +1,5 @@ import React, { RefObject, useRef } from 'react'; -import classNames from 'classnames'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; -import { SkyPlate } from '../scene/skyPlate.css'; import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; @@ -164,7 +162,7 @@ export function CallView() { return ( diff --git a/src/app/features/estate/EstateNavigator.css.ts b/src/app/features/estate/EstateNavigator.css.ts deleted file mode 100644 index 157d69cd..00000000 --- a/src/app/features/estate/EstateNavigator.css.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { globalStyle, style } from '@vanilla-extract/css'; -import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; - -/** On phones every control inside the navigator is at least 44 px square to tap. */ -export const Targets = style({}); - -globalStyle(`${Targets} button, ${Targets} a`, { - '@media': { - [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { - minHeight: 44, - minWidth: 44, - }, - }, -}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx deleted file mode 100644 index 56b554fa..00000000 --- a/src/app/features/estate/EstateNavigator.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; -import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavItem, - NavItemContent, -} from '../../components/nav'; -import { NavAction, NavLabel } from '../../components/nav/styles.css'; -import { UseStateProvider } from '../../components/UseStateProvider'; -import { JoinAddressPrompt } from '../../components/join-address-prompt'; -import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; -import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; -import { - encodeSearchParamValueArray, - getDirectRoomPath, - getHomeCreatePath, - getHomeRoomPath, - getSpaceRoomPath, - withSearchParam, -} from '../../pages/pathUtils'; -import { _RoomSearchParams } from '../../pages/paths'; -import { searchModalAtom } from '../../state/searchModal'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; -import { useCallEmbed } from '../../hooks/useCallEmbed'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../hooks/useRoomsNotificationPreferences'; -import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories'; -import { makeNavCategoryId } from '../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../state/room/roomToUnread'; -import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'; -import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; -import { useEstate, useEstateRootId } from './useEstate'; -import * as css from './EstateNavigator.css'; -import { SkyStateControl } from '../scene/SkyStateControl'; - -/** How long a tap waits for the client store to hold the room it just joined. */ -const JOIN_ARRIVAL_TIMEOUT_MS = 20000; - -const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { - const alias = getCanonicalAliasOrRoomId(mx, room.roomId); - if (room.direct) return getDirectRoomPath(alias); - // A Space the user has not joined has no Space route; the room still resolves from Home. - if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') { - return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); - } - return getHomeRoomPath(alias); -}; - -function JoinRow({ room }: { room: EstateRoom }) { - const mx = useMatrixClient(); - const navigate = useNavigate(); - const [state, join] = useAsyncCallback( - useCallback(async () => { - const result = await activateEstateRoom(mx, room); - if (result.kind === 'refused') throw new Error(result.message); - // joinRoom resolves before the store holds the room; opening it now shows the invite card. - if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself. - throw new Error('Joined. The room is still on its way and will open from this list.'); - } - navigate(estateRoomPath(mx, room)); - }, [mx, navigate, room]) - ); - const busy = state.status === AsyncStatus.Loading; - - return ( - - - - - - - - - {room.name} - - - - {state.status === AsyncStatus.Error && ( - - {(state.error as Error).message} - - )} - : } - onClick={join} - disabled={busy} - aria-label={`Join ${room.name}`} - > - Join - - - - ); -} - -function EstateRow({ room }: { room: EstateRoom }) { - const mx = useMatrixClient(); - const selectedRoomId = useSelectedRoom(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null; - - if (!mxRoom) return ; - return ( - - ); -} - -type CollapsibleProps = { - categoryId: string; - label: string; - rooms: EstateRoom[]; - children: ReactNode; -}; -function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { - const mobile = useScreenSizeContext() === ScreenSize.Mobile; - const selectedRoomId = useSelectedRoom(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const callRoomId = useCallEmbed()?.roomId; - const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); - - const active = rooms.some( - (r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId) - ); - // Phones start collapsed unless the section holds something live; wide screens start open. - // A tap flips that default; the flip is remembered per device and per screen class. - const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId); - const closed = (mobile && !active) !== toggled.has(key); - - const handleClick: MouseEventHandler = () => - setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key }); - - return ( - - - - {label} - - - {!closed && children} - - ); -} - -function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { - return ( - - {group.rooms.map((r) => ( - - ))} - - ); -} - -/** - * Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is - * remembered per device like any other section. - */ -function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { - const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); - const key = makeNavCategoryId('estate', 'browse', tierId); - const open = toggled.has(key); - const count = groups.reduce((n, g) => n + g.rooms.length, 0); - - return ( - - - setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })} - > - {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`} - - - {open && groups.map((g) => )} - - ); -} - -function Tier({ tier }: { tier: EstateTier }) { - const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); - const { front, browse } = useMemo(() => splitGroups(tier), [tier]); - return ( - - {tier.loose.map((r) => ( - - ))} - {front.map((g) => ( - - ))} - {browse.length > 0 && } - - ); -} - -function EntryPoints() { - const navigate = useNavigate(); - return ( - - - navigate(getHomeCreatePath())}> - - - - - - - Create Room - - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - Join with Address - - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - ); -} - -/** A short message that wraps inside the navigator; never a raw request error. */ -function EstateError({ message }: { message: string }) { - return ( - - {message} - - ); -} - -function EstateTiers({ rootId }: { rootId: string }) { - const { fetching, error, tiers } = useEstate(rootId); - - return ( - <> - {tiers.map((tier) => ( - - ))} - {fetching && ( - - - - )} - {error && ( - - )} - - ); -} - -/** The single navigator: every nav slot that lists rooms mounts this. */ -export function EstateNavigator() { - const openRoomFinder = useSetAtom(searchModalAtom); - const { rootId, error } = useEstateRootId(); - const scrollRef = useRef(null); - const mx = useMatrixClient(); - const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; - // A phone's list is the estate and nothing else: no Create Room, no Join with Address. - const mobile = useScreenSizeContext() === ScreenSize.Mobile; - - return ( - - - - - - {title} - - - - openRoomFinder(true)} - > - - - - - - - {!mobile && } - {rootId && } - {!rootId && error && ( - - )} - {!rootId && !error && ( - - - - )} - - - - - - - - - ); -} diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts deleted file mode 100644 index 7b63eac5..00000000 --- a/src/app/features/estate/buildEstate.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - buildEstate, - EstateHierarchyRoom, - EstateInput, - splitGroups, - stewardOfChild, -} from './buildEstate'; - -const ROOT = '!root:gamecult.org'; -const PROJECTS = '!projects:gamecult.org'; - -/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */ -const space = ( - id: string, - name: string, - children: string[], - stewards: Record = {} -): EstateHierarchyRoom => ({ - room_id: id, - name, - room_type: 'm.space', - children_state: children.map((c) => ({ - state_key: c, - content: { - via: ['gamecult.org'], - ...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}), - }, - })), -}); -const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ - room_id: id, - name, - canonical_alias: alias, -}); - -const hierarchy = [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), - room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space( - PROJECTS, - 'Projects', - ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'], - { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' } - ), - room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), - room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), - room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), -]; - -const input = (over: Partial = {}): EstateInput => ({ - rootId: ROOT, - hierarchy, - membership: new Map(), - directRoomIds: [], - ...over, -}); - -const find = (tiers: ReturnType, roomId: string) => - tiers - .flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)]) - .find((r) => r.roomId === roomId); - -describe('buildEstate', () => { - it('lists an invited project room as invited', () => { - const tiers = buildEstate( - input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }) - ); - expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); - }); - - it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => { - const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) })); - expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined'); - expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable'); - expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']); - }); - - it('omits a room absent from the hierarchy even when the client store knows it', () => { - const hidden = '!estate-secret:gamecult.org'; - const tiers = buildEstate( - input({ - membership: new Map([[hidden, 'join']]), - // The root names the room as a child, but the server did not return it. - hierarchy: [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]), - ...hierarchy.slice(1), - ], - }) - ); - expect(find(tiers, hidden)).toBeUndefined(); - }); - - it('lists every room the server returned, whatever the user may join', () => { - const tiers = buildEstate(input()); - expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); - }); - - it('groups rooms by steward Mind from the steward on each Space child entry', () => { - const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ - ['eureka', ['!cultlib:gamecult.org']], - ['void', ['!aeth:gamecult.org']], - ]); - }); - - it('sorts steward groups by display label', () => { - const projects = buildEstate( - input({ - mindLabels: new Map([ - ['eureka', 'Zed'], - ['void', 'Alpha'], - ]), - }) - ).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); - }); - - it('lists a room with no alias match loose in its tier', () => { - const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; - expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); - }); - - it('groups an invited-only user by the same child-entry stewards, with no root state', () => { - const invitee = new Map([ - [ROOT, 'invite'], - [PROJECTS, 'invite'], - ['!cultlib:gamecult.org', 'invite'], - ['!aeth:gamecult.org', 'invite'], - ]); - const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([ - ['eureka', ['invited']], - ['void', ['invited']], - ]); - }); - - it('ignores a steward that is not a non-empty string', () => { - const odd = (steward: unknown): EstateHierarchyRoom[] => [ - { - ...space(ROOT, 'GameCult', ['!a:gamecult.org']), - children_state: [ - { state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } }, - ], - }, - room('!a:gamecult.org', 'A'), - ]; - [42, '', undefined, null].forEach((m) => { - const t = buildEstate(input({ hierarchy: odd(m) }))[0]; - expect(t.groups).toEqual([]); - expect(t.loose).toHaveLength(1); - }); - }); - - it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { - const second = '!second:gamecult.org'; - const tiers = buildEstate( - input({ - hierarchy: [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]), - ...hierarchy.slice(1), - space(second, 'Workshop', ['!w1:gamecult.org']), - room('!w1:gamecult.org', 'Bench'), - ], - directRoomIds: ['!dm:gamecult.org'], - membership: new Map([['!dm:gamecult.org', 'join']]), - }) - ); - expect(tiers.map((t) => t.label)).toEqual([ - 'GameCult', - 'Projects', - 'Workshop', - 'Direct Messages', - ]); - expect(tiers[3].loose[0].direct).toBe(true); - expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); - expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); - }); - - it('takes join servers from the matching children_state entry', () => { - const tiers = buildEstate( - input({ - hierarchy: [ - { - ...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']), - children_state: [ - { state_key: '!a:gamecult.org', content: { via: ['one.example'] } }, - { state_key: '!b:gamecult.org', content: { via: ['two.example'] } }, - ], - }, - room('!a:gamecult.org', 'A'), - room('!b:gamecult.org', 'B'), - ], - }) - ); - expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']); - expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']); - }); - - it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate( - input({ - hierarchy: [ - ...hierarchy.slice(0, 2), - space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], { - '!cultlib:gamecult.org': 'eureka', - '!aeth:gamecult.org': 'eureka', - }), - ...hierarchy.slice(3, 5), - ], - }) - ); - const projects = tiers.find((t) => t.id === PROJECTS)!; - expect(projects.groups).toHaveLength(1); - expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ - '!cultlib:gamecult.org', - '!aeth:gamecult.org', - ]); - }); - - it('sorts group labels without regard to case', () => { - const projects = buildEstate( - input({ - mindLabels: new Map([ - ['eureka', 'beta'], - ['void', 'Alpha'], - ]), - }) - ).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); - }); - - it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => { - const tiers = buildEstate( - input({ - hierarchy: [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), - room('!general:gamecult.org', 'General'), - space(PROJECTS, 'Projects', ['!hidden:gamecult.org']), - ], - }) - ); - expect(tiers.map((t) => t.id)).toEqual([ROOT]); - }); - - it('puts direct messages in the final tier, off any Space', () => { - const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] })); - expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] }); - }); - - it('is identical for identical inputs', () => { - expect(buildEstate(input())).toEqual(buildEstate(input())); - }); -}); - -describe('stewardOfChild', () => { - it('reads the Mind from the org.gamecult.steward key of the child content', () => { - expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void'); - }); - - it('has no steward without the key, content or a string Mind', () => { - expect(stewardOfChild(undefined)).toBeUndefined(); - expect(stewardOfChild({ via: ['x'] })).toBeUndefined(); - expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined(); - }); -}); - -describe('Elsewhere', () => { - const strays = (over: Partial) => - buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find( - (t) => t.id === 'elsewhere' - ); - - it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { - const t = strays({ - strayRoomIds: ['!adhoc:x'], - roomNames: new Map([['!adhoc:x', 'Meeting']]), - })!; - expect(t.label).toBe('Elsewhere'); - expect(t.loose).toEqual([ - { - roomId: '!adhoc:x', - name: 'Meeting', - state: 'joined', - via: [], - parentId: null, - direct: false, - }, - ]); - }); - - it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => { - const tiers = buildEstate( - input({ - membership: new Map([ - ['!general:gamecult.org', 'join'], - ['!dm:x', 'join'], - ['!adhoc:x', 'join'], - ]), - strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'], - directRoomIds: ['!dm:x'], - }) - ); - expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']); - expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']); - }); - - it('never lists a room the user has not joined', () => { - expect( - strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] }) - ).toBeUndefined(); - }); - - it('is absent when there are no strays', () => { - expect(strays({})).toBeUndefined(); - }); -}); - -describe('direct invites', () => { - it('lists an invited DM in the Direct tier as invited, named from the store', () => { - const tiers = buildEstate( - input({ - membership: new Map([['!dm:x', 'invite']]), - directRoomIds: ['!dm:x'], - roomNames: new Map([['!dm:x', 'Void']]), - }) - ); - expect(tiers[tiers.length - 1].loose).toEqual([ - { roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true }, - ]); - }); -}); - -describe('splitGroups', () => { - const projects = (membership: EstateInput['membership']) => - buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!; - const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind); - - it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => { - const tier = projects(new Map()); - const { front, browse } = splitGroups(tier); - expect(labels(front)).toEqual([]); - expect(labels(browse).sort()).toEqual(['eureka', 'void']); - expect(tier.loose.map((r) => r.name)).toEqual(['Misc']); - }); - - it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => { - const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']]))); - expect(labels(front)).toEqual(['eureka']); - expect(labels(browse)).toEqual(['void']); - }); - - it('keeps a group up front for an invite, which is something to act on', () => { - const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']]))); - expect(labels(front)).toEqual(['void']); - }); - - it('loses no group: front and browse together are the tier groups', () => { - const tier = projects(new Map([['!cultlib:gamecult.org', 'join']])); - const { front, browse } = splitGroups(tier); - expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups); - }); -}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts deleted file mode 100644 index ca656211..00000000 --- a/src/app/features/estate/buildEstate.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * The estate index: the only code that decides which rooms the navigator lists, - * in which tier and steward group, and whether each is joined, invited or joinable. - * - * The server's /hierarchy of the root Space is the access rule. A room the - * hierarchy does not return is never listed, whatever the client store knows; - * a room it returns is never hidden by the client. The one licensed addition is - * the user's own joined rooms that sit outside the Space (the Elsewhere tier). - * - * A room's steward is read from the hierarchy too: the content of the - * m.space.child entry that lists the room carries org.gamecult.steward, which an - * invited-only member can read (she cannot read the Space's state). - */ - -export const STEWARD_KEY = 'org.gamecult.steward'; - -export type SpaceChildContent = { - via?: string[]; - /** The Mind who stewards the room, as the accounts script writes it. */ - [STEWARD_KEY]?: { mind?: unknown }; -}; - -export type EstateHierarchyRoom = { - room_id: string; - name?: string; - canonical_alias?: string; - room_type?: string; - children_state?: { state_key: string; content?: SpaceChildContent }[]; -}; - -export type EstateMembership = 'joined' | 'invited' | 'joinable'; - -export type EstateRoom = { - roomId: string; - name: string; - state: EstateMembership; - /** Servers to join through, from the hierarchy's children_state. */ - via: string[]; - /** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */ - parentId: string | null; - direct: boolean; -}; - -export type EstateGroup = { - mind: string; - label: string; - rooms: EstateRoom[]; -}; - -export type EstateTier = { - id: string; - label: string; - /** Rooms without a steward. */ - loose: EstateRoom[]; - groups: EstateGroup[]; -}; - -export type EstateInput = { - rootId: string; - /** Rooms from the server's hierarchy of the root, in hierarchy order. */ - hierarchy: readonly EstateHierarchyRoom[]; - /** The user's own membership per room id, from the client store. */ - membership: ReadonlyMap; - /** Joined and invited direct messages. */ - directRoomIds: readonly string[]; - /** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */ - strayRoomIds?: readonly string[]; - /** Names for rooms the hierarchy does not name (direct and stray rooms). */ - roomNames?: ReadonlyMap; - /** Display names for Minds, keyed by localpart. */ - mindLabels?: ReadonlyMap; -}; - -/** - * A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is - * something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first - * screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away. - */ -export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => { - const front: EstateGroup[] = []; - const browse: EstateGroup[] = []; - tier.groups.forEach((g) => - (g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g) - ); - return { front, browse }; -}; - -export const DIRECT_TIER_ID = 'direct'; -export const ELSEWHERE_TIER_ID = 'elsewhere'; - -const SPACE_TYPE = 'm.space'; - -/** The Mind a Space child entry names as the room's steward, if any. */ -export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => { - const mind = content?.[STEWARD_KEY]?.mind; - return typeof mind === 'string' && mind !== '' ? mind : undefined; -}; - -const stateOf = ( - roomId: string, - membership: ReadonlyMap -): EstateMembership => { - const m = membership.get(roomId); - if (m === 'join') return 'joined'; - if (m === 'invite') return 'invited'; - return 'joinable'; -}; - -const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id; - -export const buildEstate = ({ - rootId, - hierarchy, - membership, - directRoomIds, - strayRoomIds = [], - roomNames, - mindLabels, -}: EstateInput): EstateTier[] => { - const direct = new Set(directRoomIds); - const byId = new Map(hierarchy.map((r) => [r.room_id, r])); - const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE); - const root = byId.get(rootId); - - // Root first, then every sub-Space in hierarchy order. - const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces; - - const tiers: EstateTier[] = tierSpaces.map((space) => { - const childIds = new Set((space.children_state ?? []).map((c) => c.state_key)); - const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] }; - const groups = new Map(); - - hierarchy.forEach((r) => { - if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; - const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content; - const room: EstateRoom = { - roomId: r.room_id, - name: roomLabel(r), - state: stateOf(r.room_id, membership), - via: content?.via ?? [], - parentId: space.room_id, - direct: false, - }; - const mind = stewardOfChild(content); - if (!mind) { - tier.loose.push(room); - return; - } - let group = groups.get(mind); - if (!group) { - group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] }; - groups.set(mind, group); - } - group.rooms.push(room); - }); - - tier.groups = [...groups.values()].sort((a, b) => - a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) - ); - return tier; - }); - - const directTier: EstateTier = { - id: DIRECT_TIER_ID, - label: 'Direct Messages', - loose: directRoomIds.map((roomId) => ({ - roomId, - name: roomNames?.get(roomId) ?? roomId, - state: stateOf(roomId, membership), - via: [], - parentId: null, - direct: true, - })), - groups: [], - }; - - const listedIds = new Set(hierarchy.map((r) => r.room_id)); - const elsewhereTier: EstateTier = { - id: ELSEWHERE_TIER_ID, - label: 'Elsewhere', - loose: strayRoomIds - .filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join') - .map((roomId) => ({ - roomId, - name: roomNames?.get(roomId) ?? roomId, - state: 'joined' as const, - via: [], - parentId: null, - direct: false, - })), - groups: [], - }; - - const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); - return [ - ...listed, - ...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []), - ...(directTier.loose.length > 0 ? [directTier] : []), - ]; -}; diff --git a/src/app/features/estate/config.test.ts b/src/app/features/estate/config.test.ts deleted file mode 100644 index 4304e1c2..00000000 --- a/src/app/features/estate/config.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readFileSync } from 'fs'; -import { describe, expect, it } from 'vitest'; - -describe('repo config.json', () => { - it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => { - const config = JSON.parse(readFileSync('config.json', 'utf8')); - expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org'); - }); -}); diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts deleted file mode 100644 index 761586c4..00000000 --- a/src/app/features/estate/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EstateNavigator'; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts deleted file mode 100644 index 932e7cce..00000000 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - activateEstateRoom, - awaitMyJoin, - joinEstateRoomIdOrAlias, - joinEstateRoomOrThrow, - runJoinCommand, -} from './joinEstateRoom'; - -const room = (state: 'joined' | 'invited' | 'joinable') => ({ - roomId: '!r:gamecult.org', - state, - via: ['gamecult.org'], -}); - -/** A client that records every call; anything but joinRoom is a peek or a side door. */ -const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => { - const calls: string[] = []; - const mx = new Proxy({} as Record, { - get: (_t, prop: string) => { - if (prop === 'joinRoom') { - return (...args: unknown[]) => { - calls.push('joinRoom'); - return joinRoom(...args); - }; - } - if (prop === 'getRoom') return () => null; - if (prop === 'getSafeUserId') return () => '@me:test'; - return () => { - calls.push(prop); - throw new Error(`unexpected client call: ${prop}`); - }; - }, - }); - return { mx: mx as never, calls, joinRoom }; -}; - -describe('activateEstateRoom', () => { - it('opens a joined room without touching the client', async () => { - const { mx, calls } = recordingClient(); - expect(await activateEstateRoom(mx, room('joined'))).toEqual({ - kind: 'open', - roomId: '!r:gamecult.org', - }); - expect(calls).toEqual([]); - }); - - it.each(['invited', 'joinable'] as const)( - 'joins a %s room on the tap, through the hierarchy servers, and nothing else', - async (state) => { - const { mx, calls, joinRoom } = recordingClient(); - const result = await activateEstateRoom(mx, room(state)); - expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' }); - expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] }); - expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path - } - ); - - it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient( - vi - .fn() - .mockRejectedValue( - Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ) - ); - expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ - kind: 'refused', - message: 'Boom', - }); - const bare = recordingClient( - vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ); - expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ - kind: 'refused', - message: 'Could not join', - }); - }); - - it('reports the server refusal and does not decide access itself', async () => { - const refusal = Object.assign(new Error('M_FORBIDDEN'), { - httpStatus: 403, - errcode: 'M_FORBIDDEN', - data: { error: 'You are not invited to this room.' }, - }); - const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); - const result = await activateEstateRoom(mx, room('joinable')); - expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' }); - expect(joinRoom).toHaveBeenCalledTimes(1); - }); -}); - -/** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { - status?: number; - errcode?: string; - refuse?: boolean; - drop?: boolean; - failWrite?: boolean; - invites: Record; -}) => { - let direct: Record | undefined; - const writes: Record[] = []; - const joined: string[] = []; - const joinOpts: unknown[] = []; - const mx = { - getSafeUserId: () => '@me:test', - getRoom: (roomId: string) => ({ - getMyMembership: () => 'invite', - getMember: () => ({ - events: { - member: { - getContent: () => ({ membership: 'invite', is_direct: true }), - getSender: () => opts.invites[roomId], - }, - }, - }), - }), - getAccountData: () => (direct ? { getContent: () => direct } : undefined), - setAccountData: async (_t: string, content: Record) => { - if (opts.failWrite) throw new Error('account data write failed'); - await new Promise((r) => { - setTimeout(r, 5); - }); - writes.push(structuredClone(content)); - direct = structuredClone(content); - }, - joinRoom: async (roomId: string, joinArgs?: unknown) => { - joinOpts.push(joinArgs); - // the join lands only after m.direct has been written: record what it held - joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) - throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) - throw Object.assign(new Error('Gateway Time-out'), { - httpStatus: opts.status, - errcode: opts.errcode, - }); - if (opts.drop) throw new Error('fetch failed'); - return { roomId }; - }, - }; - return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; -}; -const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); - -describe('activateEstateRoom for a direct message invite', () => { - it('lists the room under the inviter before the join is sent', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('keeps both rooms when two DM joins are tapped together', async () => { - const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } }); - const results = await Promise.all([ - activateEstateRoom(c.mx, tap('!dm2:test')), - activateEstateRoom(c.mx, tap('!dm3:test')), - ]); - expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); - }); - - it('takes the room out again when the join is refused', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' }); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); -}); - -describe('activateEstateRoom when the join gets no answer', () => { - it('keeps the room listed and shows a retryable message', async () => { - const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('activateEstateRoom when the filing cannot be written', () => { - it('reports the failure and sends no join', async () => { - const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); - expect(c.joined).toEqual([]); - }); -}); - -describe('activateEstateRoom when a room that is not a direct message refuses', () => { - it('reports the refusal and leaves m.direct alone', async () => { - const joinRoom = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ); - const { mx, calls } = recordingClient(joinRoom); - const result = await activateEstateRoom(mx, room('joinable')); - expect(result.kind).toBe('refused'); - expect(calls).toEqual(['joinRoom']); - }); -}); - -describe('activateEstateRoom refusal bounds', () => { - it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ - status, - errcode: 'M_LIMIT_EXCEEDED', - invites: { '!dm1:test': '@friend:test' }, - }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); - - it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('activateEstateRoom when a gateway answers instead of the server', () => { - it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { - const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); - - it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ - status: 500, - errcode: 'M_UNKNOWN', - invites: { '!dm1:test': '@friend:test' }, - }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('joinEstateRoomIdOrAlias, the room card and /join', () => { - it('files a direct invite opened by its link before the join is sent', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); - expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('hands the via servers to the join', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); - expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); - }); - - it('joins an alias or an unheld room without touching m.direct', async () => { - const { mx, calls } = recordingClient(); - await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); - expect(calls).toEqual(['joinRoom']); - }); -}); - -describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { - it('files a direct invite and answers the joined room id', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('throws the refusal and unfiles the room', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); -}); - -describe('runJoinCommand, /join', () => { - it('reports each refusal and joins the others', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - const reported: string[] = []; - await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); - expect(reported).toEqual(['M_FORBIDDEN']); - expect(c.joined).toHaveLength(1); - }); - - it('reports nothing when the join is accepted', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - const reported: string[] = []; - await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); - expect(reported).toEqual([]); - expect(c.joined).toHaveLength(1); - }); -}); - -describe('awaitMyJoin', () => { - /** A store that gains the room when the test says so, emitting like the SDK. */ - const store = () => { - let membership: string | null = null; - const handlers = new Set<(room: { roomId: string }, m: string) => void>(); - return { - mx: { - getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (e: string, h: (room: { roomId: string }, m: string) => void) => { - if (e === 'Room.myMembership') handlers.add(h); - }, - off: (e: string, h: (room: { roomId: string }, m: string) => void) => { - if (e === 'Room.myMembership') handlers.delete(h); - }, - }, - handlers, - become: (roomId: string, m: string) => { - membership = m; - handlers.forEach((h) => h({ roomId }, m)); - }, - }; - }; - - it('resolves at once when the store already holds the joined room', async () => { - const s = store(); - s.become('!r:x', 'join'); - expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true); - }); - - it('stays pending while the store still shows an invite, then resolves on join', async () => { - const s = store(); - let settled = false; - const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => { - settled = true; - return v; - }); - s.become('!r:x', 'invite'); - s.become('!other:x', 'join'); - await Promise.resolve(); - expect(settled).toBe(false); - s.become('!r:x', 'join'); - expect(await p).toBe(true); - expect(s.handlers.size).toBe(0); - }); - - it('gives up after the timeout and stops listening', async () => { - vi.useFakeTimers(); - const s = store(); - const p = awaitMyJoin(s.mx, '!r:x', 500); - await vi.advanceTimersByTimeAsync(500); - expect(await p).toBe(false); - expect(s.handlers.size).toBe(0); - vi.useRealTimers(); - }); -}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts deleted file mode 100644 index 4e1629d0..00000000 --- a/src/app/features/estate/joinEstateRoom.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { EstateRoom } from './buildEstate'; -import { - addRoomIdToMDirect, - isRoomAlias, - isRoomId, - removeRoomIdFromMDirect, -} from '../../utils/matrix'; -import { splitWithSpace } from '../../utils/common'; -import { isDirectInvite } from '../../utils/room'; - -export type EstateActivation = - | { kind: 'open'; roomId: string } - | { kind: 'joined'; roomId: string } - | { kind: 'refused'; message: string }; - -const LOST_JOIN_RESPONSE = - 'The server did not answer the join. It may have gone through; tap again to retry.'; - -type JoinClient = Parameters[0]; - -/** - * What a tap on an estate row does. A joined room opens; any other room is - * joined, and only here (plus a first post in the room view). Opening a room - * never joins it and the client has no peek path. The server decides whether - * the join is allowed; its refusal is reported, the row is not hidden. - * - * An invite marked is_direct carries the mark in the invite event the client - * already holds, and the join event does not repeat it. So the room is listed - * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A - * join that fails any other way (lost response, dropped connection, a gateway's - * 5xx or a status with no errcode) may still have been accepted upstream, so the - * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every client join goes through here: the estate tap, the inbox Accept, - * the room card, /join, the lobby and space Join buttons, the tombstone and - * predecessor links. One owner for joining. - */ -export const activateEstateRoom = async ( - mx: JoinClient, - room: Pick & { via?: string[] } -): Promise => { - if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; - const refusal = (e: unknown): EstateActivation => { - const err = e as { data?: { error?: string }; message?: string }; - return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; - }; - const refusedByServer = (e: unknown) => { - const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; - const status = err.httpStatus; - const errcode = err.errcode ?? err.data?.errcode; - return ( - typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' - ); - }; - const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; - const inviter = isDirectInvite(invite, mx.getSafeUserId()) - ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() - : undefined; - try { - if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter); - } catch (e) { - return refusal(e); - } - try { - const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); - return { kind: 'joined', roomId: joined.roomId }; - } catch (e) { - if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; - if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); - return refusal(e); - } -}; - -/** - * activateEstateRoom for a caller that holds only an id or alias (room card, - * /join): a room the client has an invite for is an invited room. - */ -export const joinEstateRoomIdOrAlias = ( - mx: JoinClient, - roomIdOrAlias: string, - via?: string[] -): Promise => - activateEstateRoom(mx, { - roomId: roomIdOrAlias, - state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', - via, - }); - -/** - * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a - * useAsyncCallback button): the joined room id, or an Error carrying the refusal. - */ -export const joinEstateRoomOrThrow = async ( - mx: JoinClient, - roomIdOrAlias: string, - via?: string[] -): Promise => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); - if (result.kind === 'refused') throw new Error(result.message); - return result.roomId; -}; - -/** - * The /join command: every room id or alias in the payload is joined, and each - * refusal is handed to report, never dropped. - */ -export const runJoinCommand = async ( - mx: JoinClient, - payload: string, - report: (message: string) => void -): Promise => { - const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); - const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); - results.forEach((r) => { - if (r.kind === 'refused') report(r.message); - }); -}; - -type MembershipSource = { - getRoom(roomId: string): { getMyMembership(): string } | null; - on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; - off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; -}; - -/** - * joinRoom resolves when the server accepts the join, before the client store - * holds the room; navigating then lands on the invite card. Resolves true once - * the store says the user's membership is join, false after timeoutMs. - */ -export const awaitMyJoin = ( - mx: MembershipSource, - roomId: string, - timeoutMs: number -): Promise => - new Promise((resolve) => { - if (mx.getRoom(roomId)?.getMyMembership() === 'join') { - resolve(true); - return; - } - let finish: (joined: boolean) => void = () => undefined; - const onMembership = (room: { roomId: string }, membership: string) => { - if (room.roomId === roomId && membership === 'join') finish(true); - }; - const timer = setTimeout(() => finish(false), timeoutMs); - finish = (joined) => { - clearTimeout(timer); - mx.off('Room.myMembership', onMembership); - resolve(joined); - }; - mx.on('Room.myMembership', onMembership); - }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts deleted file mode 100644 index d27d4710..00000000 --- a/src/app/features/estate/useEstate.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import { useAtomValue } from 'jotai'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; -import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room'; -import { useStateEventCallback } from '../../hooks/useStateEventCallback'; -import { allRoomsAtom } from '../../state/room-list/roomList'; -import { allInvitesAtom } from '../../state/room-list/inviteList'; -import { mDirectAtom } from '../../state/mDirectList'; -import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate'; - -const HIERARCHY_DEPTH = 10; - -type AliasedRoom = { - roomId: string; - getCanonicalAlias(): string | null; - getAltAliases(): string[]; -}; - -const findLoadedRoom = ( - mx: ReturnType, - alias: string -): string | undefined => - (mx.getRooms() as AliasedRoom[]).find( - (r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias) - )?.roomId; - -/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ -export const useEstateRootId = (): { rootId?: string; error?: Error } => { - const mx = useMatrixClient(); - const alias = useClientConfig().featuredCommunities?.spaces?.[0]; - const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias); - const resolved = useQuery({ - queryKey: ['estate-root', alias], - queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id, - enabled: !!alias && !loaded, - }); - if (!alias) return { error: new Error('No estate root Space is configured') }; - return { rootId: loaded || resolved.data, error: resolved.error ?? undefined }; -}; - -export type Estate = { - fetching: boolean; - error: Error | null; - tiers: EstateTier[]; -}; - -/** - * Feeds buildEstate: the server hierarchy of the root Space (which also carries - * each room's steward), the user's membership, m.direct and direct invites, and - * her joined rooms outside the Space. Recomputes on membership and Space child changes. - */ -export const useEstate = (rootId: string): Estate => { - const mx = useMatrixClient(); - const queryClient = useQueryClient(); - const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH); - const joined = useAtomValue(allRoomsAtom); - const invited = useAtomValue(allInvitesAtom); - const mDirects = useAtomValue(mDirectAtom); - - const refetchHierarchy = useCallback( - () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), - [queryClient, rootId] - ); - useStateEventCallback( - mx, - useCallback( - (event) => { - if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); - }, - [refetchHierarchy] - ) - ); - // What the server lets this user see changes with invitations. - useEffect(() => { - refetchHierarchy(); - }, [invited.length, refetchHierarchy]); - - const tiers = useMemo(() => { - const membership = new Map(); - invited.forEach((id) => membership.set(id, 'invite')); - joined.forEach((id) => membership.set(id, 'join')); - const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[]; - const domain = mx.getDomain(); - const mindLabels = new Map(); - hierarchy.forEach((r) => - r.children_state?.forEach((c) => { - const mind = stewardOfChild(c.content); - const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName; - if (mind && name) mindLabels.set(mind, name); - }) - ); - const userId = mx.getUserId(); - const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId)); - const directRoomIds = [ - ...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]), - ]; - const strayRoomIds = joined.filter((id) => { - const r = mx.getRoom(id); - return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r); - }); - const roomNames = new Map(); - [...directInvites, ...strayRoomIds].forEach((id) => { - const name = mx.getRoom(id)?.name; - if (name) roomNames.set(id, name); - }); - return buildEstate({ - rootId, - hierarchy, - membership, - directRoomIds, - strayRoomIds, - roomNames, - mindLabels, - }); - }, [mx, rootId, rooms, joined, invited, mDirects]); - - return { fetching, error, tiers }; -}; diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 1802e3b0..c6aff741 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -200,12 +200,16 @@ type HierarchyItemMenuProps = { joined: boolean; powerLevels?: IPowerLevels; canEditChild: boolean; + pinned?: boolean; + onTogglePin?: (roomId: string) => void; }; export function HierarchyItemMenu({ item, joined, powerLevels, canEditChild, + pinned, + onTogglePin, }: HierarchyItemMenuProps) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -260,6 +264,20 @@ export function HierarchyItemMenu({ {joined && ( + {onTogglePin && ( + { + onTogglePin(item.roomId); + handleRequestClose(); + }} + > + + {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'} + + + )} { + const sideSpaces = sidebarItems.flatMap((item) => { + if (typeof item === 'string') return item; + return item.content; + }); + + return new Set(sideSpaces); + }, [sidebarItems]); + const [spacesItems, setSpacesItem] = useState>(() => new Map()); useElementSizeObserver( @@ -395,6 +415,18 @@ export function Lobby() { navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; + const togglePinToSidebar = useCallback( + (rId: string) => { + const newItems = sidebarItemWithout(sidebarItems, rId); + if (!sidebarSpaces.has(rId)) { + newItems.push(rId); + } + const newSpacesContent = makeCinnySpacesContent(mx, newItems); + mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); + }, + [mx, sidebarItems, sidebarSpaces] + ); + return ( @@ -467,6 +499,8 @@ export function Lobby() { disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} + pinned={sidebarSpaces.has(item.space.roomId)} + togglePinToSidebar={togglePinToSidebar} onSpacesFound={handleSpacesFound} onOpenRoom={handleOpenRoom} /> diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 132aaf8c..7de59acd 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, Room } from 'matrix-js-sdk'; +import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,7 +38,6 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -47,8 +46,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -61,8 +60,9 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.message} + {joinState.error.data?.error || joinState.error.message} + {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.message} + aria-label={joinState.error.data?.error || joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 9a283378..280b8a5a 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -35,6 +35,8 @@ type SpaceHierarchyProps = { disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; + pinned: boolean; + togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onOpenRoom: MouseEventHandler; }; @@ -56,6 +58,8 @@ export const SpaceHierarchy = forwardRef( disabledReorder, nextSpaceId, getRoom, + pinned, + togglePinToSidebar, onOpenRoom, onSpacesFound, }, @@ -132,6 +136,8 @@ export const SpaceHierarchy = forwardRef( canEditChild={ !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) } + pinned={pinned} + onTogglePin={togglePinToSidebar} /> ) } diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index e1ffbfe5..ed3eaff9 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { Room } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,7 +38,6 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -113,8 +112,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx deleted file mode 100644 index a500f920..00000000 --- a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { RoomNavCategoryButton } from './RoomNavCategoryButton'; - -const html = (closed?: boolean) => - renderToStaticMarkup(Browse); - -describe('RoomNavCategoryButton', () => { - it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => { - expect(html(true)).toContain('aria-expanded="false"'); - }); - - it('says it is expanded when open', () => { - expect(html(false)).toContain('aria-expanded="true"'); - }); - - it('claims neither when the section does not toggle', () => { - expect(html(undefined)).not.toContain('aria-expanded'); - }); -}); diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index e232b856..3af8aeac 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -16,7 +16,6 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> } - aria-expanded={closed === undefined ? undefined : !closed} {...props} ref={ref} > diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ff23930e..08341113 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -21,7 +21,6 @@ import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; -import { NavLabel } from '../../components/nav/styles.css'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; @@ -359,13 +358,7 @@ export function RoomNavItem({ )} - + {roomName} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b9655923..b5701a6f 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,26 +1,8 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, - '@media': { - // A section heading on a phone is plain text: no pill behind it. - 'screen and (max-width: 750px)': { - selectors: { - '&&': { - backgroundColor: 'transparent', - boxShadow: 'none', - border: 'none', - borderRadius: 0, - }, - ':root[data-scene] &&': { - textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, - filter: `drop-shadow(0 0 3px ${surfaceInk})`, - }, - }, - }, - }, }); export const CategoryButtonIcon = style({ opacity: config.opacity.P400, diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index d798f62c..9cd428ed 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -18,7 +18,7 @@ export const TimelineFloat = recipe({ top: config.space.S400, }, Bottom: { - bottom: config.space.S200, + bottom: config.space.S400, }, }, }, diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 3556e6fc..24f0b80e 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,7 +8,6 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -19,14 +18,16 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return joinEstateRoomOrThrow(mx, replacementRoomId, via); + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); }; return ( diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx deleted file mode 100644 index 8f09fdcf..00000000 --- a/src/app/features/room/RoomTrainingLine.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Text, color, config } from 'folds'; -import { Room } from 'matrix-js-sdk'; -import React from 'react'; -import { useStateEvent } from '../../hooks/useStateEvent'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { - ROOM_NOTICE_EVENT_TYPE, - parseRoomNotice, - roomTrainingLine, -} from '../../utils/trainingNotice'; - -// The room's notice state event decides the line; no event, or a malformed one, no line. The -// collector reads the version from the same event, so nothing is stored in the browser. -export function RoomTrainingLine({ room }: { room: Room }) { - const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE); - const { previewDemo } = useClientConfig(); - const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo); - if (!line) return null; - return ( - - {line} - - ); -} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 3fbd81fa..a4f0d7af 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -13,7 +13,6 @@ import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; -import { RoomTrainingLine } from './RoomTrainingLine'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; import { useKeyDown } from '../../hooks/useKeyDown'; @@ -105,7 +104,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
- {tombstoneEvent ? ( ( ); - const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); - const mind = - poseBubbles && messageLayout === MessageLayout.Bubble - ? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName) - : undefined; - - const own = senderId === mx.getUserId(); - const enterDelay = useEntranceDelay(mEvent.getId()); - // Minds alternate sides on phones, each always on the same one. - const poseAlt = !!mind && MINDS.indexOf(mind) % 2 === 1; - const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( ( ); - const poseJSX = !collapse && mind && ( - - ); - const msgContentJSX = ( {reply} @@ -1161,15 +1135,7 @@ export const Message = as<'div', MessageProps>( )} {messageLayout === MessageLayout.Bubble && ( - + {msgContentJSX} )} diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx deleted file mode 100644 index c447b4f0..00000000 --- a/src/app/features/scene/BackgroundScene.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { getFallbackSession } from '../../state/sessions'; -import { mountBackgroundScene } from './host'; -import { readCapabilities, resolveTier } from './tier'; -import { getSceneStyleOverride } from './sceneStyle'; -import './sceneShell.css'; -import './sceneButtons.css'; - -/** Set on while a scene runs, so signed-out screens can let it show through. */ -export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; - -/** - * The one host for the background scene, mounted once at the app root. Fixed behind everything - * (z-index -1 sits above the body's background and below all normal content; the signed-out - * screens and the signed-in shell, see sceneShell.css.ts, are clear so the scene shows through). The ridge scene - * draws its layers into this wrapper beside the canvas, so the wrapper is the positioned box. - * Tier 'off' renders nothing and loads nothing. - */ -export function BackgroundScene() { - const configured = useClientConfig().backgroundScene; - const config = useMemo(() => { - const style = getSceneStyleOverride(); - return configured && style ? { ...configured, style } : configured; - }, [configured]); - const enabled = resolveTier(config, readCapabilities(navigator)) !== 'off'; - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!enabled || !canvas) return undefined; - const root = document.documentElement; - const dispose = mountBackgroundScene(config, readCapabilities(navigator), canvas, { - loggedIn: !!getFallbackSession(), - onActive: () => root.setAttribute(SCENE_ACTIVE_ATTRIBUTE, ''), - onInactive: () => root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE), - // eslint-disable-next-line no-console - onFail: (e) => console.warn('Background scene unavailable:', e), - }); - return () => { - dispose(); - root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE); - }; - }, [config, enabled]); - - if (!enabled) return null; - return ( -
- -
- ); -} diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md deleted file mode 100644 index fa7565b0..00000000 --- a/src/app/features/scene/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# Background scene - -**Default: the ridge scene** (`ridge/`), no WebGL. `startBackgroundScene` runs it unless the client -config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still -means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene. - -- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or - null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's - value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain - 2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between - layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a - CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame. - The host canvas and the layers' parent must be positioned and the canvas should fill the parent. -- Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played. - `tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements. -- Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over - 33 ms the scene flattens to a still image (`ridge/probe.ts`) and stops. `?probe=1` (or `probe: true`) keeps sampling - and shows `n / median / p95` in a corner readout. -- `ridge/skyColour.ts` `skyColours()` is the one place sky colours come from: a fixed dusk placeholder until Void's - `src/sky/sun.js` `light()` lands (PR #20); replace its body, keep the signature. -- Aerial perspective (`ridge/ridgeWorld.ts` `AERIAL`, `transmittance`, `layerColour`, `edgeBlur`, `scatterAlpha`) is applied - once while the layers are baked, from the `SkyColours` the one seam returns (`sunAzimuth` optional: omit it for no - in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is - `T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1, - via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the - sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer - rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light. -- `harness/scene/bundle.cjs ridge` builds a standalone page (`?tier=`, `?probe=1`). -- Not yet measured on a phone: see the PR description. - -## Ray-march scene - -A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath -noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back -from the tree on login. It is a self-contained module; nothing in the client imports it yet. -Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts, -`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel -are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure -callbacks and the creature sizing rule. Nothing has drawn a creature. - -## Terrain scene (`style: 'terrain'`): the baked sky - -`terrain/` draws a Phacelle-displaced grid under a sky read from Void's bake (`src/sky/baked.ts`, layout in -`src/sky/README.md`); nothing is vendored. `terrainSky.ts` reads each moment's `sun.{elevation_deg, azimuth_deg, -color_linear}`, `ambient_linear` and `horizon_hex`; `terrainScatter.ts` decodes `baked.scatter` and uploads `sky_view` as an -RGB16F 3D texture (the transmittance table stays on the CPU, for the sun's colour at an elevation the bake does not hold). -Both fragment shaders sample it: `SKY_FRAG` for the sky, `GRID_FRAG` for the haze, which is the sky's colour at the horizon in the -fragment's azimuth, so far ground fades into the sky behind it at dusk too. - -Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az=` puts the sun anywhere between them -(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as -the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`. - -Without WebGL2 (`createTerrainScene` returns null) the door mounts `terrain/TerrainSky2D.ts` on the same canvas: the sky alone, painted once on a -96 px wide 2D canvas from `skyColour` (the CPU twin of `SKY_FRAG`, same `?sky=` `?sun=` `?az=` choice via `chooseSky`), the star layer on -top when the sky is dark, nothing moving, no sun disc. Below the horizon the image continues in the horizon's colour. A canvas that already -holds a GL context gives no 2D one, so a GL failure after context creation (compile, framebuffer) still ends in the CSS ground. - -- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a - sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce, - no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange, - where the real one keeps a blue-violet zenith. Within `HORIZON_FLOOR` (1.5 degrees) of the horizon the sky takes one flat colour, - because the table dips toward the ground there and would draw a seam against the haze. -- Stars: faded by the sun's elevation, `starGain` 1 below -12 degrees (where the table goes dark), 0 above -6, smooth between; - the field is placed against the midnight gradient and no longer gated on the sky being dark. At a baked sky that is the same as - before (midnight shows, noonday does not). -- Changed numbers: haze colour (was the gradient's horizon stop; now the scattered sky), ambient (was the gradient's zenith stop - x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than - the lifted one). `HAZE_K` (0.0004 per metre) is unchanged. - -## Entry points - -- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's - door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the - feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At - `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on - demand, so it stays out of the main chunk; if that chunk fails to load the error goes to - `onCompileFail` and the door returns null, it does not throw. -- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns - `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither - `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with - only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the - `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a - baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context - stops the scene for good and calls `onContextLost`; the scene never rebuilds itself. `host.ts` disposes it, drops - `data-scene` so the CSS ground shows, and starts a fresh scene on `webglcontextrestored`. It caps at 30 fps, pauses when - the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the - preference while it runs freezes or thaws it), and steps down a - quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. -- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and - overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. -- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). -- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL - `#define`s are generated from it; no number is written twice. -- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), - `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). - -## How it renders - -0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per - tile, shrinking each step by the widest any ray of the tile strays from it, so every step is - free for the whole tile. It stores the distance no ray of the tile can have hit before, or - `W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays - there. `createScene({ prepass: false })` skips it (the measurement baseline). -1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of - `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with - `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with - coverage and the hit distance (second render target). -2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud - slab marched with a few jittered steps, both only up to the hit distance from pass 1. -3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth, - 3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake). -4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES - tonemap, dither. - -`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra -render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited -on), and reports mean steps per pixel. Unset, neither the output nor the target exists. - -## The step bound, and what is not proven - -The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, -computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the -envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is -not evaluated. - -- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at - |side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof. - The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n). -- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest - terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a - hit proven by the march. -- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects - carry a thin fog halo. - -## Measured and not measured - -See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They -come from a software rasterizer, not a GPU, and nothing has been run on a phone. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts deleted file mode 100644 index 1da90711..00000000 --- a/src/app/features/scene/Scene.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; -import { WORLD } from './world'; -import { LEVELS } from './budget'; -import { fragmentSource } from './shaderSource'; -import { startBackgroundScene } from './index'; - -type Fn = Mock; - -/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ -function fakeGl(failFrom = Infinity, missing: string[] = []) { - let compiles = 0; - const calls: Record = {}; - const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => { - if (!calls[name]) calls[name] = vi.fn(impl); - return calls[name]; - }; - const gl = new Proxy({} as Record, { - get(_, prop: string) { - // the two start-target formats must be told apart; every other constant is 1 - if (prop === 'RGBA32F') return 32; - if (prop === 'RGBA16F') return 16; - if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; - if (prop === 'getExtension') - return spy(prop, (n: string) => (missing.includes(n) ? null : {})); - if (prop === 'compileShader') { - return spy(prop, () => { - compiles += 1; - }); - } - if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); - if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); - if (prop === 'getProgramParameter') return spy(prop, () => true); - return spy(prop); - }, - }); - return { gl: gl as unknown as WebGL2RenderingContext, calls }; -} - -function fakeCanvas(gl: WebGL2RenderingContext | null) { - const canvas = new EventTarget() as EventTarget & { - clientWidth: number; - clientHeight: number; - width: number; - height: number; - getContext: Fn; - }; - canvas.clientWidth = 400; - canvas.clientHeight = 300; - canvas.width = 0; - canvas.height = 0; - canvas.getContext = vi.fn(() => gl); - return canvas; -} - -let rafQueue: Array<(t: number) => void>; -let cancelled: number; -let win: EventTarget; -let motion: EventTarget & { matches: boolean }; -let doc: EventTarget & { visibilityState: string }; - -beforeEach(() => { - rafQueue = []; - cancelled = 0; - motion = Object.assign(new EventTarget(), { matches: false }); - win = Object.assign(new EventTarget(), { - devicePixelRatio: 1, - matchMedia: () => motion, - }); - doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); - vi.stubGlobal('window', win); - vi.stubGlobal('document', doc); - vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); - vi.stubGlobal('cancelAnimationFrame', () => { - cancelled += 1; - }); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const runFrame = (now: number) => { - const cb = rafQueue.shift(); - if (!cb) throw new Error('no frame was scheduled'); - cb(now); -}; -const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; - -describe('createScene tiers', () => { - it("'off' returns null without asking the canvas for a context", () => { - const { gl } = fakeGl(); - const canvas = fakeCanvas(gl); - expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - expect(rafQueue).toHaveLength(0); - }); - - it("'still' draws one frame and schedules no more", () => { - const { gl, calls } = fakeGl(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); - expect(scene).not.toBeNull(); - runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame - expect(rafQueue).toHaveLength(0); - }); - - it("'still' ignores login(): the pull-back is for a moving scene", () => { - const { gl } = fakeGl(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); - runFrame(1000); - scene?.login(); - expect(rafQueue).toHaveLength(0); - }); - - it("'full' keeps scheduling frames", () => { - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' }); - runFrame(1000); - expect(rafQueue).toHaveLength(1); - }); - - it("'low' traces with a cheaper level than 'full'", () => { - const traceSteps = (tier: 'low' | 'full') => { - rafQueue.length = 0; // each call is a fresh scene - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), tier }); - runFrame(1000); - // u_maxSteps is the only uniform1i whose value is a trace-step count - return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40); - }; - expect(traceSteps('full')).toContain(LEVELS[0].traceSteps); - expect(traceSteps('low')).toContain(LEVELS[2].traceSteps); - expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps); - }); - - it('returns null when WebGL2 is missing, so the host keeps its ground', () => { - expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull(); - }); -}); - -describe('cone prepass', () => { - const programsFor = (prepass?: boolean) => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); - return calls.createProgram.mock.calls.length; - }; - - it('is one more program than the baseline, and off means off', () => { - expect(programsFor(true)).toBe(programsFor(false) + 1); - expect(programsFor(undefined)).toBe(programsFor(true)); - }); - - it('draws before pass 1 and only when on', () => { - const drawsFor = (prepass: boolean) => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); - runFrame(1000); - return calls.drawArrays.mock.calls.length; - }; - expect(drawsFor(true)).toBe(drawsFor(false) + 1); - }); - - it('the trace-step output exists in pass 1 only when the host asked to measure', () => { - expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1'); - expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); - }); - - it('reads the step bytes tightly packed, whatever the width', () => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} }); - runFrame(1000); - // fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call - const packs = calls.pixelStorei.mock.calls.map((c) => c[1]); - expect(packs[0]).toBe(1); - expect(packs).toContain(4); - expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan( - calls.pixelStorei.mock.invocationCallOrder[0] - ); - }); - - it('shares one distance bound between pass 1 and the prepass', () => { - const bound = (stage: 'terrain' | 'prepass') => - fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; - expect(bound('prepass')).toBeDefined(); - expect(bound('prepass')).toBe(bound('terrain')); - expect(fragmentSource('atmosphere')).not.toContain('sceneDistance'); - }); -}); - -describe('device without EXT_color_buffer_float', () => { - const FLOAT = 'EXT_color_buffer_float'; - const HALF = 'EXT_color_buffer_half_float'; - const build = (missing: string[], extra: Partial[0]> = {}) => { - rafQueue.length = 0; - const { gl, calls } = fakeGl(Infinity, missing); - const onUnavailable = vi.fn(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra }); - if (scene) runFrame(1000); // targets are allocated on the first frame - const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? []; - const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string); - return { - scene, - onUnavailable, - formats, - prepassSource: sources.find((x) => x.includes('o_start')), - }; - }; - - it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => { - const none = vi.fn(); - createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none }); - expect(none).toHaveBeenCalledTimes(1); - expect(none).toHaveBeenCalledWith('no-webgl2'); - const r = build([FLOAT, HALF]); - expect(r.scene).toBeNull(); - expect(r.onUnavailable).toHaveBeenCalledTimes(1); - expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer'); - expect(build([]).onUnavailable).not.toHaveBeenCalled(); - }); - - it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => { - const r = build([FLOAT]); - expect(r.scene?.startFormat).toBe('RGBA16F'); - expect(r.formats).not.toContain(32); - expect(r.prepassSource).toContain('#define START_HALF 1'); - expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`); - }); - - it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => { - const wide = build([HALF]); - expect(wide.scene?.startFormat).toBe('RGBA32F'); - expect(wide.formats).toContain(32); - expect(wide.prepassSource).not.toContain('#define START_HALF'); - expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F'); - }); - - // The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is - // that rounding. The GPU's rounding of a half write is the unknown a phone must confirm. - const toHalf = (x: number) => { - if (x === 0) return 0; - let e = Math.max(Math.floor(Math.log2(x)), -14); - if (2 ** e > x) e -= 1; - const ulp = 2 ** (e - 10); - const q = x / ulp; - let r = Math.floor(q); - const d = q - r; - if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1; - return r * ulp; - }; - const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS)); - - it('stores a start distance never past the true one, and not much short of it', () => { - let seed = 7; - const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647; - const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9]; - for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4); - for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t); - for (const t of ts) { - expect(stored(t)).toBeLessThanOrEqual(t); - expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5); - } - }); - - it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => { - expect(toHalf(WORLD.far)).toBe(WORLD.far); - }); -}); - -describe('shader compile failure', () => { - it('reports through onCompileFail, returns null, and frees the programs already linked', () => { - const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails - const onCompileFail = vi.fn(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }); - expect(scene).toBeNull(); - expect(onCompileFail).toHaveBeenCalledTimes(1); - const err = onCompileFail.mock.calls[0][0] as ShaderCompileError; - expect(err).toBeInstanceOf(ShaderCompileError); - expect(err.stage).toBe('atmosphere'); - expect(err.log).toContain('bad'); - expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link - expect(rafQueue).toHaveLength(0); - }); - - it('does not throw out of createScene when no callback is given', () => { - const { gl } = fakeGl(0); - expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow(); - }); -}); - -describe('context loss', () => { - it('stops the loop, tells the host, and detaches every listener', () => { - const { gl } = fakeGl(); - const canvas = fakeCanvas(gl); - const onContextLost = vi.fn(); - createScene({ canvas: asCanvas(canvas), onContextLost }); - const lost = new Event('webglcontextlost', { cancelable: true }); - canvas.dispatchEvent(lost); - expect(lost.defaultPrevented).toBe(true); - expect(onContextLost).toHaveBeenCalledTimes(1); - expect(cancelled).toBe(1); - // listeners are gone: a second loss, a resize and a visibility change do nothing - canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); - win.dispatchEvent(new Event('resize')); - doc.dispatchEvent(new Event('visibilitychange')); - expect(onContextLost).toHaveBeenCalledTimes(1); - expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run - }); - - it('does not render a queued frame after the loss', () => { - const { gl, calls } = fakeGl(); - const canvas = fakeCanvas(gl); - createScene({ canvas: asCanvas(canvas) }); - canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); - runFrame(1000); - expect(calls.drawArrays).toBeUndefined(); - }); -}); - -describe('prefers-reduced-motion', () => { - const runAll = (t0: number) => { - let t = t0; - let n = 0; - while (rafQueue.length && n < 200) { - runFrame(t); - t += 40; - n += 1; - } - return n; - }; - - it('freezes to a bounded run when the preference turns on mid-flight', () => { - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)) }); - runFrame(1000); - expect(rafQueue).toHaveLength(1); // moving: keeps going - motion.matches = true; - motion.dispatchEvent(new Event('change')); - expect(runAll(1040)).toBeLessThan(200); // ends - expect(rafQueue).toHaveLength(0); - }); - - it('thaws a frozen scene when the preference turns off', () => { - motion.matches = true; - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)) }); - runAll(1000); - expect(rafQueue).toHaveLength(0); - motion.matches = false; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(1); - runFrame(5000); - expect(rafQueue).toHaveLength(1); // unbounded again - }); - - it('removes the listener on dispose and on context loss', () => { - const { gl } = fakeGl(); - const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) }); - scene?.dispose(); - rafQueue.length = 0; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(0); - - const lostCanvas = fakeCanvas(fakeGl().gl); - createScene({ canvas: asCanvas(lostCanvas) }); - lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); - rafQueue.length = 0; - motion.matches = true; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(0); - }); - - it('a still does not listen: one frame stays one frame', () => { - const { gl } = fakeGl(); - createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); - runFrame(1000); - motion.matches = false; - motion.dispatchEvent(new Event('change')); - expect(rafQueue).toHaveLength(0); - }); -}); - -describe('startBackgroundScene', () => { - it("does not request a context when the config leaves the tier 'off'", async () => { - const { gl } = fakeGl(); - const canvas = fakeCanvas(gl); - expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull(); - expect( - await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) }) - ).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - }); - - it('applies the device ceiling: saveData runs a still, one frame', async () => { - const { gl, calls } = fakeGl(); - const scene = await startBackgroundScene( - { tier: 'full', style: 'raymarch' }, - { saveData: true }, - { canvas: asCanvas(fakeCanvas(gl)) } - ); - expect(scene).not.toBeNull(); - runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(5); - expect(rafQueue).toHaveLength(0); - }); - - it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => { - vi.resetModules(); - vi.doMock('./Scene', () => { - throw new Error('Failed to fetch dynamically imported module'); - }); - try { - const { startBackgroundScene: start } = await import('./index'); - const onCompileFail = vi.fn(); - const canvas = fakeCanvas(fakeGl().gl); - const scene = await start( - { tier: 'full', style: 'raymarch' }, - {}, - { canvas: asCanvas(canvas), onCompileFail } - ); - expect(scene).toBeNull(); - expect(onCompileFail).toHaveBeenCalledTimes(1); - expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); - expect(canvas.getContext).not.toHaveBeenCalled(); - // and with no callback it still does not throw - expect( - await start({ tier: 'full', style: 'raymarch' }, {}, { canvas: asCanvas(canvas) }) - ).toBeNull(); - } finally { - vi.doUnmock('./Scene'); - vi.resetModules(); - } - }); - - it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { - const { gl } = fakeGl(0); - const onCompileFail = vi.fn(); - const scene = await startBackgroundScene( - { tier: 'full', style: 'raymarch' }, - {}, - { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } - ); - expect(scene).toBeNull(); - expect(onCompileFail).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts deleted file mode 100644 index c2f0423a..00000000 --- a/src/app/features/scene/Scene.ts +++ /dev/null @@ -1,639 +0,0 @@ -import { fragmentSource, vertexSource, Stage } from './shaderSource'; -import { cameraPose, pullProgress, CameraPose } from './camera'; -import { BudgetLadder, Level, LEVELS } from './budget'; -import type { SceneTier } from './tier'; -import { PALETTE } from './palette'; - -/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ -export const GPU_BUDGET_MS = 4.0; -const FRAME_INTERVAL_MS = 1000 / 30; -const REDUCED_MOTION_FRAMES = 32; -const MAX_DPR = 1.5; - -export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; -const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass']; -/** Pass 1 pixels per prepass tile edge. */ -const TILE = 8; - -export interface SceneStats { - /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ - passMs: Record; - level: number; - solidSize: [number, number]; - atmosphereSize: [number, number]; -} - -/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ -export interface TraceStats { - /** Mean pass-1 steps per pixel. */ - traceSteps: number; - /** Mean prepass steps per tile; 0 without the prepass. */ - prepassSteps: number; - /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ - stepsPerPixel: number; -} - -/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ -export type SourcePatch = (stage: Stage, source: string) => string; - -export class ShaderCompileError extends Error { - constructor(readonly stage: Stage | 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } -} - -/** - * Why createScene returned null for want of a device feature. Rendering to half floats needs - * EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with - * the first and RGBA16F with only the second. - */ -export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer'; - -/** - * The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds - * to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value - * below t, and the bias covers the absolute error where halves go subnormal. The shader reads both - * through #defines, so this is the one copy. - */ -export const HALF_START_SCALE = 1 - 1 / 1024; -export const HALF_START_BIAS = 1 / 32768; -const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`; - -/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ -const LOW_START_LEVEL = 2; -/** The 'still' tier draws one frame at this level and stops. */ -const STILL_LEVEL = LEVELS[0]; - -export interface SceneOptions { - canvas: HTMLCanvasElement; - /** - * 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts - * the adaptive ladder low. 'full' starts it at the top. Default 'full'. - */ - tier?: SceneTier; - /** createScene is about to return null because the device lacks a feature, and which one. */ - onUnavailable?: (reason: SceneUnavailable) => void; - /** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */ - halfStart?: boolean; - /** A shader failed to compile or link. createScene frees what it built and returns null. */ - onCompileFail?: (e: ShaderCompileError) => void; - /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ - onContextLost?: () => void; - /** Called per finished frame when timer queries exist. */ - onStats?: (s: SceneStats) => void; - /** - * Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it - * compiles pass 1 with a step-count output and an extra render target; unset, neither exists. - */ - onTraceStats?: (s: TraceStats) => void; - /** Weather knob, 0 clear .. 1 overcast. */ - coverage?: number; - /** Fixed level instead of the adaptive ladder (measurement). */ - fixedLevel?: Level; - /** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */ - prepass?: boolean; - /** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */ - fixedPull?: number; - /** Ignore the 30 fps cap and the visibility pause (measurement). */ - unthrottled?: boolean; - patchSource?: SourcePatch; -} - -export interface Scene { - /** Texture format of the ray-march scene's prepass start target; the ridge scene has none. */ - readonly startFormat?: 'RGBA32F' | 'RGBA16F'; - /** Start the camera pull-back from the tree. */ - login(): void; - dispose(): void; -} - -function compile( - gl: WebGL2RenderingContext, - type: number, - source: string, - stage: Stage | 'vertex' -): WebGLShader { - const sh = gl.createShader(type)!; - gl.shaderSource(sh, source); - gl.compileShader(sh); - if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { - const log = gl.getShaderInfoLog(sh) ?? ''; - gl.deleteShader(sh); - throw new ShaderCompileError(stage, log); - } - return sh; -} - -export function linkStage( - gl: WebGL2RenderingContext, - stage: Stage, - patch?: SourcePatch, - defines?: string -): WebGLProgram { - const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); - const fs = compile( - gl, - gl.FRAGMENT_SHADER, - patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines), - stage - ); - const prog = gl.createProgram()!; - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - gl.linkProgram(prog); - gl.deleteShader(vs); - gl.deleteShader(fs); - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { - const log = gl.getProgramInfoLog(prog) ?? ''; - gl.deleteProgram(prog); - throw new ShaderCompileError(stage, log); - } - return prog; -} - -interface Target { - tex: WebGLTexture; - w: number; - h: number; -} - -function halton(i: number, base: number): number { - let f = 1; - let r = 0; - let n = i; - while (n > 0) { - f /= base; - r += f * (n % base); - n = Math.floor(n / base); - } - return r; -} - -/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */ -export function createScene(opts: SceneOptions): Scene | null { - const { canvas } = opts; - const tier = opts.tier ?? 'full'; - if (tier === 'off') return null; - const gl = canvas.getContext('webgl2', { - antialias: false, - alpha: false, - depth: false, - stencil: false, - powerPreference: 'low-power', - }); - if (!gl) { - opts.onUnavailable?.('no-webgl2'); - return null; - } - const wide = !!gl.getExtension('EXT_color_buffer_float'); - if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) { - opts.onUnavailable?.('no-float-or-half-float-colour-buffer'); - return null; - } - // Every other target is a half; only the prepass start can ask for 32 bits. - const halfStart = opts.halfStart || !wide; - gl.getExtension('OES_texture_float_linear'); - const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { - TIME_ELAPSED_EXT: number; - GPU_DISJOINT_EXT: number; - } | null; - - const usePrepass = opts.prepass ?? true; - const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass'); - const programs = {} as Record; - try { - passes.forEach((p) => { - const defines = - p === 'terrain' && opts.onTraceStats - ? '#define TRACE_STATS 1\n' - : p === 'prepass' && halfStart - ? HALF_START_DEFINES - : undefined; - programs[p] = linkStage(gl, p, opts.patchSource, defines); - }); - } catch (e) { - passes.forEach((p) => { - if (programs[p]) gl.deleteProgram(programs[p]); - }); - if (!(e instanceof ShaderCompileError)) throw e; - opts.onCompileFail?.(e); - return null; - } - const uniforms = new Map>(); - const loc = (p: WebGLProgram, name: string) => { - let m = uniforms.get(p); - if (!m) uniforms.set(p, (m = new Map())); - if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); - return m.get(name) ?? null; - }; - - const vao = gl.createVertexArray(); - gl.bindVertexArray(vao); - - const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F; - - const makeTarget = (w: number, h: number, format: number, filter: number): Target => { - const tex = gl.createTexture()!; - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - return { tex, w, h }; - }; - - const fbos = { - solid: gl.createFramebuffer()!, - pre: gl.createFramebuffer()!, - atm: gl.createFramebuffer()!, - hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], - }; - let solid: Target; - let depth: Target; - let steps: Target | undefined; - let startTex: Target; - let atm: Target; - let hist: [Target, Target]; - const freeTargets = () => { - [solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => { - if (t) gl.deleteTexture(t.tex); - }); - }; - - const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - targets.forEach((t, i) => - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0) - ); - gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); - }; - - const still = tier === 'still'; - const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); - const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); - - // Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on. - // Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together. - const READBACK_EVERY = 8; - interface Readback { - fence: WebGLSync; - pixels: number; - tiles: number; - } - let readback: Readback | undefined; - const packSteps = opts.onTraceStats ? gl.createBuffer() : null; - const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null; - const discardReadback = () => { - if (readback) gl.deleteSync(readback.fence); - readback = undefined; - }; - const startReadback = () => { - if (!steps || readback) return; - gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); - gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); - gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2); - gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0); - let tiles = 0; - if (packStart) { - tiles = startTex.w * startTex.h; - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); - gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); - gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); - } - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); - gl.pixelStorei(gl.PACK_ALIGNMENT, 4); - readback = { - fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, - pixels: steps.w * steps.h, - tiles, - }; - }; - const pollReadback = () => { - if (!readback) return; - const status = gl.clientWaitSync(readback.fence, 0, 0); - if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return; - const { pixels, tiles } = readback; - const bytes = new Uint8Array(pixels); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); - gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes); - let preSum = 0; - if (packStart) { - const floats = new Float32Array(tiles * 4); - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); - gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats); - for (let i = 1; i < floats.length; i += 4) preSum += floats[i]; - } - gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); - let sum = 0; - for (let i = 0; i < bytes.length; i += 1) sum += bytes[i]; - discardReadback(); - const traceSteps = sum / pixels; - const prepassSteps = tiles ? preSum / tiles : 0; - opts.onTraceStats?.({ - traceSteps, - prepassSteps, - stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels, - }); - }; - - let canvasW = 0; - let canvasH = 0; - let sizedFor: Level | undefined; - let resetHistory = true; - - const ensureSize = () => { - const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); - const w = Math.max(2, Math.round(canvas.clientWidth * dpr)); - const h = Math.max(2, Math.round(canvas.clientHeight * dpr)); - const lv = level(); - if (w === canvasW && h === canvasH && lv === sizedFor) return; - canvasW = w; - canvasH = h; - sizedFor = lv; - canvas.width = w; - canvas.height = h; - freeTargets(); - const sw = Math.max(2, Math.round(w * lv.solidScale)); - const sh = Math.max(2, Math.round(h * lv.solidScale)); - const aw = Math.max(2, Math.round(w * lv.atmosphereScale)); - const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); - solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); - depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); - // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. - startTex = usePrepass - ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST) - : makeTarget(1, 1, startFormat, gl.NEAREST); - steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; - atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); - hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; - attach(fbos.solid, steps ? [solid, depth, steps] : [solid, depth]); - discardReadback(); - attach(fbos.pre, [startTex]); - attach(fbos.atm, [atm]); - attach(fbos.hist[0], [hist[0]]); - attach(fbos.hist[1], [hist[1]]); - resetHistory = true; - }; - - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); - const start = performance.now(); - let loginAt: number | undefined; - let frame = 0; - let histIndex = 0; - let prev: CameraPose | undefined; - let raf = 0; - let lastRendered = -Infinity; - let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this - let disposed = false; - - // Timer queries in flight: each holds one pass's query. - const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; - const finished = new Map>>(); - - const timed = (pass: PassName, f: number, body: () => void) => { - if (!timer || !opts.onStats) { - body(); - return; - } - const q = gl.createQuery()!; - gl.beginQuery(timer.TIME_ELAPSED_EXT, q); - body(); - gl.endQuery(timer.TIME_ELAPSED_EXT); - pending.push({ q, pass, frame: f }); - }; - - const collect = () => { - if (!timer) return; - while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) { - const { q, pass, frame: f } = pending.shift()!; - const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT); - const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number; - gl.deleteQuery(q); - const row = finished.get(f) ?? {}; - if (!disjoint) row[pass] = ns / 1e6; - finished.set(f, row); - if (Object.keys(row).length === passes.length) { - finished.delete(f); - const passMs = { prepass: 0, ...row } as Record; - const total = passes.reduce((s, p) => s + passMs[p], 0); - if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; - opts.onStats?.({ - passMs, - level: ladder.index, - solidSize: [solid.w, solid.h], - atmosphereSize: [atm.w, atm.h], - }); - } - } - }; - - const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => { - const aspect = canvasW / canvasH; - const th = Math.tan(pose.fov / 2); - gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]); - gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]); - gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th); - gl.uniform1f(loc(p, 'u_time'), time); - gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]); - gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]); - gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]); - gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]); - gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]); - }; - - const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => { - gl.activeTexture(gl.TEXTURE0 + unit); - gl.bindTexture(gl.TEXTURE_2D, t.tex); - gl.uniform1i(loc(p, name), unit); - }; - - const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); - - const render = (now: number) => { - ensureSize(); - const lv = level(); - const frozen = reducedMotion?.matches ?? false; - const time = frozen ? 0 : (now - start) / 1000; - let elapsed: number | undefined; - if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; - const pose = cameraPose(opts.fixedPull ?? pullProgress(elapsed), time); - const f = frame; - frame += 1; - const jx = halton((f % 16) + 1, 2) - 0.5; - const jy = halton((f % 16) + 1, 3) - 0.5; - - // Pass 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays. - if (usePrepass) { - timed('prepass', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); - gl.viewport(0, 0, startTex.w, startTex.h); - const p = programs.prepass; - gl.useProgram(p); - setCommon(p, pose, time); - gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); - gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h); - draw(); - }); - } - - // Pass 1: solid surfaces, writes colour and depth. - timed('terrain', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); - gl.viewport(0, 0, solid.w, solid.h); - const p = programs.terrain; - gl.useProgram(p); - setCommon(p, pose, time); - gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]); - gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]); - gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); - gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); - gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); - bindTex(0, startTex, p, 'u_start'); - draw(); - }); - if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost - - // Pass 2: atmosphere up to pass 1's depth, low-res. - timed('atmosphere', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm); - gl.viewport(0, 0, atm.w, atm.h); - const p = programs.atmosphere; - gl.useProgram(p); - setCommon(p, pose, time); - bindTex(0, depth, p, 'u_depth'); - gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps); - gl.uniform1f(loc(p, 'u_frame'), f); - gl.uniform2f(loc(p, 'u_jitter'), jx, jy); - gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h); - gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45); - draw(); - }); - - // TAA resolve into the history that is not being read. - const write = histIndex === 0 ? 1 : 0; - timed('resolve', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); - gl.viewport(0, 0, atm.w, atm.h); - const p = programs.resolve; - gl.useProgram(p); - setCommon(p, pose, time); - bindTex(0, atm, p, 'u_cur'); - bindTex(1, hist[histIndex], p, 'u_history'); - bindTex(2, depth, p, 'u_depth'); - const pp = prev ?? pose; - const th = Math.tan(pp.fov / 2); - gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]); - gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]); - gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th); - gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125); - gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h); - draw(); - }); - - // Full-resolution composite to the canvas. - timed('composite', f, () => { - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.viewport(0, 0, canvasW, canvasH); - const p = programs.composite; - gl.useProgram(p); - setCommon(p, pose, time); - bindTex(0, solid, p, 'u_solid'); - bindTex(1, hist[write], p, 'u_atm'); - gl.uniform1f(loc(p, 'u_frame'), f); - draw(); - }); - - histIndex = write; - prev = pose; - resetHistory = false; - collect(); - pollReadback(); - }; - - const tick = (now: number) => { - raf = 0; - if (disposed) return; - const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; - if (due) { - if ( - !opts.fixedLevel && - !timer && - lastRendered > 0 && - now - lastRendered > FRAME_INTERVAL_MS * 1.5 - ) { - // no timer: a missed cap counts as over budget - if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; - } - lastRendered = now; - render(now); - framesLeft -= 1; - } - if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) - raf = requestAnimationFrame(tick); - }; - - const wake = () => { - if (disposed || raf) return; - if (reducedMotion?.matches && framesLeft <= 0) return; - resetHistory = true; // the first frame after a pause has a real elapsed dt - raf = requestAnimationFrame(tick); - }; - const undo: Array<() => void> = []; - const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { - target.addEventListener(type, handler); - undo.push(() => target.removeEventListener(type, handler)); - }; - const stop = () => { - disposed = true; - if (raf) cancelAnimationFrame(raf); - undo.forEach((f) => f()); - }; - if (!opts.unthrottled) { - listen(document, 'visibilitychange', () => { - if (document.visibilityState === 'visible') wake(); - }); - } - listen(window, 'resize', () => wake()); - // A lost context takes every texture and program with it and nothing here rebuilds them, so the - // scene ends and the host decides what to show. preventDefault is what lets the browser restore - // the context later (without it a lost context stays lost); the host then builds a new scene on - // `webglcontextrestored`, and this one stays dead. - listen(canvas, 'webglcontextlost', (e) => { - e.preventDefault(); - stop(); - opts.onContextLost?.(); - }); - if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; - // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. - if (reducedMotion && !still) { - listen(reducedMotion, 'change', () => { - framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; - wake(); - }); - } - wake(); - - return { - startFormat: halfStart ? 'RGBA16F' : 'RGBA32F', - login() { - if (still || disposed) return; // a still has no pull-back - loginAt = performance.now(); - if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; - wake(); - }, - dispose() { - if (disposed) return; - stop(); - freeTargets(); - passes.forEach((p) => gl.deleteProgram(programs[p])); - discardReadback(); - [packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b)); - gl.deleteVertexArray(vao); - [fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); - }, - }; -} diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx deleted file mode 100644 index b664dc07..00000000 --- a/src/app/features/scene/SkyStateControl.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { MouseEventHandler, useState } from 'react'; -import { - Box, - config, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { stopPropagation } from '../../utils/keyboard'; -import { useSkyState } from '../../hooks/useSkyState'; -import { SKY_STATES, setSkyState, type SkyState } from './skyState'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { - getSceneStyleOverride, - SERVED_SCENE_STYLES, - setSceneStyle, - type ServedSceneStyle, -} from './sceneStyle'; - -const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' }; - -const LABEL: Record = { - auto: 'Auto', - day: 'Day', - dusk: 'Dusk', - night: 'Night', -}; - -/** - * One title-bar button that opens the four sky states. A single button, not a row, so the bar - * still fits a 320 px phone beside the search button; the menu items carry the state names. - */ -export function SkyStateControl() { - const state = useSkyState(); - const [cords, setCords] = useState(); - const backgroundScene = useClientConfig().backgroundScene; - const configuredStyle = backgroundScene?.style; - const sceneStyle: ServedSceneStyle = - getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge'); - - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - return ( - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - {SKY_STATES.map((s) => ( - { - setSkyState(s); - setCords(undefined); - }} - after={state === s ? : undefined} - > - - {LABEL[s]} - - - ))} - {backgroundScene && ( - <> - - Background - - {SERVED_SCENE_STYLES.map((st) => ( - { - setCords(undefined); - if (st !== sceneStyle) setSceneStyle(st); - }} - after={sceneStyle === st ? : undefined} - > - - {STYLE_LABEL[st]} - - - ))} - - )} - - - - } - > - - - - - ); -} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts deleted file mode 100644 index 9ac8f0af..00000000 --- a/src/app/features/scene/budget.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { BudgetLadder, LEVELS } from './budget'; - -describe('budget ladder', () => { - it('steps down after a whole window over budget and never back up', () => { - const ladder = new BudgetLadder(4, 5); - Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); - expect(ladder.push(9)).toBe(true); - expect(ladder.index).toBe(1); - Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level - expect(ladder.index).toBe(1); - }); - - it('judges the median, so one slow frame does not step down', () => { - const ladder = new BudgetLadder(4, 5); - [1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms)); - expect(ladder.index).toBe(0); - }); - - it('stops at the last level', () => { - const ladder = new BudgetLadder(4, 1); - Array.from({ length: 20 }).forEach(() => ladder.push(99)); - expect(ladder.index).toBe(LEVELS.length - 1); - }); - - it('every level is cheaper than the one before in each cost axis', () => { - LEVELS.slice(1).forEach((lv, i) => { - const before = LEVELS[i]; - expect(lv.solidScale).toBeLessThan(before.solidScale); - expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale); - expect(lv.cloudSteps).toBeLessThan(before.cloudSteps); - expect(lv.traceSteps).toBeLessThan(before.traceSteps); - }); - }); - - it('starts at the level it is given, clamped into the ladder', () => { - expect(new BudgetLadder(4, 5, 2).index).toBe(2); - expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1); - expect(new BudgetLadder(4, 5, -3).index).toBe(0); - }); -}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts deleted file mode 100644 index 3b373d86..00000000 --- a/src/app/features/scene/budget.ts +++ /dev/null @@ -1,59 +0,0 @@ -// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up. - -export interface Level { - /** Pass 1 resolution as a fraction of the canvas. */ - solidScale: number; - /** Pass 2 resolution as a fraction of the canvas. */ - atmosphereScale: number; - /** Cloud march steps. */ - cloudSteps: number; - /** Sphere-trace step cap. */ - traceSteps: number; -} - -export const LEVELS: readonly Level[] = [ - { solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 }, - { solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 }, - { solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 }, - { solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 }, -]; - -/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ -export class BudgetLadder { - private level: number; - - private recent: number[] = []; - - private readonly budgetMs: number; - - private readonly window: number; - - /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ - constructor(budgetMs: number, window = 45, startLevel = 0) { - this.budgetMs = budgetMs; - this.window = window; - this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); - } - - get current(): Level { - return LEVELS[this.level]; - } - - get index(): number { - return this.level; - } - - /** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */ - push(ms: number): boolean { - this.recent.push(ms); - if (this.recent.length < this.window) return false; - const sorted = [...this.recent].sort((a, b) => a - b); - const median = sorted[Math.floor(sorted.length / 2)]; - this.recent = []; - if (median > this.budgetMs && this.level < LEVELS.length - 1) { - this.level += 1; - return true; - } - return false; - } -} diff --git a/src/app/features/scene/camera.test.ts b/src/app/features/scene/camera.test.ts deleted file mode 100644 index f7199a65..00000000 --- a/src/app/features/scene/camera.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera'; -import { WORLD } from './world'; - -const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]); - -describe('login pull-back', () => { - it('is at rest before login and complete after PULL_BACK_SECONDS', () => { - expect(pullProgress(undefined)).toBe(0); - expect(pullProgress(0)).toBe(0); - expect(pullProgress(PULL_BACK_SECONDS)).toBe(1); - expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1); - }); - - it('only moves away from the tree and never reverses', () => { - let last = -1; - for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) { - const d = dist(cameraPose(pullProgress(s), 0).position); - expect(d).toBeGreaterThanOrEqual(last); - last = d; - } - }); - - it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => { - for (let t = 0; t < 2000; t += 37) { - expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner); - } - }); - - it('looks at the tree: forward points at the axis and the basis is orthonormal', () => { - const pose = cameraPose(0.5, 12); - const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis; - const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; - expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6); - expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6); - expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6); - expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6); - // Forward points back across the tree axis, not away from it. - const [px, , pz] = pose.position; - expect(fx * px + fz * pz).toBeLessThan(0); - }); -}); diff --git a/src/app/features/scene/camera.ts b/src/app/features/scene/camera.ts deleted file mode 100644 index 2b08f0d1..00000000 --- a/src/app/features/scene/camera.ts +++ /dev/null @@ -1,56 +0,0 @@ -// The scene's camera: close under the tree at rest, pulled back on login, drifting after. -// Pure functions of time so the pull-back is testable without a GL context. - -export type Vec3 = readonly [number, number, number]; - -export interface CameraPose { - position: Vec3; - /** Column-major right, up, forward: the layout of the shader's mat3 uniform. */ - basis: readonly [number, number, number, number, number, number, number, number, number]; - /** Vertical field of view in radians. */ - fov: number; -} - -export const PULL_BACK_SECONDS = 6; - -// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground. -const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 }; -const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 }; -const DRIFT_RAD_PER_SEC = 0.025; - -const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10); -const lerp = (a: number, b: number, t: number) => a + (b - a) * t; - -/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */ -export function pullProgress(elapsed: number | undefined): number { - if (elapsed === undefined || elapsed <= 0) return 0; - return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS)); -} - -function norm(v: Vec3): Vec3 { - const l = Math.hypot(v[0], v[1], v[2]) || 1; - return [v[0] / l, v[1] / l, v[2] / l]; -} -function cross(a: Vec3, b: Vec3): Vec3 { - return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; -} - -/** - * @param pull 0..1 from pullProgress - * @param time seconds since the scene started, for the slow orbit - */ -export function cameraPose(pull: number, time: number): CameraPose { - const radius = lerp(NEAR.radius, FAR.radius, pull); - const camY = lerp(NEAR.camY, FAR.camY, pull); - const targetY = lerp(NEAR.targetY, FAR.targetY, pull); - const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC; - const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)]; - const forward = norm([-position[0], targetY - position[1], -position[2]]); - const right = norm(cross([0, 1, 0], forward)); - const up = cross(forward, right); - return { - position, - basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'], - fov: lerp(NEAR.fov, FAR.fov, pull), - }; -} diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts deleted file mode 100644 index 938c217f..00000000 --- a/src/app/features/scene/creatures.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures'; - -const buckets: SizeBucket[] = ['small', 'medium', 'large']; - -describe('creature scale', () => { - it('follows sizeBucket for a creature with an estate', () => { - buckets.forEach((sizeBucket) => - expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket]) - ); - }); - - it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { - expect(creatureScale({ neutral: true })).toBeNull(); - buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); - }); - - it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { - expect(creatureScale({ neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull(); - }); - - it('draws nothing for an unknown bucket, including inherited property names', () => { - expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull(); - }); -}); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts deleted file mode 100644 index a0b2bd29..00000000 --- a/src/app/features/scene/creatures.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. -// -// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). -// Nothing here is binding until Bifrost's steward cuts that contract. -// -// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false. -// Invariants: -// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. -// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is -// ignored, and a neutral creature is not drawn. -// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted. -// - activity comes from the Heimdall session. -// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. -// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. - -export type SizeBucket = 'small' | 'medium' | 'large'; - -export interface Creature { - id: string; - estate: string; - /** Absent when neutral. Read it only through creatureScale. */ - sizeBucket?: SizeBucket; - neutral: boolean; - activity: 'idle' | 'active'; -} - -export const BUCKET_SCALE: Readonly> = { - small: 0.7, - medium: 1, - large: 1.4, -}; - -/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ -export function creatureScale(c: Pick): number | null { - if (c.neutral || c.sizeBucket === undefined) return null; - return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; -} diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts deleted file mode 100644 index cef6b281..00000000 --- a/src/app/features/scene/cultmathTrim.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { trimGlsl } from './cultmathTrim'; -import { fragmentSource, Stage } from './shaderSource'; -import cultmath from './shaders/CultMath.glsl?raw'; - -const LIB = `// header -#ifndef LIB_GLSL -#define LIB_GLSL -const float LIB_PI = 3.14; - -float lib_half(float v) { return v * 0.5; } -vec2 lib_half(vec2 v) { return v * 0.5; } - -// uses the constant and an overload set -float lib_pi_half(float v) { return lib_half(v) * LIB_PI; } - -struct LibPair { float a; float b; }; - -LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; } -float lib_unused(float v) { return v; } -#endif -`; - -describe('trimGlsl', () => { - it('keeps what a root names and what that reaches, and drops the rest', () => { - const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']); - expect(out).toContain('lib_pi_half'); - expect(out).toContain('LIB_PI'); - expect(out).toContain('float lib_half'); - expect(out).toContain('vec2 lib_half'); // overloads stay together - expect(out).not.toContain('lib_unused'); - expect(out).not.toContain('LibPair'); - expect(out).not.toContain('lib_pair'); - }); - - it('keeps a struct that a kept function returns', () => { - const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']); - expect(out).toContain('struct LibPair'); - expect(out).toContain('lib_half'); - expect(out).not.toContain('lib_pi_half'); - }); - - it('keeps the include guard balanced', () => { - const out = trimGlsl(LIB, ['lib_half(1.0)']); - expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/); - expect(out.trimEnd().endsWith('#endif')).toBe(true); - }); - - it('keeps nothing but the guard for a root that names nothing', () => { - const out = trimGlsl(LIB, ['void main() {}']); - expect(out).not.toContain('lib_'); - expect(out).toContain('#ifndef LIB_GLSL'); - }); -}); - -describe('CultMath per shader stage', () => { - const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite']; - const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []); - const defined = (src: string, name: string) => - new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src); - - it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => { - const src = fragmentSource(stage); - cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true)); - }); - - it.each(stages)('%s is smaller than the whole library', (stage) => { - expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2); - }); - - it('puts the noise a stage calls in that stage and not in the others', () => { - expect(fragmentSource('terrain')).toContain('cultmath_snoise'); - expect(fragmentSource('composite')).not.toContain('cultmath_snoise'); - }); - - it('adds Phacelle to terrain alone, as its own file', () => { - expect(fragmentSource('terrain')).toContain('cultmath_phacelle'); - stages - .filter((s) => s !== 'terrain') - .forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle')); - }); -}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts deleted file mode 100644 index fc246e5c..00000000 --- a/src/app/features/scene/cultmathTrim.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never - * edited; each stage compiles only the declarations its own source (and the source concatenated - * beside it) names, directly or through other kept declarations. Overloads share a name and are - * kept together. - */ - -interface Decl { - name: string; - text: string; -} - -const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m; -const GUARD_CLOSE = /\n#endif\s*$/; - -/** Name a top-level declaration defines: function, struct or const. */ -function declName(text: string): string | undefined { - const code = text.replace(/\/\/.*$/gm, ''); - if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1]; - return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1]; -} - -/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */ -function declarations(body: string): Decl[] { - const out: Decl[] = []; - let depth = 0; - let start = 0; - let i = 0; - while (i < body.length) { - const c = body[i]; - if (c === '/' && body[i + 1] === '/') { - const eol = body.indexOf('\n', i); - i = eol < 0 ? body.length : eol; - } else if (c === '{') { - depth += 1; - } else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) { - if (c === '}') depth -= 1; - // a struct's closing brace is followed by `;` - const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i; - const text = body.slice(start, end + 1); - const name = declName(text); - if (name) out.push({ name, text }); - start = end + 1; - i = end; - } else if (c === '}') { - depth -= 1; - } - i += 1; - } - return out; -} - -const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? []; - -/** `library` trimmed to what `roots` reach; the include guard is kept. */ -export function trimGlsl(library: string, roots: string[]): string { - const open = GUARD_OPEN.exec(library)?.[0] ?? ''; - const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : ''; - const body = library - .slice(open ? library.indexOf(open) + open.length : 0) - .replace(GUARD_CLOSE, ''); - const decls = declarations(body); - const byName = new Map(); - decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); - - const kept = new Set(); - const queue = roots.flatMap(idents); - while (queue.length) { - const group = byName.get(queue.pop() ?? ''); - group?.forEach((d) => { - if (kept.has(d)) return; - kept.add(d); - queue.push(...idents(d.text)); - }); - } - const text = decls - .filter((d) => kept.has(d)) - .map((d) => d.text.replace(/^\s*\n/, '')) - .join('\n'); - return `${open}${text}${close}`; -} diff --git a/src/app/features/scene/debugOverlays.test.ts b/src/app/features/scene/debugOverlays.test.ts deleted file mode 100644 index e1a4e61e..00000000 --- a/src/app/features/scene/debugOverlays.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { debugOverlaysAllowed, overlayRequested } from './debugOverlays'; - -let search = ''; -beforeEach(() => { - search = ''; - vi.stubGlobal('window', { - get location() { - return { search }; - }, - }); -}); -afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); -}); - -describe('debugOverlaysAllowed', () => { - it('a production build allows nothing by default', () => { - vi.stubEnv('DEV', false); - vi.stubEnv('VITE_PREVIEW_DEMO', ''); - expect(debugOverlaysAllowed(undefined)).toBe(false); - expect(debugOverlaysAllowed({ tier: 'full' })).toBe(false); - expect(debugOverlaysAllowed({ tier: 'full', debugOverlays: false })).toBe(false); - }); - - it('a dev build, a demo preview build or an explicit config flag allows them', () => { - vi.stubEnv('DEV', true); - expect(debugOverlaysAllowed(undefined)).toBe(true); - vi.stubEnv('DEV', false); - vi.stubEnv('VITE_PREVIEW_DEMO', '1'); - expect(debugOverlaysAllowed(undefined)).toBe(true); - vi.stubEnv('VITE_PREVIEW_DEMO', ''); - expect(debugOverlaysAllowed({ debugOverlays: true })).toBe(true); - }); -}); - -describe('overlayRequested', () => { - it('needs both the query and the permission', () => { - search = '?probe=1'; - expect(overlayRequested('probe', true)).toBe(true); - expect(overlayRequested('probe', false)).toBe(false); - expect(overlayRequested('tierlabel', true)).toBe(false); - search = '?tierlabel=1'; - expect(overlayRequested('tierlabel', true)).toBe(true); - search = ''; - expect(overlayRequested('probe', true)).toBe(false); - }); -}); diff --git a/src/app/features/scene/debugOverlays.ts b/src/app/features/scene/debugOverlays.ts deleted file mode 100644 index a02d9e92..00000000 --- a/src/app/features/scene/debugOverlays.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { BackgroundSceneConfig } from './tier'; - -/** - * Whether the page URL may switch on the scene's diagnostic overlays (`?probe=1` frame readout, `?tierlabel=1` path - * label). Only a dev build, a demo preview build (VITE_PREVIEW_DEMO=1), or a config that says `debugOverlays: true` - * allows it; a production visitor adding a query string gets nothing. - */ -export const debugOverlaysAllowed = (config: BackgroundSceneConfig | undefined): boolean => - import.meta.env?.DEV === true || - import.meta.env?.VITE_PREVIEW_DEMO === '1' || - config?.debugOverlays === true; - -/** The page URL asks for this overlay (`?name=1`), and the overlays are allowed. */ -export const overlayRequested = (name: 'probe' | 'tierlabel', allowed: boolean): boolean => - allowed && new URLSearchParams(window.location.search).get(name) === '1'; diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts deleted file mode 100644 index b987d0cc..00000000 --- a/src/app/features/scene/host.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Scene } from './index'; -import { mountBackgroundScene } from './host'; - -const { startBackgroundScene } = vi.hoisted(() => ({ startBackgroundScene: vi.fn() })); -vi.mock('./index', () => ({ startBackgroundScene })); - -let canvas: HTMLCanvasElement; -const config = { tier: 'low' as const }; -const caps = { hardwareConcurrency: 8 }; - -function fakeScene(): Scene & { - login: ReturnType; - dispose: ReturnType; -} { - return { login: vi.fn(), dispose: vi.fn() }; -} -const flush = () => - new Promise((r) => { - setTimeout(r, 0); - }); - -beforeEach(() => { - canvas = new EventTarget() as HTMLCanvasElement; -}); - -afterEach(() => { - startBackgroundScene.mockReset(); -}); - -describe('mountBackgroundScene', () => { - it('starts the scene on the canvas with the config and capabilities', async () => { - const scene = fakeScene(); - startBackgroundScene.mockResolvedValue(scene); - const onActive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); - await flush(); - expect(startBackgroundScene).toHaveBeenCalledWith( - config, - caps, - expect.objectContaining({ canvas }) - ); - expect(onActive).toHaveBeenCalledOnce(); - expect(scene.login).not.toHaveBeenCalled(); - }); - - it('disposes the scene on unmount, once', async () => { - const scene = fakeScene(); - startBackgroundScene.mockResolvedValue(scene); - const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); - await flush(); - dispose(); - dispose(); - expect(scene.dispose).toHaveBeenCalledOnce(); - }); - - it('disposes a scene that resolves after unmount, without activating or logging in', async () => { - const scene = fakeScene(); - let resolve!: (s: Scene) => void; - startBackgroundScene.mockReturnValue( - new Promise((r) => { - resolve = r; - }) - ); - const onActive = vi.fn(); - const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); - dispose(); - resolve(scene); - await flush(); - expect(scene.dispose).toHaveBeenCalledOnce(); - expect(scene.login).not.toHaveBeenCalled(); - expect(onActive).not.toHaveBeenCalled(); - }); - - it('leaves the ground alone when there is no scene', async () => { - startBackgroundScene.mockResolvedValue(null); - const onActive = vi.fn(); - const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); - await flush(); - expect(onActive).not.toHaveBeenCalled(); - expect(dispose).not.toThrow(); - }); - - it('reports a failed start instead of throwing', async () => { - startBackgroundScene.mockRejectedValue(new Error('boom')); - const onFail = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onFail }); - await flush(); - expect(onFail).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); - }); - - it('forwards the scene own compile failure to onFail', async () => { - startBackgroundScene.mockImplementation(async (_c, _k, o) => { - o.onCompileFail(new Error('shader')); - return null; - }); - const onFail = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onFail }); - await flush(); - expect(onFail).toHaveBeenCalledWith(expect.objectContaining({ message: 'shader' })); - }); - - it('calls login() once the scene starts when a session already exists', async () => { - const scene = fakeScene(); - startBackgroundScene.mockResolvedValue(scene); - mountBackgroundScene(config, caps, canvas, { loggedIn: true }); - await flush(); - expect(scene.login).toHaveBeenCalledOnce(); - }); - - it('hands the door its onInactive and onActive, for a sky-state rebuild that makes no scene', async () => { - startBackgroundScene.mockResolvedValue(fakeScene()); - const onInactive = vi.fn(); - const onActive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive, onActive }); - await flush(); - const given = startBackgroundScene.mock.calls[0][2]; - onActive.mockClear(); - given.onInactive(); - given.onActive(); - expect(onInactive).toHaveBeenCalledOnce(); - expect(onActive).toHaveBeenCalledOnce(); - }); - - describe('a lost WebGL context', () => { - const lose = () => - startBackgroundScene.mock.calls[ - startBackgroundScene.mock.calls.length - 1 - ][2].onContextLost(); - const restore = () => canvas.dispatchEvent(new Event('webglcontextrestored')); - - it('disposes the dead scene and puts the ground back, without rebuilding yet', async () => { - const scene = fakeScene(); - startBackgroundScene.mockResolvedValue(scene); - const onInactive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); - await flush(); - lose(); - expect(scene.dispose).toHaveBeenCalledOnce(); - expect(onInactive).toHaveBeenCalledOnce(); - expect(startBackgroundScene).toHaveBeenCalledOnce(); - }); - - it('starts a fresh scene on restore, activates it and logs in when a session exists', async () => { - const first = fakeScene(); - const second = fakeScene(); - startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); - const onActive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); - await flush(); - lose(); - restore(); - await flush(); - expect(startBackgroundScene).toHaveBeenCalledTimes(2); - expect(onActive).toHaveBeenCalledTimes(2); - expect(second.login).toHaveBeenCalledOnce(); - expect(first.dispose).toHaveBeenCalledOnce(); - }); - - it('rebuilds on every loss and restore, disposing each dead scene once', async () => { - const scenes = [fakeScene(), fakeScene(), fakeScene()]; - scenes.forEach((s) => startBackgroundScene.mockResolvedValueOnce(s)); - mountBackgroundScene(config, caps, canvas, { loggedIn: false }); - await flush(); - lose(); - restore(); - await flush(); - lose(); - restore(); - await flush(); - expect(scenes.map((s) => s.dispose.mock.calls.length)).toEqual([1, 1, 0]); - }); - - it('ignores a restore while a scene is running', async () => { - startBackgroundScene.mockResolvedValue(fakeScene()); - mountBackgroundScene(config, caps, canvas, { loggedIn: false }); - await flush(); - restore(); - await flush(); - expect(startBackgroundScene).toHaveBeenCalledOnce(); - }); - - it('leaves the ground when the rebuild yields no scene', async () => { - startBackgroundScene.mockResolvedValueOnce(fakeScene()).mockResolvedValueOnce(null); - const onActive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); - await flush(); - lose(); - restore(); - await flush(); - expect(onActive).toHaveBeenCalledOnce(); - }); - - it('does not rebuild after dispose, and removes its restore listener', async () => { - startBackgroundScene.mockResolvedValue(fakeScene()); - const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); - await flush(); - lose(); - dispose(); - restore(); - await flush(); - expect(startBackgroundScene).toHaveBeenCalledOnce(); - }); - - it('removes the exact restore listener it added when it is disposed', async () => { - const add = vi.spyOn(canvas, 'addEventListener'); - const remove = vi.spyOn(canvas, 'removeEventListener'); - startBackgroundScene.mockResolvedValue(fakeScene()); - const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); - await flush(); - const added = add.mock.calls.find(([type]) => type === 'webglcontextrestored'); - expect(added).toBeDefined(); - expect(remove).not.toHaveBeenCalled(); - dispose(); - expect(remove).toHaveBeenCalledWith('webglcontextrestored', added?.[1]); - }); - - it('treats a second loss from the same scene as nothing', async () => { - const scene = fakeScene(); - startBackgroundScene.mockResolvedValue(scene); - const onInactive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); - await flush(); - lose(); - lose(); - expect(onInactive).toHaveBeenCalledOnce(); - expect(scene.dispose).toHaveBeenCalledOnce(); - }); - - it('ignores the old scene loss callback once a new scene has started', async () => { - const first = fakeScene(); - const second = fakeScene(); - startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); - const onInactive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); - await flush(); - const oldLost = startBackgroundScene.mock.calls[0][2].onContextLost; - lose(); - restore(); - await flush(); - onInactive.mockClear(); - oldLost(); - expect(second.dispose).not.toHaveBeenCalled(); - expect(onInactive).not.toHaveBeenCalled(); - }); - - it('drops a scene that resolves after a loss that arrived while it started', async () => { - const late = fakeScene(); - let resolve!: (s: Scene) => void; - startBackgroundScene.mockReturnValueOnce( - new Promise((r) => { - resolve = r; - }) - ); - const onActive = vi.fn(); - mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); - lose(); - resolve(late); - await flush(); - expect(late.dispose).toHaveBeenCalledOnce(); - expect(onActive).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts deleted file mode 100644 index 9a31c812..00000000 --- a/src/app/features/scene/host.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { startBackgroundScene } from './index'; -import type { Scene } from './index'; -import type { BackgroundSceneConfig, Capabilities } from './tier'; - -export type HostOptions = { - /** A Matrix session already exists, so the scene starts past its login moment. */ - loggedIn: boolean; - /** The scene is running: the host may now let it show through. Not called when there is none. */ - onActive?: () => void; - /** The scene stopped because its GL context was lost: put the CSS ground back until it restores. */ - onInactive?: () => void; - /** A shader failed to compile, the chunk failed to load, or starting threw. The CSS ground stays. */ - onFail?: (e: Error) => void; -}; - -/** - * Start the background scene on `canvas` and return the function that ends it. The start is async, - * so a dispose that arrives first wins: the scene is disposed the moment it resolves. A null scene - * (tier off, no 2D context, load failure) leaves the canvas untouched and the ground showing. - * - * A lost WebGL context ends the scene it killed: the host disposes it, calls `onInactive` so the - * CSS ground shows, and starts a fresh scene when the canvas's context is restored. The 2D sky - * cannot take over on this canvas (a canvas that has held a WebGL context gives no 2D one), so the - * ground is the fallback while the context is gone; a restore that cannot rebuild leaves it there. - */ -export function mountBackgroundScene( - config: BackgroundSceneConfig | undefined, - capabilities: Capabilities, - canvas: HTMLCanvasElement, - { loggedIn, onActive, onInactive, onFail }: HostOptions -): () => void { - let cancelled = false; - let scene: Scene | null = null; - let generation = 0; - - const start = () => { - generation += 1; - const mine = generation; - const stale = () => cancelled || mine !== generation; - startBackgroundScene(config, capabilities, { - canvas, - onCompileFail: onFail, - onInactive, - onActive, - onContextLost: () => { - if (stale()) return; - generation += 1; - scene?.dispose(); - scene = null; - onInactive?.(); - }, - }).then( - (started) => { - if (stale()) { - started?.dispose(); - return; - } - scene = started; - if (!scene) return; - onActive?.(); - if (loggedIn) scene.login(); - }, - (e) => { - if (!stale()) onFail?.(e instanceof Error ? e : new Error(String(e))); - } - ); - }; - - const onRestored = () => { - if (!cancelled && !scene) start(); - }; - canvas.addEventListener('webglcontextrestored', onRestored); - start(); - - return () => { - cancelled = true; - canvas.removeEventListener('webglcontextrestored', onRestored); - scene?.dispose(); - scene = null; - }; -} diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts deleted file mode 100644 index 23c6b297..00000000 --- a/src/app/features/scene/index.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { atStandardTime } from './standardTime.testkit'; -import { baked } from '../../../sky/baked'; -import type { Scene } from './Scene'; -import { startBackgroundScene } from './index'; -import { setSkyClock, setSkyState } from './skyState'; -import { ridgeSkyFor } from './skyForState'; - -const { createRidgeScene, createScene } = vi.hoisted(() => ({ - createRidgeScene: vi.fn(), - createScene: vi.fn(), -})); -vi.mock('./ridge/Ridge', () => ({ createRidgeScene })); -vi.mock('./Scene', () => ({ createScene })); - -const canvas = {} as HTMLCanvasElement; -const config = { tier: 'low' as const }; -const caps = { hardwareConcurrency: 8 }; -const flush = () => - new Promise((r) => { - setTimeout(r, 0); - }); -// Resolves after `n` further turns of the microtask queue. -const microtasks = (n: number) => - Array.from({ length: n }).reduce>( - (p) => p.then(() => undefined), - Promise.resolve() - ); -const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); -const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; - -beforeEach(() => { - vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); - // Local midnight: 'auto' resolves to night. - setSkyClock(() => atStandardTime(0, 0)); - setSkyState('auto'); - createRidgeScene.mockReset(); - createScene.mockReset(); -}); -afterEach(() => { - setSkyClock(); - setSkyState('auto'); - vi.unstubAllGlobals(); -}); - -describe('startBackgroundScene follows the sky state', () => { - it('hands the ridge scene the sky auto resolves to (night at midnight)', async () => { - createRidgeScene.mockReturnValue(fakeScene()); - const scene = await startBackgroundScene(config, caps, { canvas }); - expect(createRidgeScene).toHaveBeenCalledOnce(); - expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); - scene?.dispose(); - }); - - it('does not rebuild when auto is replaced by the explicit state it already resolves to', async () => { - const a = fakeScene(); - createRidgeScene.mockReturnValueOnce(a); - const scene = await startBackgroundScene(config, caps, { canvas }); - setSkyState('night'); - await flush(); - expect(a.dispose).not.toHaveBeenCalled(); - expect(createRidgeScene).toHaveBeenCalledOnce(); - scene?.dispose(); - }); - - it('starts on the stored state', async () => { - setSkyState('night'); - createRidgeScene.mockReturnValue(fakeScene()); - const scene = await startBackgroundScene(config, caps, { canvas }); - expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); - scene?.dispose(); - }); - - it('rebuilds on a change: the old scene is disposed, the new one draws the new sky', async () => { - const first = fakeScene(); - const second = fakeScene(); - createRidgeScene.mockReturnValueOnce(first).mockReturnValueOnce(second); - const scene = await startBackgroundScene(config, caps, { canvas }); - setSkyState('day'); - await flush(); - expect(first.dispose).toHaveBeenCalledOnce(); - expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); - scene?.dispose(); - expect(second.dispose).toHaveBeenCalledOnce(); - }); - - it('rebuilds for a change that landed while the first build was awaited', async () => { - const [a, b] = [fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); - const started = startBackgroundScene(config, caps, { canvas }); - setSkyState('day'); - const scene = await started; - await flush(); - expect(createRidgeScene).toHaveBeenCalledTimes(2); - expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); - expect(a.dispose).toHaveBeenCalledOnce(); - scene?.dispose(); - expect(b.dispose).toHaveBeenCalledOnce(); - }); - - it('replays a login onto the rebuilt scene, but only if one happened', async () => { - const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); - const scene = await startBackgroundScene(config, caps, { canvas }); - setSkyState('day'); - await flush(); - expect(b.login).not.toHaveBeenCalled(); - scene?.login(); - expect(b.login).toHaveBeenCalledOnce(); - setSkyState('dusk'); - await flush(); - expect(c.login).toHaveBeenCalledOnce(); - scene?.dispose(); - }); - - it('puts the ground back when a rebuild makes no scene, and lets it show when one returns', async () => { - const [a, c] = [fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(null).mockReturnValueOnce(c); - const onInactive = vi.fn(); - const onActive = vi.fn(); - const scene = await startBackgroundScene(config, caps, { canvas, onInactive, onActive }); - expect(onInactive).not.toHaveBeenCalled(); - setSkyState('day'); - await flush(); - expect(onInactive).toHaveBeenCalledOnce(); - expect(onActive).not.toHaveBeenCalled(); - setSkyState('dusk'); - await flush(); - expect(onActive).toHaveBeenCalledOnce(); - expect(onInactive).toHaveBeenCalledOnce(); - scene?.dispose(); - }); - - it('does not report a dropped rebuild as inactive', async () => { - createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(null); - const onInactive = vi.fn(); - const scene = await startBackgroundScene(config, caps, { canvas, onInactive }); - setSkyState('day'); - scene?.dispose(); - await flush(); - expect(onInactive).not.toHaveBeenCalled(); - }); - - it('removes the subscription on dispose', async () => { - const first = fakeScene(); - createRidgeScene.mockReturnValue(first); - const scene = await startBackgroundScene(config, caps, { canvas }); - scene?.dispose(); - scene?.dispose(); - setSkyState('day'); - await flush(); - expect(createRidgeScene).toHaveBeenCalledOnce(); - expect(first.dispose).toHaveBeenCalledOnce(); - }); - - it('builds nothing for a rebuild that is disposed before it starts', async () => { - createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(fakeScene()); - const scene = await startBackgroundScene(config, caps, { canvas }); - setSkyState('day'); - scene?.dispose(); - await flush(); - expect(createRidgeScene).toHaveBeenCalledOnce(); - }); - - it('keeps only the latest of two quick changes: the last sky is built, the others are not left running', async () => { - const scenes = [fakeScene(), fakeScene(), fakeScene()]; - scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); - const scene = await startBackgroundScene(config, caps, { canvas }); - setSkyState('day'); - setSkyState('dusk'); - await flush(); - // The sky last built is dusk's, and it is the live scene: a login reaches it and nothing else. - const built = createRidgeScene.mock.calls.length; - expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( - hex(ridgeSkyFor('dusk').top) - ); - const live = scenes[built - 1]; - scene?.login(); - expect(live.login).toHaveBeenCalledOnce(); - scenes.filter((s) => s !== live).forEach((s) => expect(s.login).not.toHaveBeenCalled()); - // Every scene but the live one was disposed, the live one not yet. - scenes.slice(0, built).forEach((s) => { - expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); - }); - scene?.dispose(); - expect(live.dispose).toHaveBeenCalledOnce(); - }); - - // A second sky change `ticks` microtasks after the first. True when it landed mid-build: the build for the first - // change finished, was found stale and dropped (never shown), and only the newest sky was announced. - const changeAfter = async (ticks: number) => { - createRidgeScene.mockReset(); - setSkyState('auto'); - const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); - const onActive = vi.fn(); - const scene = await startBackgroundScene(config, caps, { canvas, onActive }); - setSkyState('day'); - await microtasks(ticks); - setSkyState('dusk'); - await flush(); - const at = `at ${ticks} microtasks`; - const built = createRidgeScene.mock.calls.length; - expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top), at).toBe( - hex(ridgeSkyFor('dusk').top) - ); - // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. - const live = [a, b, c][built - 1]; - scene?.login(); - expect(live.login, at).toHaveBeenCalledOnce(); - [a, b, c].slice(0, built).forEach((x) => { - expect(x.dispose, at).toHaveBeenCalledTimes(x === live ? 0 : 1); - if (x !== live) expect(x.login, at).not.toHaveBeenCalled(); - }); - scene?.dispose(); - expect(live.dispose, at).toHaveBeenCalledOnce(); - return built === 3 && onActive.mock.calls.length === 1; - }; - - it('a sky change at any point of a build leaves only the newer build live; the stale one is disposed', async () => { - // The offsets are swept, not pinned, so a shift in the awaits inside the build cannot move the change off the - // build: at least one offset must land mid-build, or the sweep proves nothing. - const offsets = Array.from({ length: 21 }, (_, i) => i); - const landed = await offsets.reduce>( - (p, k) => p.then(async (acc) => ((await changeAfter(k)) ? [...acc, k] : acc)), - Promise.resolve([]) - ); - // This can fail after a correct refactor that changes the await count in create/skyKey and moves every offset - // off the build. Then widen the range of offsets until some land again; do not weaken the assertion. - expect(landed.length, 'at least one offset must land mid-build').toBeGreaterThan(0); - }); - - it('leaves the ray-march scene alone: it has one fixed dusk', async () => { - const inner = fakeScene() as unknown as Scene; - createScene.mockReturnValue(inner); - const scene = await startBackgroundScene({ ...config, style: 'raymarch' }, caps, { canvas }); - expect(scene).toBe(inner); - setSkyState('day'); - await flush(); - expect(createScene).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts deleted file mode 100644 index bc041672..00000000 --- a/src/app/features/scene/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Scene, SceneOptions } from './Scene'; -import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { debugOverlaysAllowed } from './debugOverlays'; -import { getSkyState, subscribe } from './skyState'; -import type { SkyState } from './skyState'; - -export type { Scene, SceneStats } from './Scene'; -export type { SceneStyle, SceneTier } from './tier'; - -export type StartOptions = Omit & { - /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ - onCompileFail?: (e: Error) => void; - /** A rebuild for a new sky state made no scene: the host puts the CSS ground back (as on a lost context). */ - onInactive?: () => void; - /** A rebuild made a scene again: the host lets it show. */ - onActive?: () => void; -}; - -async function create( - config: BackgroundSceneConfig | undefined, - options: StartOptions & { tier: SceneTier; debug?: boolean }, - state: SkyState -): Promise { - let createScene: (o: SceneOptions) => Scene | null; - try { - // Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables. - const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); - if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') { - const { createTerrainScene } = await import('./terrain/Terrain'); - const sky = terrainSkyFor(state); - const scene = createTerrainScene({ ...options, ...sky }); - // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. - if (scene) return scene; - const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); - return createTerrainSkyScene({ ...options, ...sky }); - } else { - const { createRidgeScene } = await import('./ridge/Ridge'); - return createRidgeScene({ ...options, sky: ridgeSkyFor(state) }); - } - } catch (e) { - options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); - return null; - } - return createScene(options); -} - -/** - * What a rebuild is for: the sky options the scene would be built from. A change of choice that draws the same sky - * (Day picked while the clock says day, 'auto' to the fixed state it resolves to) is not a rebuild; the page URL steers - * only 'auto' (`terrainSkyFor`), so there the two differ and the key says so. - */ -async function skyKey( - config: BackgroundSceneConfig | undefined, - state: SkyState -): Promise { - try { - const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); - return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state)); - } catch { - // The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure). - return null; - } -} - -/** - * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no - * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the - * main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain' (which falls back to its - * sky alone, `terrain/TerrainSky2D.ts`, where WebGL2 is missing). Null means - * "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the - * failure goes to onCompileFail and nothing throws. - * - * The ridge and terrain scenes bake their sky once, so they follow the sky (`skyState.ts`: the choice, resolved, so 'auto' follows the clock) by - * being rebuilt on the same canvas when it changes: the door subscribes on start and the returned - * scene's `dispose` unsubscribes. The pull-back restarts from the tree; a later `login()` is - * replayed on the new scene. The ray-march scene has one fixed dusk and ignores the state. - */ -export async function startBackgroundScene( - config: BackgroundSceneConfig | undefined, - capabilities: Capabilities, - options: StartOptions -): Promise { - const tier = resolveTier(config, capabilities); - if (tier === 'off') return null; - const debug = debugOverlaysAllowed(config); - const build = (state: SkyState) => create(config, { ...options, tier, debug }, state); - const state = getSkyState(); - const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state); - const first = await build(state); - if (!first || config?.style === 'raymarch') return first; - - let inner: Scene | null = first; - let built = key0; - let loggedIn = false; - let disposed = false; - let generation = 0; - // A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn. - const rebuild = async () => { - // The choice can move while the key is awaited; that change has its own rebuild, so this one stands down. - const next = getSkyState(); - const key = await skyKey(config, next); - if (getSkyState() !== next) return; - if ((key !== null && key === built) || disposed) return; - generation += 1; - const mine = generation; - built = key; - inner?.dispose(); - inner = null; - const scene = await build(next); - if (disposed || mine !== generation) { - scene?.dispose(); - return; - } - inner = scene; - if (!scene) options.onInactive?.(); - else { - options.onActive?.(); - if (loggedIn) scene.login(); - } - }; - const off = subscribe(rebuild); - // A change that landed while the first build was awaited was missed by the subscription. - rebuild(); - return { - login() { - loggedIn = true; - inner?.login(); - }, - dispose() { - if (disposed) return; - disposed = true; - off(); - inner?.dispose(); - inner = null; - }, - }; -} diff --git a/src/app/features/scene/palette.ts b/src/app/features/scene/palette.ts deleted file mode 100644 index 54a84ad3..00000000 --- a/src/app/features/scene/palette.ts +++ /dev/null @@ -1,27 +0,0 @@ -// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs -// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the -// background reads as the same dusk as gamecult.org. The shaders hold no colour literal. - -export type Rgb = readonly [number, number, number]; - -const srgb = (r: number, g: number, b: number): Rgb => [ - (r / 255) ** 2.2, - (g / 255) ** 2.2, - (b / 255) ** 2.2, -]; - -export const PALETTE = { - sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb, - skyTop: srgb(30, 36, 92), - skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb, - fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb, - grass: srgb(72, 112, 86), - rock: srgb(112, 104, 122), - bark: srgb(62, 46, 40), - leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb, - sunDir: (() => { - const v = [0.55, 0.16, -0.82]; - const l = Math.hypot(v[0], v[1], v[2]); - return [v[0] / l, v[1] / l, v[2] / l] as Rgb; - })(), -} as const; diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts deleted file mode 100644 index adee2717..00000000 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ /dev/null @@ -1,690 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRidgeScene } from './Ridge'; -import { fbmPeriodic, valueNoisePeriodic, hash2 } from './noise'; -import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe'; -import { - AERIAL, - FBM, - LAYERS, - edgeBlur, - hazeColour, - layerColour, - layerSize, - profile, - ridgeY, - scatterAlpha, - transmittance, -} from './ridgeWorld'; -import { skyColours, SkyColours } from './skyColour'; -import { makeStars } from './stars'; -import { startBackgroundScene } from '../index'; - -describe('noise', () => { - it('is deterministic and in [0, 1)', () => { - expect(hash2(3, 5)).toBe(hash2(3, 5)); - for (let i = 0; i < 200; i += 1) { - const v = valueNoisePeriodic(i * 0.37, 2, 7); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThan(1); - } - }); - - it('wraps: the lattice repeats every `period` cells', () => { - for (const x of [0, 0.4, 3.9, 6.99]) { - expect(valueNoisePeriodic(x + 7, 4, 7)).toBeCloseTo(valueNoisePeriodic(x, 4, 7), 12); - } - }); - - it('different rows are different profiles', () => { - expect(valueNoisePeriodic(1.5, 0, 9)).not.toBeCloseTo(valueNoisePeriodic(1.5, 1, 9), 3); - }); -}); - -describe('ridge profile', () => { - it('every layer is periodic in t, so a drift of one period loops seamlessly', () => { - for (const spec of LAYERS) { - for (const t of [0, 0.13, 0.5, 0.97]) { - expect(Number.isFinite(profile(spec, t))).toBe(true); - } - // the end of one period meets the start of the next - expect(profile(spec, 1 - 1e-9)).toBeCloseTo(profile(spec, 0), 3); - } - }); - - it('every octave count is an integer lattice, which periodicity needs', () => { - for (const spec of LAYERS) { - for (let k = 0; k < FBM.octaves; k += 1) { - expect(Number.isInteger(spec.baseCells * FBM.lacunarity ** k)).toBe(true); - } - } - }); - - it('stays in [0, 1] and layers differ', () => { - for (const spec of LAYERS) { - for (let i = 0; i < 100; i += 1) { - const p = profile(spec, i / 100); - expect(p).toBeGreaterThanOrEqual(0); - expect(p).toBeLessThanOrEqual(1); - } - } - expect(profile(LAYERS[0], 0.3)).not.toBeCloseTo(profile(LAYERS[1], 0.3), 3); - }); - - it('ridge tops lie inside the layer band and rise with farther base', () => { - const size = layerSize(400, 800, 1); - for (const spec of LAYERS) { - for (let x = 0; x < size.period; x += 17) { - const y = ridgeY(spec, x, size.period, size.height, 800); - expect(y).toBeGreaterThanOrEqual(0); - expect(y).toBeLessThanOrEqual(size.height); - } - } - }); - - it('layers get darker and slower toward the viewer, and their sizes are integers', () => { - const sky = skyColours(); - const lum = (c: readonly number[]) => c[0] + c[1] + c[2]; - for (let i = 1; i < LAYERS.length; i += 1) { - expect(lum(layerColour(LAYERS[i], sky))).toBeLessThan(lum(layerColour(LAYERS[i - 1], sky))); - expect(LAYERS[i].seconds).toBeLessThan(LAYERS[i - 1].seconds); - } - for (const [w, h, s] of [ - [393, 851, 2], - [1280, 720, 1], - [412.5, 915.3, 2], - ] as const) { - const z = layerSize(w, h, s); - expect( - Number.isInteger(z.period) && Number.isInteger(z.width) && Number.isInteger(z.height) - ).toBe(true); - expect(z.width).toBe(2 * z.period); - } - }); -}); - -describe('aerial perspective', () => { - const sky = skyColours(); - const lum = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - const chroma = (c: readonly number[]) => Math.max(...c) - Math.min(...c); - const far = LAYERS[0]; - const near = LAYERS[LAYERS.length - 1]; - - it('transmittance is Beer-Lambert in distance: 1 at the viewer, exp(-k d) behind, strictly falling', () => { - expect(transmittance(near)).toBe(1); - for (const l of LAYERS) - expect(transmittance(l)).toBeCloseTo(Math.exp(-AERIAL.extinction * (1 - l.depth)), 12); - for (let i = 1; i < LAYERS.length; i += 1) - expect(transmittance(LAYERS[i])).toBeGreaterThan(transmittance(LAYERS[i - 1])); - // two layers' optical depths add: T(a)*T(b) = T(a+b) - const t = (d: number) => Math.exp(-AERIAL.extinction * d); - expect(t(0.3) * t(0.4)).toBeCloseTo(t(0.7), 12); - }); - - it('the nearest layer is exactly the ground colour; the farthest tends to the haze', () => { - expect(layerColour(near, sky)).toEqual([...sky.ground]); - const h = hazeColour(sky); - const f = layerColour(far, sky); - const dist = (a: readonly number[], b: readonly number[]) => - Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); - expect(dist(f, h)).toBeLessThan(dist(sky.ground, h) * (transmittance(far) + 0.2)); - }); - - it('colour moves toward the haze and loses chroma with distance, beyond what the mix alone does', () => { - const h = hazeColour(sky); - for (const l of LAYERS.slice(0, -1)) { - const t = transmittance(l); - const c = layerColour(l, sky); - const mixed = [0, 1, 2].map((i) => sky.ground[i] * t + h[i] * (1 - t)); - expect(chroma(c)).toBeLessThan(chroma(mixed)); - expect(Math.abs(lum(c) - lum(mixed))).toBeLessThan(1.5); // desaturation keeps luma - } - }); - - it('edge softening grows with distance and is zero at the viewer', () => { - expect(edgeBlur(near)).toBe(0); - for (let i = 1; i < LAYERS.length; i += 1) - expect(edgeBlur(LAYERS[i])).toBeLessThan(edgeBlur(LAYERS[i - 1])); - expect(edgeBlur(far)).toBeLessThanOrEqual(AERIAL.blurPx); - }); - - it('in-scatter peaks at the sun azimuth, is periodic, faint, grows with distance, and is off without a sun', () => { - const az = 0.3; - const s = { ...sky, sunAzimuth: az }; - expect(scatterAlpha(far, s, az)).toBeGreaterThan(scatterAlpha(far, s, az + 0.5)); - expect(scatterAlpha(far, s, 0)).toBeCloseTo(scatterAlpha(far, s, 1), 12); - expect(scatterAlpha(far, s, az)).toBeLessThanOrEqual(AERIAL.scatter); - expect(scatterAlpha(near, s, az)).toBe(0); - expect(scatterAlpha(LAYERS[1], s, az)).toBeLessThan(scatterAlpha(far, s, az)); - expect(scatterAlpha(far, { ...sky, sunAzimuth: undefined }, az)).toBe(0); - }); -}); - -describe('frame sampler', () => { - const feed = (s: FrameSampler, dt: number, frames: number, from = 0) => { - let t = from; - let done = false; - for (let i = 0; i < frames && !done; i += 1) { - t += dt; - done = s.push(t); - } - return done; - }; - - it('median and p95 of known intervals', () => { - const v = Array.from({ length: 100 }, (_, i) => (i < 90 ? 16 : 50)); - expect(frameStats(v)).toEqual({ n: 100, medianMs: 16, p95Ms: 50 }); - expect(frameStats([])).toEqual({ n: 0, medianMs: 0, p95Ms: 0 }); - }); - - it('does not complete before 2 s of measured frames, and ignores the warm-up', () => { - const s = new FrameSampler(); - expect(feed(s, 16, 100)).toBe(false); // 1.6 s - expect(feed(s, 16, 40, 1600)).toBe(true); - expect(s.stats().n).toBeLessThan(100 + 40 - WARMUP_FRAMES + 1); - }); - - it('a smooth 60 Hz run is not slow; a run of 40 ms frames is', () => { - const good = new FrameSampler(); - feed(good, 16.7, 400); - expect(good.slow()).toBe(false); - const bad = new FrameSampler(); - feed(bad, 100, 400); // ~100 ms frames, the PR #13 readout - expect(bad.stats().p95Ms).toBeGreaterThan(SLOW_P95_MS); - expect(bad.slow()).toBe(true); - }); - - it('a long warm-up frame does not poison the window', () => { - const s = new FrameSampler(); - let t = 0; - s.push(t); - for (let i = 0; i < WARMUP_FRAMES; i += 1) s.push((t += 300)); // setup, upload, JIT - feed(s, 16.7, 200, t); - expect(s.slow()).toBe(false); - expect(WINDOW_MS).toBe(2000); - }); - - it('reset forgets a hidden-tab gap', () => { - const s = new FrameSampler(); - feed(s, 16, 20); - s.reset(); - expect(s.stats().n).toBe(0); - }); -}); - -// A minimal DOM: just what createRidgeScene touches. -function fakeEl(tag = 'div') { - const el: any = { - tag, - style: {}, - children: [] as any[], - parentElement: null as any, - width: 0, - height: 0, - clientWidth: 400, - clientHeight: 800, - attrs: {} as Record, - setAttribute(k: string, v: string) { - this.attrs[k] = v; - }, - getContext: vi.fn(() => ctx()), - remove() { - if (this.parentElement) - this.parentElement.children = this.parentElement.children.filter((c: any) => c !== this); - this.parentElement = null; - }, - appendChild(c: any) { - c.parentElement = this; - this.children.push(c); - return c; - }, - animate: vi.fn(() => { - const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() }; - el.anims.push(a); - return a; - }), - anims: [] as any[], - }; - return el; -} -// A day sky has no stars, so the scene tests below see only the ridge loop; the dusk default -// carries stars and a twinkle overlay, tested in 'the starfield in the scene'. -const DAY: SkyColours = { - top: [96, 150, 220], - mid: [150, 190, 235], - horizon: [240, 215, 190], - ground: [7, 17, 26], - sunAzimuth: 0.3, -}; -const draws: string[] = []; -function ctx(withFilter = false) { - const c: any = { - globalCompositeOperation: 'source-over', - createLinearGradient: () => ({ addColorStop: vi.fn() }), - fillRect() { - draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky'); - }, - beginPath() {}, - moveTo() {}, - lineTo() {}, - closePath() {}, - // Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's. - clearRect() { - c.overlay = true; - draws.push('clear'); - }, - arc() { - draws.push(c.overlay ? 'twinkle' : 'star'); - c.arcing = true; - }, - fill: () => { - if (c.arcing) c.arcing = false; - else draws.push('fill'); - }, - drawImage: () => draws.push('flatten'), - set fillStyle(_v: unknown) {}, - }; - if (withFilter) c.filter = 'none'; - return c; -} - -let rafQueue: Array<(t: number) => void>; -let motion: { matches: boolean; addEventListener: any; removeEventListener: any }; -let doc: any; -let search: string; - -beforeEach(() => { - rafQueue = []; - draws.length = 0; - search = ''; - motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }; - doc = Object.assign(new EventTarget(), { - visibilityState: 'visible', - createElement: (t: string) => fakeEl(t), - }); - vi.stubGlobal('document', doc); - vi.stubGlobal( - 'window', - Object.assign(new EventTarget(), { - devicePixelRatio: 2, - innerWidth: 400, - innerHeight: 800, - matchMedia: () => motion, - location: { - get search() { - return search; - }, - }, - setTimeout, - }) - ); - vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); - vi.stubGlobal('cancelAnimationFrame', () => { - rafQueue = []; - }); -}); -afterEach(() => vi.unstubAllGlobals()); - -function host() { - const parent = fakeEl('div'); - const canvas = fakeEl('canvas'); - parent.appendChild(canvas); - return { parent, canvas }; -} -const runFrames = (dt: number, n: number, from = 0) => { - let t = from; - for (let i = 0; i < n && rafQueue.length; i += 1) { - t += dt; - // One display frame: every callback queued for it sees the same timestamp. - rafQueue.splice(0).forEach((cb) => cb(t)); - } - return t; -}; - -describe('createRidgeScene', () => { - it("returns null at tier 'off' and touches nothing", () => { - const { canvas } = host(); - expect(createRidgeScene({ canvas, tier: 'off' })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - }); - - it('returns null when 2D context is missing, so the host keeps its CSS ground', () => { - const { canvas } = host(); - canvas.getContext = vi.fn(() => null); - expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull(); - }); - - it('bakes the sky once and paints four layers at integer sizes, farthest first', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - expect(s).not.toBeNull(); - const layers = parent.children.slice(1); - expect(layers).toHaveLength(LAYERS.length); - layers.forEach((l: any) => { - expect(Number.isInteger(l.width) && Number.isInteger(l.height)).toBe(true); - expect(l.width).toBe(canvas.width * 2); // DPR 2: layer is two periods wide - expect(l.animate).toHaveBeenCalledTimes(1); - }); - expect(draws.filter((d) => d === 'sky')).toHaveLength(1); - expect(draws.filter((d) => d === 'fill')).toHaveLength(LAYERS.length); - s.dispose(); - }); - - it('after the layers are painted, a smooth run draws nothing more and ends its rAF loop', () => { - const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - const before = draws.length; - runFrames(16.7, 400); - expect(draws.length).toBe(before); - expect(rafQueue).toHaveLength(0); // no per-frame work left - s.dispose(); - }); - - it('p95 over 33 ms across 2 s flattens to a still image and removes the layers', () => { - const { parent, canvas } = host(); - const onProbe = vi.fn(); - const s = createRidgeScene({ canvas, tier: 'full', onProbe, sky: DAY })!; - runFrames(100, 100); - expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); - expect(parent.children).toEqual([canvas]); - expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); - expect(rafQueue).toHaveLength(0); - s.dispose(); - }); - - it("'still' tier flattens immediately and never starts a loop", () => { - const { parent, canvas } = host(); - createRidgeScene({ canvas, tier: 'still' }); - expect(parent.children).toEqual([canvas]); - expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); - expect(rafQueue).toHaveLength(0); - }); - - it('prefers-reduced-motion: layers are built but never played, and nothing is sampled', () => { - motion.matches = true; - const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims)); - expect(anims.length).toBe(LAYERS.length); - anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); - expect(rafQueue).toHaveLength(0); - s.dispose(); - }); - - it('pauses the drift when the tab is hidden and resumes when it is visible', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - const anims = parent.children.slice(1).flatMap((l: any) => l.anims); - anims.forEach((a: any) => a.pause.mockClear()); - doc.visibilityState = 'hidden'; - doc.dispatchEvent(new Event('visibilitychange')); - anims.forEach((a: any) => expect(a.pause).toHaveBeenCalled()); - anims.forEach((a: any) => a.play.mockClear()); - doc.visibilityState = 'visible'; - doc.dispatchEvent(new Event('visibilitychange')); - anims.forEach((a: any) => expect(a.play).toHaveBeenCalled()); - s.dispose(); - }); - - it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { - search = '?probe=1'; - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, debug: true })!; - const readout = parent.children[parent.children.length - 1]; - runFrames(16.7, 300); - expect(rafQueue.length).toBe(1); // still sampling - expect(readout.textContent).toMatch(/median 16\.\d ms · p95 1[67]\.\d ms/); - s.dispose(); - expect(parent.children.some((c: any) => c === readout)).toBe(false); - }); - - it('?probe=1 does nothing where the overlays are not allowed: no readout, the loop ends', () => { - search = '?probe=1'; - const { parent, canvas } = host(); - const before = parent.children.length; - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - expect(parent.children.length).toBe(before + LAYERS.length); - runFrames(16.7, 300); - expect(rafQueue.length).toBe(0); - s.dispose(); - }); - - it('a scene started at zero size paints nothing, then builds on the first non-zero resize', async () => { - const { parent, canvas } = host(); - canvas.clientWidth = 0; - canvas.clientHeight = 0; - (window as any).innerWidth = 0; - (window as any).innerHeight = 0; - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY }); - expect(s).not.toBeNull(); - expect(parent.children).toEqual([canvas]); - expect(draws).toHaveLength(0); - // Still zero: a resize does not paint either. - window.dispatchEvent(new Event('resize')); - await new Promise((r) => setTimeout(r, 300)); - expect(parent.children).toEqual([canvas]); - (window as any).innerWidth = 400; - (window as any).innerHeight = 800; - window.dispatchEvent(new Event('resize')); - await new Promise((r) => setTimeout(r, 300)); - expect(parent.children.slice(1)).toHaveLength(LAYERS.length); - expect(draws.filter((d) => d === 'sky')).toHaveLength(1); - s!.dispose(); - expect(parent.children).toEqual([canvas]); - }); - - it('dispose cancels the loop, the animations and the layers', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - const anims = parent.children.slice(1).flatMap((l: any) => l.anims); - s.dispose(); - anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled()); - expect(parent.children).toEqual([canvas]); - expect(rafQueue).toHaveLength(0); - }); -}); - -describe('the door', () => { - it('runs the ridge scene by default and never requests a WebGL context', async () => { - const { canvas } = host(); - const getContext = vi.fn((kind: string) => (kind === '2d' ? ctx() : null)); - canvas.getContext = getContext; - const scene = await startBackgroundScene({ tier: 'full' }, {}, { canvas }); - expect(scene).not.toBeNull(); - expect(getContext.mock.calls.every(([k]) => k === '2d')).toBe(true); - scene!.dispose(); - }); - - it('the tier flag still defaults to off: nothing imported, no context', async () => { - const { canvas } = host(); - expect(await startBackgroundScene(undefined, {}, { canvas })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - }); -}); - -describe('fbm', () => { - it('is normalised into [0, 1]', () => { - for (let i = 0; i < 100; i += 1) { - const v = fbmPeriodic(i / 100, 0, 3, FBM); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - } - }); -}); - -describe('aerial perspective in the scene', () => { - it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => { - const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - expect(draws.filter((d) => d === 'scatter')).toHaveLength(LAYERS.length - 1); - const before = draws.length; - runFrames(16.7, 400); - expect(draws.length).toBe(before); - s.dispose(); - }); - - it('a sky without a sun bakes no scatter', () => { - const { canvas } = host(); - const s = createRidgeScene({ - canvas, - tier: 'full', - sky: { ...DAY, sunAzimuth: undefined }, - })!; - expect(draws.filter((d) => d === 'scatter')).toHaveLength(0); - s.dispose(); - }); - - it('softens with the canvas filter where it exists, blur scaled by DPR, tiled so the wrap does not fade', () => { - const { parent, canvas } = host(); - const filters: string[] = []; - const orig = (document as any).createElement; - (document as any).createElement = (t: string) => { - const el = orig(t); - el.getContext = vi.fn(() => { - const c = ctx(true); - let f = 'none'; - Object.defineProperty(c, 'filter', { - get: () => f, - set: (v: string) => { - f = v; - filters.push(v); - }, - }); - return c; - }); - return el; - }; - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - const blurs = filters.filter((f) => f !== 'none'); - expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp - expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2 - expect(parent.children).toHaveLength(1 + LAYERS.length); - s.dispose(); - }); - - it("'still' tier keeps the sky and flattens the aerial layers without extra elements", () => { - const { parent, canvas } = host(); - createRidgeScene({ canvas, tier: 'still' }); - expect(parent.children).toEqual([canvas]); - expect(draws.filter((d) => d === 'sky')).toHaveLength(1); - expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); - }); -}); - -describe('the starfield in the scene', () => { - const count = (kind: string) => draws.filter((d) => d === kind).length; - const night = { - ...DAY, - top: [10, 14, 40], - mid: [30, 34, 80], - horizon: [60, 50, 90], - } as SkyColours; - const N = makeStars(night, 400, 800).length; - const hear = (name: string) => { - const calls = motion.addEventListener.mock.calls.filter((c: any[]) => c[0] === name); - return calls[calls.length - 1][1] as () => void; - }; - // Everything but the overlay's own marks: the bake and the ridges. - const baked = () => draws.filter((d) => d !== 'twinkle' && d !== 'clear').length; - - it('bakes every star once into the sky; per frame only the overlay is touched', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; - expect(count('star')).toBe(N); - expect(count('sky')).toBe(1); - expect(parent.children.indexOf(parent.children[1])).toBe(1); - expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges - expect(parent.children).toHaveLength(2 + LAYERS.length); - const before = baked(); - runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps - expect(baked()).toBe(before); - expect(count('clear')).toBeGreaterThan(60); - expect(count('clear')).toBeLessThanOrEqual(101); - // each pass paints at most SLOTS stars - expect(count('twinkle')).toBeLessThanOrEqual(count('clear') * 32); - expect(count('twinkle')).toBeGreaterThan(0); - s.dispose(); - }); - - it('a day sky has no stars and no overlay layer', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - expect(count('star')).toBe(0); - expect(parent.children).toHaveLength(1 + LAYERS.length); - runFrames(16.7, 60); - expect(count('clear')).toBe(0); - s.dispose(); - }); - - it("tier 'still' bakes the stars and has no overlay or loop", () => { - const { parent, canvas } = host(); - createRidgeScene({ canvas, tier: 'still', sky: night }); - expect(count('star')).toBe(N); - expect(parent.children).toEqual([canvas]); - expect(rafQueue).toHaveLength(0); - }); - - it('stops twinkling under reduced motion, live, and resumes when it is lifted', () => { - const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; - let t = runFrames(16.7, 30); - expect(count('twinkle')).toBeGreaterThan(0); - motion.matches = true; - hear('change')(); - t = runFrames(16.7, 5, t); - const stopped = draws.length; - t = runFrames(16.7, 200, t); - expect(draws.length).toBe(stopped); - expect(rafQueue).toHaveLength(0); - motion.matches = false; - hear('change')(); - runFrames(16.7, 30, t); - expect(draws.length).toBeGreaterThan(stopped); - s.dispose(); - }); - - it('starts steady, with nothing painted over the stars, when reduced motion is already on', () => { - motion.matches = true; - const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; - expect(rafQueue).toHaveLength(0); - runFrames(16.7, 60); - expect(count('twinkle')).toBe(0); - s.dispose(); - }); - - it('stops while the tab is hidden and resumes when it is visible', () => { - const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; - let t = runFrames(16.7, 30); - doc.visibilityState = 'hidden'; - doc.dispatchEvent(new Event('visibilitychange')); - const hidden = draws.length; - t = runFrames(16.7, 200, t); - expect(draws.length).toBe(hidden); - expect(rafQueue).toHaveLength(0); - doc.visibilityState = 'visible'; - doc.dispatchEvent(new Event('visibilitychange')); - runFrames(16.7, 30, t); - expect(draws.length).toBeGreaterThan(hidden); - s.dispose(); - }); - - it('flattening to a still (slow frames) drops the overlay and leaves every star baked under the ridges', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; - runFrames(100, 100); - expect(parent.children).toEqual([canvas]); - expect(count('star')).toBe(N); - expect(draws.indexOf('flatten')).toBeGreaterThan(draws.lastIndexOf('star')); - expect(rafQueue).toHaveLength(0); - s.dispose(); - }); - - it('dispose removes the overlay and stops the loop', () => { - const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; - s.dispose(); - expect(parent.children).toEqual([canvas]); - expect(rafQueue).toHaveLength(0); - }); -}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts deleted file mode 100644 index fd40a967..00000000 --- a/src/app/features/scene/ridge/Ridge.ts +++ /dev/null @@ -1,415 +0,0 @@ -import type { Scene, SceneOptions } from '../Scene'; -import { overlayRequested } from '../debugOverlays'; -import { FrameSampler, FrameStats } from './probe'; -import { skyColours, SkyColours, Srgb } from './skyColour'; -import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; -import { - LAYERS, - LayerSpec, - edgeBlur, - hazeColour, - layerColour, - layerSize, - ridgeY, - scatterAlpha, -} from './ridgeWorld'; - -export type RidgeOptions = Pick & { - /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */ - probe?: boolean; - /** The diagnostic overlays may be asked for by URL (debugOverlays.ts). Default: no. */ - debug?: boolean; - /** Each time the readout would update, and once with `still` when the fallback fires. */ - onProbe?: (s: FrameStats & { still: boolean }) => void; - /** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */ - sky?: SkyColours; -}; - -const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`; -const mix = (a: Srgb, b: Srgb, t: number): Srgb => [ - Math.round(a[0] + (b[0] - a[0]) * t), - Math.round(a[1] + (b[1] - a[1]) * t), - Math.round(a[2] + (b[2] - a[2]) * t), -]; - -function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null { - return c.getContext('2d'); -} - -function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean { - const ctx = context2d(canvas); - if (!ctx) return false; - const g = ctx.createLinearGradient(0, 0, 0, canvas.height); - g.addColorStop(0, css(sky.top)); - g.addColorStop(0.5, css(sky.mid)); - g.addColorStop(0.78, css(sky.horizon)); - g.addColorStop(1, css(sky.horizon)); - ctx.fillStyle = g; - ctx.fillRect(0, 0, canvas.width, canvas.height); - return true; -} - -const SCATTER_STOPS = 16; - -/** Warm in-scatter over the silhouette only (source-atop), as a horizontal gradient that repeats each period. */ -function scatter( - ctx: CanvasRenderingContext2D, - spec: LayerSpec, - sky: SkyColours, - size: { period: number; width: number; height: number } -) { - if (sky.sunAzimuth === undefined || scatterAlpha(spec, sky, sky.sunAzimuth) <= 0) return; - const g = ctx.createLinearGradient(0, 0, size.width, 0); - const [r, gr, b] = sky.horizon; - for (let k = 0; k <= 2 * SCATTER_STOPS; k += 1) { - const t = (k % SCATTER_STOPS) / SCATTER_STOPS; - g.addColorStop(k / (2 * SCATTER_STOPS), `rgba(${r},${gr},${b},${scatterAlpha(spec, sky, t)})`); - } - ctx.globalCompositeOperation = 'source-atop'; - ctx.fillStyle = g; - ctx.fillRect(0, 0, size.width, size.height); - ctx.globalCompositeOperation = 'source-over'; -} - -/** - * Soften the silhouette's edge by `px` once, where the browser's 2D canvas supports `filter` (not - * Safari, which keeps the sharp edge). One period is tiled past both ends and the bottom row extended below, - * then blurred in a single draw, so the blur sees the wrap and the ground instead of fading at borders. - */ -function soften(src: HTMLCanvasElement, px: number, period: number): HTMLCanvasElement { - const probe = context2d(src); - if (!probe || px <= 0 || !('filter' in probe)) return src; - const pad = Math.ceil(3 * px); - // The unfiltered tiling: blurring tile by tile would fade each tile's own border. - const tiled = document.createElement('canvas'); - tiled.width = src.width + 2 * period; - tiled.height = src.height + pad; - const tctx = context2d(tiled); - const out = document.createElement('canvas'); - out.width = src.width; - out.height = src.height; - const ctx = context2d(out); - if (!tctx || !ctx) return src; - for (let k = 0; k < 4; k += 1) { - tctx.drawImage(src, 0, 0, period, src.height, k * period, 0, period, src.height); - tctx.drawImage(src, 0, src.height - 1, period, 1, k * period, src.height, period, pad); - } - ctx.filter = `blur(${px}px)`; - ctx.drawImage(tiled, -period, 0); - ctx.filter = 'none'; - return out; -} - -/** One ridge layer, painted once: a filled silhouette two periods wide on a plain 2D canvas. */ -function paintLayer( - spec: LayerSpec, - sky: SkyColours, - size: { period: number; width: number; height: number }, - viewH: number, - scale: number -): HTMLCanvasElement | null { - const el = document.createElement('canvas'); - el.width = size.width; - el.height = size.height; - const ctx = context2d(el); - if (!ctx) return null; - const top: number[] = []; - for (let x = 0; x < size.period; x += 1) - top.push(ridgeY(spec, x, size.period, size.height, viewH)); - const c = layerColour(spec, sky); - const g = ctx.createLinearGradient(0, 0, 0, size.height); - g.addColorStop(0, css(c)); - g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth)))); - ctx.fillStyle = g; - ctx.beginPath(); - ctx.moveTo(0, size.height); - for (let x = 0; x <= size.width; x += 1) ctx.lineTo(x, top[x % size.period]); - ctx.lineTo(size.width, size.height); - ctx.closePath(); - ctx.fill(); - scatter(ctx, spec, sky, size); - return soften(el, edgeBlur(spec) * scale, size.period); -} - -interface Built { - layers: HTMLCanvasElement[]; - anims: Animation[]; - /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ - twinkle?: HTMLCanvasElement; -} - -/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */ -const TWINKLE_FRAME_MS = 50; - -/** - * The ridge scene: same contract as `createScene` ({ login, dispose }, null for "keep the CSS - * ground"), no WebGL. The layers are painted once on the CPU; per frame the only work is the - * browser compositor sliding each layer with a CSS transform (a Web Animation, no script). The host - * canvas holds the baked sky; it and the layers' parent must be positioned, and the canvas should fill it. - */ -export function createRidgeScene(opts: RidgeOptions): Scene | null { - const tier = opts.tier ?? 'full'; - if (tier === 'off') return null; - const canvas = opts.canvas; - const parent = canvas.parentElement; - if (!parent) return null; - const sky = opts.sky ?? skyColours(); - const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); - - let built: Built | undefined; - let field: Star[] = []; - let twRaf = 0; - let twLast = 0; - let still = tier === 'still'; - let disposed = false; - let raf = 0; - let readout: HTMLElement | undefined; - let resizeTimer = 0; - const sampler = new FrameSampler(); - - const viewSize = () => { - const w = canvas.clientWidth || window.innerWidth; - const h = canvas.clientHeight || window.innerHeight; - return { w, h, scale: (window.devicePixelRatio || 1) >= 2 ? 2 : 1 }; - }; - - const teardownLayers = () => { - built?.anims.forEach((a) => a.cancel()); - built?.layers.forEach((l) => l.remove()); - built?.twinkle?.remove(); - cancelAnimationFrame(twRaf); - twRaf = 0; - built = undefined; - }; - - /** True while there is nothing to paint on: the canvas has no client size and the window itself is 0 x 0 (`viewSize` falls back to the window). */ - const hasNoArea = () => { - const { w, h, scale } = viewSize(); - const size = layerSize(w, h, scale); - return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1; - }; - - /** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */ - const build = (): boolean => { - teardownLayers(); - // A zero-size canvas throws on drawImage; wait for the first resize that gives the host an area. - if (hasNoArea()) return false; - const { w, h, scale } = viewSize(); - const size = layerSize(w, h, scale); - canvas.width = size.period; - canvas.height = Math.round(h * scale); - if (!paintSky(canvas, sky)) return false; - const layers: HTMLCanvasElement[] = []; - // Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame - // pass clears and paints over. 'still' has no overlay. - const stars = makeStars(sky, w, h); - const skyCtx = context2d(canvas); - if (skyCtx) drawStars(skyCtx, stars, canvas.width, canvas.height, scale); - let twinkle: HTMLCanvasElement | undefined; - if (stars.length && tier !== 'still') { - twinkle = document.createElement('canvas'); - twinkle.width = canvas.width; - twinkle.height = canvas.height; - twinkle.setAttribute('aria-hidden', 'true'); - Object.assign(twinkle.style, { - position: 'absolute', - left: '0', - top: '0', - width: '100%', - height: '100%', - pointerEvents: 'none', - }); - parent.appendChild(twinkle); - } - field = stars; - for (const spec of LAYERS) { - const el = paintLayer(spec, sky, size, canvas.height, scale); - if (!el) { - layers.forEach((l) => l.remove()); - twinkle?.remove(); - return false; - } - el.setAttribute('aria-hidden', 'true'); - Object.assign(el.style, { - position: 'absolute', - left: '0', - bottom: '0', - width: `${size.width / scale}px`, - height: `${size.height / scale}px`, - pointerEvents: 'none', - willChange: 'transform', - }); - layers.push(el); - } - // DOM order is paint order: farthest first, so the nearest ridge ends up on top. - layers.forEach((l) => parent.appendChild(l)); - const anims = layers - .map((el, i) => - el.animate?.( - [ - { transform: 'translate3d(0,0,0)' }, - { transform: `translate3d(${-size.period / scale}px,0,0)` }, - ], - { duration: LAYERS[i].seconds * 1000, iterations: Infinity, easing: 'linear' } - ) - ) - .filter((a): a is Animation => !!a); - built = { layers, anims, twinkle }; - syncPlayState(); - return true; - }; - - const moving = () => - !still && - !disposed && - !(reducedMotion?.matches ?? false) && - document.visibilityState === 'visible'; - - /** Paint the overlay at t seconds (stars away from base), or just clear it with no t (all at base). */ - const drawTwinkle = (t?: number) => { - const el = built?.twinkle; - const ctx = el && context2d(el); - if (!el || !ctx) return; - const active = t === undefined ? [] : activeAt(field.length, t); - paintTwinkle(ctx, field, active, el.width, el.height, viewSize().scale); - }; - - // The same guard as the drift: not still, not disposed, not reduced motion, tab visible. Not moving - // stops the loop and leaves the stars steady at base brightness. - const twTick = (now: number) => { - twRaf = 0; - if (!moving() || !built?.twinkle) return; - if (now - twLast >= TWINKLE_FRAME_MS) { - twLast = now; - drawTwinkle(now / 1000); - } - twRaf = requestAnimationFrame(twTick); - }; - - function syncPlayState() { - built?.anims.forEach((a) => (moving() ? a.play() : a.pause())); - if (!built?.twinkle) return; - if (moving()) { - if (!twRaf) twRaf = requestAnimationFrame(twTick); - } else { - cancelAnimationFrame(twRaf); - twRaf = 0; - drawTwinkle(); - } - } - - /** Flatten the sky and layers into the host canvas and drop the layers: a still image, no compositor layers left. */ - const settleToStill = () => { - if (still && !built) return; - still = true; - const ctx = context2d(canvas); - if (ctx && built) { - const { anims } = built; - built.layers.forEach((l, i) => { - // Freeze where the drift is, so the swap to a still does not jump. - const a = anims[i]; - const t = Number(a?.currentTime ?? 0); - const turns = (t % (LAYERS[i].seconds * 1000)) / (LAYERS[i].seconds * 1000); - ctx.drawImage(l, -Math.round(turns * canvas.width), canvas.height - l.height); - }); - } - teardownLayers(); - cancelAnimationFrame(raf); - raf = 0; - report(true); - }; - - const report = (stillNow: boolean) => { - const s = { ...sampler.stats(), still: stillNow }; - opts.onProbe?.(s); - if (readout) { - readout.textContent = `ridge · n ${s.n} · median ${s.medianMs.toFixed( - 1 - )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; - } - }; - - let sinceReport = 0; - const tick = (now: number) => { - raf = 0; - if (!moving()) return; - const full = sampler.push(now); - if (full && sampler.slow() && !still) { - settleToStill(); - return; - } - // After the window the series keeps rolling only for the readout; otherwise the loop ends here. - if (full && !probe) return; - if (probe && now - sinceReport > 500) { - sinceReport = now; - report(false); - } - raf = requestAnimationFrame(tick); - }; - - const wake = () => { - syncPlayState(); - if (!moving() || raf) return; - sampler.reset(); - raf = requestAnimationFrame(tick); - }; - - // No area yet is not a failure: the resize listener below builds once the host has one. - if (!build() && !hasNoArea()) return null; - if (probe) { - readout = document.createElement('div'); - Object.assign(readout.style, { - position: 'absolute', - left: '8px', - top: '8px', - font: '12px monospace', - color: '#fff', - background: 'rgba(0,0,0,.55)', - padding: '2px 6px', - borderRadius: '3px', - pointerEvents: 'none', - }); - parent.appendChild(readout); - } - if (still) settleToStill(); - else wake(); - - const onVisibility = () => { - sampler.reset(); - wake(); - }; - const onResize = () => { - clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(() => { - if (disposed) return; - const wasStill = still; - if (!build()) return; - if (wasStill) settleToStill(); - else { - sampler.reset(); - wake(); - } - }, 250); - }; - document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('resize', onResize); - reducedMotion?.addEventListener?.('change', syncPlayState); - - return { - login() { - // No camera to pull back; the ridges are the same before and after. - }, - dispose() { - if (disposed) return; - disposed = true; - cancelAnimationFrame(raf); - clearTimeout(resizeTimer); - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('resize', onResize); - reducedMotion?.removeEventListener?.('change', syncPlayState); - teardownLayers(); - readout?.remove(); - }, - }; -} diff --git a/src/app/features/scene/ridge/noise.ts b/src/app/features/scene/ridge/noise.ts deleted file mode 100644 index 77af507d..00000000 --- a/src/app/features/scene/ridge/noise.ts +++ /dev/null @@ -1,51 +0,0 @@ -// CultMath's value noise and fbm on the CPU, in one dimension and periodic. Clean-room: the -// algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not copied -// from any other terrain. Doubles instead of floats, so values differ from the GLSL in the last -// digits; nothing here has to match a shader. - -const frac = (v: number): number => v - Math.floor(v); - -/** `cultmath_hash(vec2)`. */ -export const hash2 = (x: number, y: number): number => - frac(Math.sin(x * 127.1 + y * 311.7) * 43758.5453); - -const smooth = (t: number): number => t * t * (3 - 2 * t); - -/** - * `cultmath_value_noise` along row `row` of the lattice, with the lattice wrapping every `period` - * cells: noise(x + period) === noise(x). `row` is an integer, so the vertical blend of the 2D - * original is the identity. - */ -export function valueNoisePeriodic(x: number, row: number, period: number): number { - const cell = Math.floor(x); - const u = smooth(x - cell); - const wrap = (c: number) => ((c % period) + period) % period; - const a = hash2(wrap(cell), row); - const b = hash2(wrap(cell + 1), row); - return a + (b - a) * u; -} - -export interface FbmShape { - octaves: number; - lacunarity: number; - gain: number; -} - -/** - * Fractal sum in [0, 1]. `t` runs 0..1 across one period; the first octave has `baseCells` cells - * per period and each next one `lacunarity` times more, so `baseCells * lacunarity^k` must be an - * integer for the sum to be periodic (checked by the caller's config, not here). - */ -export function fbmPeriodic(t: number, row: number, baseCells: number, s: FbmShape): number { - let sum = 0; - let norm = 0; - let amp = 1; - let cells = baseCells; - for (let k = 0; k < s.octaves; k += 1) { - sum += amp * valueNoisePeriodic(t * cells, row + k * 13, cells); - norm += amp; - amp *= s.gain; - cells *= s.lacunarity; - } - return sum / norm; -} diff --git a/src/app/features/scene/ridge/probe.ts b/src/app/features/scene/ridge/probe.ts deleted file mode 100644 index 5ab9c57d..00000000 --- a/src/app/features/scene/ridge/probe.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Frame cadence from rAF timestamps: what `?probe=1` shows, and what the still-image fallback -// decides on. Pure, so it is tested without a browser. - -export interface FrameStats { - /** Frame intervals counted. */ - n: number; - medianMs: number; - p95Ms: number; -} - -/** The fallback's threshold and window, from the brief: p95 over 33 ms across 2 s. */ -export const SLOW_P95_MS = 33; -export const WINDOW_MS = 2000; -/** The first frames pay for layout, canvas upload and JIT; they say nothing about steady cadence. */ -export const WARMUP_FRAMES = 5; - -const quantile = (sorted: readonly number[], q: number): number => - sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]; - -export function frameStats(intervals: readonly number[]): FrameStats { - const s = [...intervals].sort((a, b) => a - b); - return { n: s.length, medianMs: quantile(s, 0.5), p95Ms: quantile(s, 0.95) }; -} - -export class FrameSampler { - private last: number | undefined; - private skipped = 0; - private startedAt: number | undefined; - private readonly intervals: number[] = []; - - /** Feed each rAF timestamp. Returns true once a full window has been measured. */ - push(now: number): boolean { - const prev = this.last; - this.last = now; - if (prev === undefined) return false; - if (this.skipped < WARMUP_FRAMES) { - this.skipped += 1; - return false; - } - this.startedAt ??= prev; - this.intervals.push(now - prev); - return now - this.startedAt >= WINDOW_MS; - } - - /** A pause (hidden tab, resize) breaks the series: its gap is not a slow frame. */ - reset(): void { - this.last = undefined; - this.skipped = 0; - this.startedAt = undefined; - this.intervals.length = 0; - } - - stats(): FrameStats { - return frameStats(this.intervals); - } - - slow(): boolean { - return this.stats().p95Ms > SLOW_P95_MS; - } -} diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts deleted file mode 100644 index 048d1b50..00000000 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM. -import { fbmPeriodic, FbmShape } from './noise'; -import type { Srgb, SkyColours } from './skyColour'; - -export interface LayerSpec { - /** 0 farthest. Also the noise row, so layers never share a profile. */ - index: number; - /** Lattice cells across one period for the first octave; the fbm octaves scale it by `lacunarity`. */ - baseCells: number; - /** Peak-to-valley height of the silhouette, as a fraction of the layer band. */ - relief: number; - /** 0 hills (plain fbm) .. 1 crags (ridged: 1 - |2n - 1|). */ - crag: number; - /** Ridge top at its lowest point, fraction of the viewport height up from the bottom. */ - base: number; - /** 0 = the layer is the horizon haze, 1 = the nearest ground colour. */ - depth: number; - /** Seconds for the layer to drift one period. Farther layers are slower. */ - seconds: number; -} - -/** Four layers. `lacunarity` 2 with these base cell counts keeps every octave an integer. */ -export const LAYERS: readonly LayerSpec[] = [ - { index: 0, baseCells: 3, relief: 0.24, crag: 0.8, base: 0.34, depth: 0.12, seconds: 420 }, - { index: 1, baseCells: 4, relief: 0.2, crag: 0.55, base: 0.26, depth: 0.38, seconds: 300 }, - { index: 2, baseCells: 5, relief: 0.16, crag: 0.3, base: 0.17, depth: 0.66, seconds: 210 }, - { index: 3, baseCells: 6, relief: 0.12, crag: 0.1, base: 0.08, depth: 1, seconds: 150 }, -]; - -export const FBM: FbmShape = { octaves: 5, lacunarity: 2, gain: 0.5 }; - -/** Silhouette height in [0, 1] at `t` (0..1 across one period); periodic in t. */ -export function profile(spec: LayerSpec, t: number): number { - const n = fbmPeriodic(t, spec.index * 101, spec.baseCells, FBM); - const crag = 1 - Math.abs(2 * n - 1); - return n + (crag - n) * spec.crag; -} - -/** The distance haze the far layers dissolve into: between the mid sky and the horizon, not the horizon glow itself. */ -export function hazeColour(sky: SkyColours): Srgb { - const m = (a: number, b: number) => Math.round(a + (b - a) * 0.4); - return [ - m(sky.mid[0], sky.horizon[0]), - m(sky.mid[1], sky.horizon[1]), - m(sky.mid[2], sky.horizon[2]), - ]; -} - -/** - * Aerial perspective, applied once at bake time. A layer at distance d (0 nearest, 1 farthest) is seen - * through Beer-Lambert transmittance T = exp(-extinction * d): it shows T of its own colour and 1 - T of - * the haze, then loses `desaturate * (1 - T)` of its chroma, is softened by up to `blurPx` (canvas - * pixels at scale 1) and takes a faint warm in-scatter of at most `scatter` on the sun side. - */ -export const AERIAL = { extinction: 1.6, desaturate: 0.5, blurPx: 2, scatter: 0.16 }; - -/** Distance of a layer from the viewer, 0 (nearest) .. 1 (farthest). */ -export const layerDistance = (spec: LayerSpec): number => 1 - spec.depth; - -export const transmittance = (spec: LayerSpec): number => - Math.exp(-AERIAL.extinction * layerDistance(spec)); - -/** Layer colour as seen: its own ground colour thinned by the haze between it and the eye. */ -export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb { - const h = hazeColour(sky); - const t = transmittance(spec); - const seen = (i: number) => sky.ground[i] * t + h[i] * (1 - t); - const luma = 0.2126 * seen(0) + 0.7152 * seen(1) + 0.0722 * seen(2); - const keep = 1 - AERIAL.desaturate * (1 - t); - const out = (i: number) => Math.round(luma + (seen(i) - luma) * keep); - return [out(0), out(1), out(2)]; -} - -/** Edge softening for a layer, in canvas pixels at scale 1; 0 for the nearest. */ -export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec)); - -/** - * Alpha of the warm sun-side in-scatter at `t` (0..1 across one period; periodic). Zero when the sky has - * no sun; grows with distance, peaks at the sun's azimuth. - */ -export function scatterAlpha(spec: LayerSpec, sky: SkyColours, t: number): number { - if (sky.sunAzimuth === undefined) return 0; - const lobe = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t - sky.sunAzimuth)); - return AERIAL.scatter * (1 - transmittance(spec)) * lobe * lobe; -} - -/** Integer pixel sizes of a layer for a viewport: one period wide, twice that painted, so a drift of one period loops. */ -export function layerSize(cssW: number, cssH: number, scale: number) { - const period = Math.round(cssW * scale); - const band = Math.round(cssH * scale * 0.6); - return { period, width: period * 2, height: band }; -} - -/** Ridge-top y (canvas pixels, from the top of the layer's band) for pixel column x. */ -export function ridgeY( - spec: LayerSpec, - x: number, - period: number, - bandH: number, - viewH: number -): number { - const amp = spec.relief * viewH; - const bottomOffset = spec.base * viewH; // lowest the top sits above the viewport bottom - const topFromBottom = bottomOffset + amp * profile(spec, (x % period) / period); - return bandH - Math.min(bandH, topFromBottom); -} diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts deleted file mode 100644 index 7917d89e..00000000 --- a/src/app/features/scene/ridge/skyColour.ts +++ /dev/null @@ -1,30 +0,0 @@ -// The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, -// cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a -// fixed twilight: lavender zenith, rose mid-sky, orange horizon. These are the raw stops: the scene draws them -// shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup, -// and bakes the result. - -export type Srgb = readonly [number, number, number]; - -export interface SkyColours { - /** Top of the sky gradient, sRGB 0..255. */ - top: Srgb; - /** Mid-sky band. */ - mid: Srgb; - /** Colour at the horizon, which the far ridges fade into. */ - horizon: Srgb; - /** Nearest ridge: the ground colour the farther layers mix away from. */ - ground: Srgb; - /** Where the sun sits across one drift period, 0..1; the layers pick up a faint warm in-scatter there. Omit for none. */ - sunAzimuth?: number; -} - -export function skyColours(_now: Date = new Date()): SkyColours { - return { - top: [170, 145, 220], - mid: [225, 130, 165], - horizon: [240, 150, 80], - ground: [7, 17, 26], - sunAzimuth: 0.3, - }; -} diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts deleted file mode 100644 index e14f2a7c..00000000 --- a/src/app/features/scene/ridge/stars.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* eslint-disable no-restricted-syntax, no-continue -- test loops over sample times */ -import { describe, expect, it } from 'vitest'; -import { skyColours } from './skyColour'; -import { - MAX_STARS, - NIGHT_LUMA, - SLOTS, - TWINKLE_GAIN, - TWINKLE_PERIOD, - activeAt, - makeStars, - skyLuma, - slotCount, - slotOffset, - slotPeriod, - starCount, - twinkleGain, -} from './stars'; - -const DAY = { - ...skyColours(), - top: [96, 150, 220], - mid: [150, 190, 235], - horizon: [240, 215, 190], -} as const; -const NIGHT = { - ...skyColours(), - top: [10, 14, 40], - mid: [30, 34, 80], - horizon: [60, 50, 90], -} as const; -const N = makeStars(NIGHT, 390, 844).length; - -function hash2(a: number, b: number) { - return ((a * 7919 + b * 104729) % 1000) / 1000; -} - -describe('star placement', () => { - it('is deterministic: the same sky and size give the identical field', () => { - expect(makeStars(NIGHT, 390, 844)).toEqual(makeStars(NIGHT, 390, 844)); - expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); - }); - - it('draws none in a day sky or the lavender dusk, and some in a night one', () => { - expect(makeStars(DAY, 390, 844)).toHaveLength(0); - expect(makeStars(skyColours(), 390, 844)).toHaveLength(0); - expect(makeStars(NIGHT, 390, 844).length).toBeGreaterThan(50); - }); - - it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { - for (const [w, h] of [ - [390, 844], - [1920, 1080], - [100, 100], - ] as const) { - expect(makeStars(NIGHT, w, h).length).toBeLessThanOrEqual(starCount(w, h)); - expect(starCount(w, h)).toBeLessThanOrEqual(MAX_STARS); - } - expect(N).toBeGreaterThan(150); - }); - - it('thins toward the horizon: more than twice the stars in the top third than the bottom third of the sky', () => { - const all = makeStars(NIGHT, 390, 844); - expect(all.filter((s) => s.y < 0.26).length).toBeGreaterThan( - 2 * all.filter((s) => s.y > 0.52).length - ); - }); - - it('fades with the sky: every star sits where the sky is dark, alpha within bounds', () => { - for (const s of makeStars(NIGHT, 390, 844)) { - expect(skyLuma(NIGHT, s.y)).toBeLessThan(NIGHT_LUMA); - expect(s.alpha).toBeLessThanOrEqual(0.9); - expect(s.alpha).toBeGreaterThanOrEqual(0.05); - } - }); - - it('varies in size and brightness', () => { - const all = makeStars(NIGHT, 390, 844); - expect(new Set(all.map((s) => s.r.toFixed(2))).size).toBeGreaterThan(50); - expect( - Math.max(...all.map((s) => s.alpha)) - Math.min(...all.map((s) => s.alpha)) - ).toBeGreaterThan(0.3); - }); -}); - -describe('twinkle schedule', () => { - const times = Array.from({ length: 400 }, (_, i) => i * 0.37); - - it('is a pure function of (seed, time): same t, same set and values', () => { - for (const t of [0, 0.4, 13.7, 3599.9]) expect(activeAt(N, t)).toEqual(activeAt(N, t)); - }); - - it('the set is SLOTS stars at every sampled time (inside 24..48), never more than the field has', () => { - expect(SLOTS).toBeGreaterThanOrEqual(24); - expect(SLOTS).toBeLessThanOrEqual(48); - for (const t of times) expect(activeAt(N, t)).toHaveLength(SLOTS); - expect(activeAt(10, 5)).toHaveLength(10); - expect(slotCount(10)).toBe(10); - expect(activeAt(0, 5)).toHaveLength(0); - }); - - it('no star is twice in the set at one instant, and every index is a real star', () => { - for (const t of times) { - const ids = activeAt(N, t).map((a) => a.star); - expect(new Set(ids).size).toBe(ids.length); - ids.forEach((i) => expect(i >= 0 && i < N).toBe(true)); - } - }); - - it("periods are each slot's own, within 2..7 s, and the offsets differ", () => { - for (let j = 0; j < SLOTS; j += 1) { - expect(slotPeriod(j)).toBeGreaterThanOrEqual(TWINKLE_PERIOD.min); - expect(slotPeriod(j)).toBeLessThanOrEqual(TWINKLE_PERIOD.max); - } - expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotPeriod(j))).size).toBe(SLOTS); - expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotOffset(j))).size).toBe(SLOTS); - for (const a of activeAt(N, 100)) expect(a.period).toBeCloseTo(slotPeriod(a.slot), 12); - }); - - it('over an hour every star twinkles at least once', () => { - const seen = new Set(); - for (let t = 0; t < 3600; t += 1) for (const a of activeAt(N, t + 0.5)) seen.add(a.star); - expect(seen.size).toBe(N); - }); - - it('a star joins at its base brightness and leaves at exactly base, with no pop', () => { - const seed = { flip: false, ripple: 3, phase: 1 }; - expect(twinkleGain(0, seed)).toBe(1); - expect(twinkleGain(1, seed)).toBe(1); - for (const flip of [true, false]) - for (const e of [1e-4, 1 - 1e-4]) - expect(Math.abs(twinkleGain(e, { ...seed, flip }) - 1)).toBeLessThan(0.01); - // through the schedule: the instant a twinkle ends the slot is on a new star's first instant, at gain 1 - for (let j = 0; j < SLOTS; j += 1) { - const end = slotOffset(j) + 40 * slotPeriod(j); - const at = activeAt(N, end).find((a) => a.slot === j)!; - expect(at.gain).toBeCloseTo(1, 9); - } - }); - - it('the star that joins is never the one that just left', () => { - for (let j = 0; j < SLOTS; j += 1) { - let prev = -1; - for (let k = 0; k < 300; k += 1) { - const t = slotOffset(j) + (k + 0.5) * slotPeriod(j); - const { star } = activeAt(N, t).find((a) => a.slot === j)!; - expect(star).not.toBe(prev); - prev = star; - } - } - }); - - it('joins never coincide: every (slot, cycle) start over an hour is distinct', () => { - const starts: number[] = []; - for (let j = 0; j < SLOTS; j += 1) - for (let k = 0; slotOffset(j) + k * slotPeriod(j) < 3600; k += 1) - starts.push(slotOffset(j) + k * slotPeriod(j)); - starts.sort((a, b) => a - b); - let min = Infinity; - for (let i = 1; i < starts.length; i += 1) min = Math.min(min, starts[i] - starts[i - 1]); - expect(min).toBeGreaterThan(0); - console.log(`joins/hour ${starts.length}, min gap between joins ${(min * 1000).toFixed(3)} ms`); - }); - - it('dims deeper than it brightens, within 0.35x..1.15x', () => { - let lo = Infinity; - let hi = -Infinity; - for (let j = 0; j < 20; j += 1) - for (let k = 0; k < 20; k += 1) { - const seed = { flip: hash2(j, k) < 0.5, ripple: 2 + (k % 2), phase: j + k }; - for (let i = 0; i <= 200; i += 1) { - const g = twinkleGain(i / 200, seed); - lo = Math.min(lo, g); - hi = Math.max(hi, g); - } - } - expect(lo).toBeGreaterThanOrEqual(TWINKLE_GAIN.dim - 1e-9); - expect(hi).toBeLessThanOrEqual(TWINKLE_GAIN.bright + 1e-9); - expect(1 - lo).toBeGreaterThan(2 * (hi - 1)); - }); - - it('stars outside the set are steady: only the active ones have a gain to apply', () => { - const inSet = new Set(activeAt(N, 50).map((a) => a.star)); - expect(inSet.size).toBeLessThan(N / 4); - }); -}); diff --git a/src/app/features/scene/ridge/stars.ts b/src/app/features/scene/ridge/stars.ts deleted file mode 100644 index 0e6d2628..00000000 --- a/src/app/features/scene/ridge/stars.ts +++ /dev/null @@ -1,249 +0,0 @@ -// The Midnight starfield: placed once from a seeded PRNG and baked once into the sky canvas. A -// rolling window of them twinkles on an overlay canvas: SLOTS slots, each running back-to-back -// twinkles of one star at a time, so the active set is always SLOTS stars and every star gets its turn. -// The schedule is a pure function of (seed, time): no state accumulates and no Math.random runs -// after the field is built, so any instant maps straight to the active set and its brightnesses. - -/* eslint-disable no-bitwise -- the seeded PRNG and hash are 32-bit integer arithmetic */ -import type { SkyColours, Srgb } from './skyColour'; - -/** - * Stars show when the sky behind them is darker than this sRGB luma (0..255). The ridge scene has - * no night flag (the sky theme's Midnight/Noonday is a body class chosen by the app, not seen here), - * so night is the sky's own darkness: a star's alpha is 1 - luma/NIGHT_LUMA at its height in the - * gradient, and a sky whose zenith is lighter than this gets no stars at all. - */ -export const NIGHT_LUMA = 110; -/** The sky gradient runs from the top (0) through mid (0.5) to the horizon glow (0.78 and below). */ -export const SKY_STOPS = { mid: 0.5, horizon: 0.78 }; -export const MAX_STARS = 400; -/** Twinkling stars at any instant (fewer only if the field has fewer stars). */ -export const SLOTS = 32; -export const TWINKLE_PERIOD = { min: 2, max: 7 } as const; -/** Gain at the dimmest and brightest swing: dimming goes deeper than brightening. */ -export const TWINKLE_GAIN = { dim: 0.35, bright: 1.15 } as const; -const SEED = 0x57a45eed; - -export interface Star { - /** Position as fractions of the sky canvas width and height. */ - x: number; - y: number; - /** Radius in CSS px. */ - r: number; - /** Base alpha 0..0.9 (brightness times how dark the sky is there). */ - alpha: number; - /** CSS colour, white tinted blue or warm. */ - fill: string; - /** The sky gradient's colour at this height: dimming paints it over the baked star. */ - cover: string; -} - -const luma = (c: Srgb) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; -const lerp = (a: number, b: number, t: number) => a + (b - a) * t; - -/** The baked gradient's colour at fraction t of the canvas height (the canvas interpolates in sRGB). */ -function skyAt(sky: SkyColours, t: number): Srgb { - const mix = (a: Srgb, b: Srgb, k: number): Srgb => [ - Math.round(lerp(a[0], b[0], k)), - Math.round(lerp(a[1], b[1], k)), - Math.round(lerp(a[2], b[2], k)), - ]; - if (t <= SKY_STOPS.mid) return mix(sky.top, sky.mid, t / SKY_STOPS.mid); - if (t <= SKY_STOPS.horizon) - return mix(sky.mid, sky.horizon, (t - SKY_STOPS.mid) / (SKY_STOPS.horizon - SKY_STOPS.mid)); - return sky.horizon; -} - -export const skyLuma = (sky: SkyColours, t: number): number => luma(skyAt(sky, t)); -export const isNight = (sky: SkyColours): boolean => luma(sky.top) < NIGHT_LUMA; - -/** mulberry32: 32-bit seeded PRNG, one float in [0, 1) per call. */ -function prng(seed: number) { - let a = seed >>> 0; - return () => { - a = (a + 0x6d2b79f5) >>> 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Stateless seeded hash of up to three integers to [0, 1). */ -export function hash(a: number, b = 0, c = 0): number { - let h = Math.imul(a | 0, 0x9e3779b1) ^ SEED; - h = Math.imul(h ^ (h >>> 15) ^ Math.imul(b | 0, 0x85ebca6b), 0xc2b2ae35); - h = Math.imul(h ^ (h >>> 13) ^ Math.imul(c | 0, 0x27d4eb2f), 0x165667b1); - h ^= h >>> 16; - return (h >>> 0) / 4294967296; -} - -/** How many stars a viewport carries: about one per 1100 CSS px squared, 150..MAX_STARS. */ -export const starCount = (cssW: number, cssH: number): number => - Math.min(MAX_STARS, Math.max(150, Math.round((cssW * cssH) / 1100))); - -/** - * Seeded placement. Each candidate draws all its random numbers whether it is kept or not, so a - * given seed and sky always give the same field and the prefix does not shift with the count. - * Heights are squeezed toward the zenith (y = 0.78 u^1.7) and alpha falls with the sky's luma, so - * the stars thin out and fade into the horizon glow. None for a day sky. - */ -export function makeStars(sky: SkyColours, cssW: number, cssH: number): Star[] { - if (!isNight(sky)) return []; - const rnd = prng(SEED); - const n = starCount(cssW, cssH); - // Every candidate draws all its numbers, kept or not, so the prefix is stable. - const candidates = Array.from({ length: MAX_STARS }, () => ({ - x: rnd(), - y: SKY_STOPS.horizon * rnd() ** 1.7, - r: 0.35 + 1.0 * rnd() ** 4, - bright: 0.35 + 0.65 * rnd() ** 1.5, - tint: rnd(), - })).slice(0, n); - return candidates.flatMap(({ x, y, r, bright, tint }) => { - const alpha = Math.min(0.9, bright * Math.max(0, 1 - skyLuma(sky, y) / NIGHT_LUMA)); - if (alpha < 0.05) return []; - // White, pulled toward blue or toward warm by the tint draw. - const to: Srgb = tint < 0.5 ? [165, 195, 255] : [255, 222, 185]; - const k = 0.55 * Math.abs(tint - 0.5) * 2; - const c = [255, 255, 255].map((v, j) => Math.round(lerp(v, to[j], k))); - const s = skyAt(sky, y); - return [ - { - x, - y, - r, - alpha, - fill: `rgb(${c[0]},${c[1]},${c[2]})`, - cover: `rgb(${s[0]},${s[1]},${s[2]})`, - }, - ]; - }); -} - -// ---- The schedule --------------------------------------------------------------------------- - -export const slotCount = (stars: number): number => Math.min(SLOTS, stars); -/** A slot's twinkle period in seconds: its own, in TWINKLE_PERIOD. */ -export const slotPeriod = (j: number): number => - TWINKLE_PERIOD.min + (TWINKLE_PERIOD.max - TWINKLE_PERIOD.min) * hash(j, 1); -/** A slot's phase: seconds into its first cycle at t = 0. */ -export const slotOffset = (j: number): number => hash(j, 2) * slotPeriod(j); - -/** Stars in slot j are those with index = j (mod slots), `m` of them; no star is in two slots. */ -const partitionSize = (stars: number, slots: number, j: number): number => - Math.floor((stars - 1 - j) / slots) + 1; - -/** The k-th star slot j twinkles: epochs of a seeded shuffle of its partition, never repeating across the seam. */ -function partitionPick(stars: number, slots: number, j: number, k: number): number { - const m = partitionSize(stars, slots, j); - const shuffled = (e: number) => { - const order = Array.from({ length: m }, (_, q) => q).sort( - (a, b) => hash(j, e, a + 10) - hash(j, e, b + 10) || a - b - ); - return order; - }; - const e = Math.floor(k / m); - const order = shuffled(e); - if (m > 1 && order[0] === shuffled(e - 1)[m - 1]) [order[0], order[1]] = [order[1], order[0]]; - return j + slots * order[k - e * m]; -} - -/** - * Brightness multiplier at fraction u of one twinkle (0..1): exactly 1 at both ends, so a star - * joins and leaves at its base brightness. One swing, a little unrelated shimmer, and the - * asymmetric map below: dimming goes to TWINKLE_GAIN.dim, brightening only to .bright. - */ -export function twinkleGain( - u: number, - seed: { flip: boolean; ripple: number; phase: number } -): number { - if (u <= 0 || u >= 1) return 1; - const main = Math.sin(2 * Math.PI * u) * (seed.flip ? -1 : 1); - const shimmer = Math.sin(2 * Math.PI * seed.ripple * u + seed.phase) * Math.sin(Math.PI * u); - const s = Math.max(-1, Math.min(1, 0.85 * main + 0.15 * shimmer)); - return s >= 0 ? 1 + (TWINKLE_GAIN.bright - 1) * s : 1 + (1 - TWINKLE_GAIN.dim) * s; -} - -export interface Twinkling { - slot: number; - star: number; - /** Seconds since this twinkle started, and its length. */ - start: number; - period: number; - gain: number; -} - -/** The active set at time `t` seconds for a field of `stars` stars, in slot order. */ -export function activeAt(stars: number, t: number): Twinkling[] { - const slots = slotCount(stars); - return Array.from({ length: slots }, (_, j) => { - const period = slotPeriod(j); - const off = slotOffset(j); - const k = Math.floor((t - off) / period); - const start = off + k * period; - const seed = { - flip: hash(j, k, 3) < 0.5, - ripple: 2 + Math.floor(hash(j, k, 4) * 2), - phase: 2 * Math.PI * hash(j, k, 5), - }; - return { - slot: j, - star: partitionPick(stars, slots, j, k), - start, - period, - gain: twinkleGain((t - start) / period, seed), - }; - }); -} - -// ---- Drawing -------------------------------------------------------------------------------- - -/** Bake `stars` into `ctx` (canvas `w` x `h` device px, `scale` device px per CSS px) at base alpha. */ -export function drawStars( - ctx: CanvasRenderingContext2D, - stars: readonly Star[], - w: number, - h: number, - scale: number -) { - stars.forEach((s) => { - ctx.globalAlpha = s.alpha; - ctx.fillStyle = s.fill; - ctx.beginPath(); - ctx.arc(s.x * w, s.y * h, s.r * scale, 0, 2 * Math.PI); - ctx.fill(); - }); - ctx.globalAlpha = 1; -} - -/** - * The overlay pass: clear, then for each active star whose gain is off base, paint over the baked - * star. Dimming covers it with the sky's own colour at (1 - gain), which leaves exactly - * alpha * gain of the star; brightening adds a second star on top. A star at base draws nothing. - */ -export function drawTwinkle( - ctx: CanvasRenderingContext2D, - stars: readonly Star[], - active: readonly Twinkling[], - w: number, - h: number, - scale: number -) { - ctx.clearRect(0, 0, w, h); - active.forEach((a) => { - const s = stars[a.star]; - if (!s || Math.abs(a.gain - 1) < 0.01) return; - if (a.gain < 1) { - ctx.globalAlpha = 1 - a.gain; - ctx.fillStyle = s.cover; - } else { - ctx.globalAlpha = Math.min(1, ((a.gain - 1) * s.alpha) / (1 - s.alpha)); - ctx.fillStyle = s.fill; - } - ctx.beginPath(); - ctx.arc(s.x * w, s.y * h, s.r * scale + (a.gain < 1 ? 1 : 0), 0, 2 * Math.PI); - ctx.fill(); - }); - ctx.globalAlpha = 1; -} diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts deleted file mode 100644 index 5c96e71b..00000000 --- a/src/app/features/scene/sceneButtons.css.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { globalStyle } from '@vanilla-extract/css'; - -// Meta, 2026-11-10: "buttons are big and ugly, too, they don't need chrome either". Over the scene a folds Button or -// Chip is its label in the page's ink, with a faint wash on hover and focus. Menus and dialogs (#portalContainer) keep -// folds' look. The selectors are folds' hashed recipe base classes (Button epr39zd, Chip _1bv8u1w6). -const scene = ':root[data-scene] #root'; -const controls = [`${scene} .epr39zd`, `${scene} ._1bv8u1w6`].join(', '); -globalStyle(controls, { - backgroundColor: 'transparent', - boxShadow: 'none', - border: 'none', - color: 'inherit', -}); -globalStyle( - [ - `${scene} .epr39zd:hover`, - `${scene} .epr39zd:focus-visible`, - `${scene} ._1bv8u1w6:hover`, - `${scene} ._1bv8u1w6:focus-visible`, - ].join(', '), - { backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' } -); -// A visible keyboard focus ring in the control's own ink, on top of the hover wash. -globalStyle([`${scene} .epr39zd:focus-visible`, `${scene} ._1bv8u1w6:focus-visible`].join(', '), { - outline: '2px solid currentColor', - outlineOffset: '2px', -}); -globalStyle(`${scene} .epr39zd`, { - fontWeight: 700, - textDecoration: 'underline', - textUnderlineOffset: '0.25em', -}); diff --git a/src/app/features/scene/sceneShell.css.ts b/src/app/features/scene/sceneShell.css.ts deleted file mode 100644 index 18911a1b..00000000 --- a/src/app/features/scene/sceneShell.css.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createVar, fallbackVar, globalStyle } from '@vanilla-extract/css'; -import { color } from 'folds'; - -// While the background scene runs (html[data-scene], set by BackgroundScene) the signed-in shell -// leaves the ridge visible behind the whole app: the page, header, sidebar and room surfaces are -// clear, with only a faint wash for hover and press. Chat bubbles (message/layout/layout.css.ts) -// and the composer (editor/Editor.css.ts) are the filled surfaces. The tokens are redefined on -// #root only, so everything that renders into #portalContainer (menus, dialogs, tooltips, -// popouts) keeps its themed fill: that is the sparse menu chrome. -const wash = (pct: number) => `color-mix(in srgb, currentColor ${pct}%, transparent)`; - -globalStyle(':root[data-scene] #root', { - vars: { - [color.Background.Container]: 'transparent', - [color.Background.ContainerHover]: wash(8), - [color.Background.ContainerActive]: wash(12), - [color.Background.ContainerLine]: wash(14), - [color.Surface.Container]: 'transparent', - [color.Surface.ContainerHover]: wash(8), - [color.Surface.ContainerActive]: wash(12), - [color.Surface.ContainerLine]: wash(14), - }, -}); - -// The themed surface colour as text on a coloured ground (avatar initials) must survive the clear -// shell above. It is captured on , above the #root override, and read back with its themed -// fallback when no scene runs. -const surfaceInkVar = createVar(); -globalStyle(':root[data-scene] body', { vars: { [surfaceInkVar]: color.Surface.Container } }); -export const surfaceInk = fallbackVar(surfaceInkVar, color.Surface.Container); diff --git a/src/app/features/scene/sceneStyle.ts b/src/app/features/scene/sceneStyle.ts deleted file mode 100644 index 7c31233e..00000000 --- a/src/app/features/scene/sceneStyle.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Temporary: which background the viewer picked (terrain or ridges), kept in one localStorage key so the two scenes we -// serve can be compared on any device. Absent, the client config's backgroundScene.style decides. -import type { SceneStyle } from './tier'; - -export type ServedSceneStyle = Extract; -export const SERVED_SCENE_STYLES: readonly ServedSceneStyle[] = ['terrain', 'ridge']; -export const SCENE_STYLE_KEY = 'ratatoskr.sceneStyle'; - -export function getSceneStyleOverride(): ServedSceneStyle | undefined { - try { - const v = localStorage.getItem(SCENE_STYLE_KEY); - return SERVED_SCENE_STYLES.includes(v as ServedSceneStyle) - ? (v as ServedSceneStyle) - : undefined; - } catch { - return undefined; - } -} - -/** Store the pick and reload, so the scene host mounts the other scene from a clean page. */ -export function setSceneStyle(s: ServedSceneStyle): void { - try { - localStorage.setItem(SCENE_STYLE_KEY, s); - } catch { - return; - } - window.location.reload(); -} diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts deleted file mode 100644 index 42631d57..00000000 --- a/src/app/features/scene/shaderSource.ts +++ /dev/null @@ -1,41 +0,0 @@ -import cultmath from './shaders/CultMath.glsl?raw'; -import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; -import common from './shaders/common.glsl?raw'; -import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; -import sdf from './shaders/sdf.glsl?raw'; -import prepassFrag from './shaders/prepass.frag.glsl?raw'; -import terrainFrag from './shaders/terrain.frag.glsl?raw'; -import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; -import resolveFrag from './shaders/resolve.frag.glsl?raw'; -import compositeFrag from './shaders/composite.frag.glsl?raw'; -import { WORLD, worldDefines } from './world'; -import { trimGlsl } from './cultmathTrim'; - -export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; - -const HEADER = - '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; - -export const vertexSource = HEADER + fullscreenVert; - -const stages: Record = { - prepass: prepassFrag, - terrain: terrainFrag, - atmosphere: atmosphereFrag, - resolve: resolveFrag, - composite: compositeFrag, -}; - -/** - * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the - * shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define` - * lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where - * it is called. - */ -export function fragmentSource(stage: Stage, defines = ''): string { - const solid = stage === 'terrain' || stage === 'prepass'; - const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; - const world = solid ? sdf : ''; - const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); - return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; -} diff --git a/src/app/features/scene/shaders/CultMath.Phacelle.glsl b/src/app/features/scene/shaders/CultMath.Phacelle.glsl deleted file mode 100644 index 11405fa2..00000000 --- a/src/app/features/scene/shaders/CultMath.Phacelle.glsl +++ /dev/null @@ -1,99 +0,0 @@ -// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it. -#ifndef CULTMATH_PHACELLE_GLSL -#define CULTMATH_PHACELLE_GLSL -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the -// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke -// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact -// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after -// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). - -// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit -// against CultMath.CultPhasor, same field order. -struct CultPhasor -{ - vec4 cos; - vec4 sin; -}; - -// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave -// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors -// the C# body operation for operation; the C#-only internal overload that switches the prune off -// has no shader twin. -CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization) -{ - vec3 cell = floor(p); - vec3 local = p - cell; - float phaseOffset = offset * CULTMATH_TAU; - float sumW = 0.0; - float sumCos = 0.0; - float sumSin = 0.0; - vec3 gradW = vec3(0.0, 0.0, 0.0); - vec3 gradCos = vec3(0.0, 0.0, 0.0); - vec3 gradSin = vec3(0.0, 0.0, 0.0); - - for (int dz = -1; dz <= 2; dz++) - { - float gz = max(0.0, abs(local.z - float(dz)) - 0.5); - for (int dy = -1; dy <= 2; dy++) - { - float gy = max(0.0, abs(local.y - float(dy)) - 0.5); - for (int dx = -1; dx <= 2; dx++) - { - float gx = max(0.0, abs(local.x - float(dx)) - 0.5); - if (gx * gx + gy * gy + gz * gz >= 2.25) - continue; - - vec3 gridStep = vec3(dx, dy, dz); - ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep)); - vec3 jitter = vec3( - float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5, - float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5, - float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5); - vec3 v = local - gridStep - jitter; - float falloff = exp(-2.0 * dot(v, v)); - float w = max(0.0, falloff - 0.01111); - vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0); - float phase = dot(v, side) + phaseOffset; - float c = cos(phase); - float s = sin(phase); - - sumW += w; - sumCos += w * c; - sumSin += w * s; - gradW += dw; - gradCos += dw * c - (w * s) * side; - gradSin += dw * s + (w * c) * side; - } - } - } - - float invW = 1.0 / sumW; - float rawCos = sumCos * invW; - float rawSin = sumSin * invW; - vec3 gradRawCos = (gradCos - rawCos * gradW) * invW; - vec3 gradRawSin = (gradSin - rawSin * gradW) * invW; - float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); - float floorLength = 1.0 - normalization; - - CultPhasor result; - if (rawLength > floorLength) - { - float outCos = rawCos / rawLength; - float outSin = rawSin / rawLength; - vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); - } - else - { - result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); - } - - return result; -} - -#endif diff --git a/src/app/features/scene/shaders/CultMath.glsl b/src/app/features/scene/shaders/CultMath.glsl deleted file mode 100644 index 8a380a96..00000000 --- a/src/app/features/scene/shaders/CultMath.glsl +++ /dev/null @@ -1,1035 +0,0 @@ -// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it. -#ifndef CULTMATH_GLSL -#define CULTMATH_GLSL -// CultMath HLSL mirror. -// -// HLSL already owns the vec2/vec3/vec4 type names and most primitive -// intrinsics. This include names the CultMath semantic surface explicitly so -// shader code, C# wrappers, and Rust/native parity fixtures can share the same -// small vocabulary without local helper drift. - -const float CULTMATH_PI = 3.14159265358979323846; -const float CULTMATH_TAU = 6.28318530717958647692; -const float CULTMATH_HALF_PI = 1.57079632679489661923; - -float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } -float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } - -float cultmath_frac(float value) { return value - floor(value); } -vec2 cultmath_frac(vec2 value) { return value - floor(value); } -vec3 cultmath_frac(vec3 value) { return value - floor(value); } -vec4 cultmath_frac(vec4 value) { return value - floor(value); } - -float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } -vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); } -vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); } -vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); } - -// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. -float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } -vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); } -vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); } -vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); } - -float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } -vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; } -vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; } -vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; } - -float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } -vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } -vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } -vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } - -float cultmath_smoothstep(float minimum, float maximum, float value) -{ - float t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0 - 2.0 * t); -} - -vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value) -{ - vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t); -} - -vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value) -{ - vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t); -} - -vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value) -{ - vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t); -} - -float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } - -float cultmath_smootherstep(float value) -{ - value = cultmath_saturate(value); - return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); -} - -vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } -vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } - -// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation -// order as the C# math.snoise(vec3) mirror. Ties resolve in simplexnoise1234's -// total order; see math.snoise. -float cultmath_snoise(vec3 value) -{ - vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - vec3 i = floor(value + dot(value, c.yyy)); - vec3 x0 = value - i + dot(i, c.xxx); - vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - m = m * m; - return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); -} - -float cultmath_snoise(vec2 value) -{ - vec4 c = vec4( - 0.211324865405187, - 0.366025403784439, - -0.577350269189626, - 0.024390243902439); - vec2 i = floor(value + dot(value, c.yy)); - vec2 x0 = value - i + dot(i, c.xx); - vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); - vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); - - i = cultmath_snoise_mod289(i); - vec3 p = cultmath_snoise_permute( - cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); - vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); - m *= m; - m *= m; - - vec3 x = 2.0 * fract(p * c.w) - 1.0; - vec3 h = abs(x) - 0.5; - vec3 ox = floor(x + 0.5); - vec3 a0 = x - ox; - m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); - - vec3 g = vec3( - a0.x * x0.x + h.x * x0.y, - a0.y * x12.x + h.y * x12.y, - a0.z * x12.z + h.z * x12.w); - return 130.0 * dot(m, g); -} - -// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise -// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as -// cultmath_snoise(vec3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the -// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(vec3)'s own derivation -// verbatim. -vec4 cultmath_snoise_grad(vec3 value) -{ - vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - vec3 i = floor(value + dot(value, c.yyy)); - vec3 x0 = value - i + dot(i, c.xxx); - vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - vec4 m2 = m0 * m0; - vec4 m3 = m2 * m0; - vec4 m4 = m2 * m2; - - vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - float value2 = 105.0 * dot(m4, px); - vec3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 - + -8.0 * m3.y * x1 * px.y + m4.y * p1 - + -8.0 * m3.z * x2 * px.z + m4.z * p2 - + -8.0 * m3.w * x3 * px.w + m4.w * p3; - grad *= 105.0; - - return vec4(grad, value2); -} - -// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the -// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is -// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the -// two agree. -mat3 cultmath_snoise_hessian(vec3 value) -{ - vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); - vec3 i = floor(value + dot(value, c.yyy)); - vec3 x0 = value - i + dot(i, c.xxx); - vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); - vec3 l = 1.0 - g; - vec3 i1 = min(g.xyz, l.zxy); - vec3 i2 = max(g.xyz, l.zxy); - vec3 x1 = x0 - i1 + c.xxx; - vec3 x2 = x0 - i2 + c.yyy; - vec3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); - vec4 j = p - 49.0 * floor(p * ns.z * ns.z); - vec4 x_ = floor(j * ns.z); - vec4 y_ = floor(j - 7.0 * x_); - vec4 x = x_ * ns.x + ns.yyyy; - vec4 y = y_ * ns.x + ns.yyyy; - vec4 h = 1.0 - abs(x) - abs(y); - - vec4 b0 = vec4(x.xy, y.xy); - vec4 b1 = vec4(x.zw, y.zw); - vec4 s0 = floor(b0) * 2.0 + 1.0; - vec4 s1 = floor(b1) * 2.0 + 1.0; - vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); - vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - vec3 p0 = vec3(a0.xy, h.x); - vec3 p1 = vec3(a0.zw, h.y); - vec3 p2 = vec3(a1.xy, h.z); - vec3 p3 = vec3(a1.zw, h.w); - vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - vec4 m2 = m0 * m0; - vec4 m3 = m2 * m0; - vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - vec4 wide = 48.0 * m2 * px; - vec4 narrow = 8.0 * m3; - - vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x); - vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y); - vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z); - vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x); - vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y); - vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z); - - float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px)); - float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px)); - float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px)); - float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx)); - float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx)); - float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy)); - return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz); -} - -// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment. -float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1) -{ - lo = min(lo, hi); - if (b == 0.0) - return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; - - float direction = t1 < t0 ? -1.0 : 1.0; - float start = min(t0, t1); - float stop = max(t0, t1); - float atLo = (lo - a) / b; - float atHi = (hi - a) / b; - float enter = clamp(min(atLo, atHi), start, stop); - float leave = clamp(max(atLo, atHi), start, stop); - float before = b > 0.0 ? lo : hi; - float after = b > 0.0 ? hi : lo; - float held = enter > start ? before * (enter - start) : 0.0; - float kept = stop > leave ? after * (stop - leave) : 0.0; - float total = held + (a + b * (0.5 * (enter + leave))) * (leave - enter) + kept; - return direction * total; -} - -// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the -// per-octave frequency/amplitude derivation and why octaves is clamped. -vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - vec3 gradient = vec3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - vec4 n = cultmath_snoise_grad(p * frequency); - value += amplitude * n.w; - gradient += amplitude * frequency * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return vec4(gradient, value); -} - -// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad -// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. -vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - vec3 gradient = vec3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - vec4 n = cultmath_snoise_grad(p * frequency); - float s = sign(n.w); - value += amplitude * (1.0 - abs(n.w)); - gradient += -amplitude * frequency * s * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return vec4(gradient, value); -} - -float cultmath_lengthsq(vec2 value) { return dot(value, value); } -float cultmath_lengthsq(vec3 value) { return dot(value, value); } -float cultmath_lengthsq(vec4 value) { return dot(value, value); } - -float cultmath_distance(vec2 left, vec2 right) { return length(left - right); } -float cultmath_distance(vec3 left, vec3 right) { return length(left - right); } -float cultmath_distance(vec4 left, vec4 right) { return length(left - right); } - -vec2 cultmath_reflect(vec2 incident, vec2 normal_value) -{ - return incident - 2.0 * dot(normal_value, incident) * normal_value; -} - -vec3 cultmath_reflect(vec3 incident, vec3 normal_value) -{ - return incident - 2.0 * dot(normal_value, incident) * normal_value; -} - -vec2 cultmath_rotate(vec2 value, float radians_value) -{ - float s = sin(radians_value); - float c = cos(radians_value); - return vec2(value.x * c - value.y * s, value.x * s + value.y * c); -} - -vec2 cultmath_rotate_degrees(vec2 value, float degrees_value) -{ - return cultmath_rotate(value, cultmath_radians(degrees_value)); -} - -float cultmath_csum(vec2 value) { return value.x + value.y; } -float cultmath_csum(vec3 value) { return value.x + value.y + value.z; } -float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; } - -float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } - -float cultmath_damp(float start, float end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt) -{ - float amount = 1.0 - exp(-lambda_value * dt); - return cultmath_lerp(start, end, vec2(amount, amount)); -} - -vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt) -{ - float amount = 1.0 - exp(-lambda_value * dt); - return cultmath_lerp(start, end, vec3(amount, amount, amount)); -} - -vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt) -{ - float amount = 1.0 - exp(-lambda_value * dt); - return cultmath_lerp(start, end, vec4(amount, amount, amount, amount)); -} - -float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } -float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); } -float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); } - -// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits. -uint cultmath_pcg(uint value) -{ - uint state = value * 747796405u + 2891336453u; - uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u; - return (word >> 22) ^ word; -} - -ivec3 cultmath_pcg3d(ivec3 value) -{ - uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u, z = uint(value.z) * 1664525u + 1013904223u; - x += y * z; y += z * x; z += x * y; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; - x += y * z; y += z * x; z += x * y; - return ivec3(int(x), int(y), int(z)); -} - -ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); } -ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); } - -ivec4 cultmath_pcg4d(ivec4 value) -{ - uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u; - uint z = uint(value.z) * 1664525u + 1013904223u, w = uint(value.w) * 1664525u + 1013904223u; - x += y * w; y += z * x; z += x * y; w += y * z; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; - x += y * w; y += z * x; z += x * y; w += y * z; - return ivec4(int(x), int(y), int(z), int(w)); -} - -ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(value.w)))); } - -// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See -// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. -vec4 cultmath_smin_grad(vec4 a, vec4 b, float k) -{ - float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0); - float value = mix(b.w, a.w, h) - k * h * (1.0 - h); - vec3 gradient = mix(b.xyz, a.xyz, h); - return vec4(gradient, value); -} - -// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): -// compared field by field and bit for bit against CultMath.CultCellular, same field order. -struct CultCellular -{ - vec4 nearest; - vec4 edge; - float id; -}; - -// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment -// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and -// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. -CultCellular cultmath_cellular(vec3 p) -{ - vec3 cell = floor(p); - float f1 = 1.0e30; - float f2 = 1.0e30; - vec3 c1 = vec3(0.0, 0.0, 0.0); - vec3 c2 = vec3(0.0, 0.0, 0.0); - vec3 cellId = vec3(0.0, 0.0, 0.0); - - for (int dz = -2; dz <= 2; dz++) - { - float lz = max(0.0, abs(float(dz)) - 1.0); - for (int dy = -2; dy <= 2; dy++) - { - float ly = max(0.0, abs(float(dy)) - 1.0); - for (int dx = -2; dx <= 2; dx++) - { - float lx = max(0.0, abs(float(dx)) - 1.0); - float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); - if (lowerBound >= f2) - continue; - - vec3 neighbor = cell + vec3(dx, dy, dz); - ivec3 hash = cultmath_pcg3d(ivec3(neighbor)); - vec3 jitter = vec3( - float(uint(hash.x) >> 8) * (1.0 / 16777216.0), - float(uint(hash.y) >> 8) * (1.0 / 16777216.0), - float(uint(hash.z) >> 8) * (1.0 / 16777216.0)); - vec3 feature = neighbor + jitter; - float d = cultmath_distance(p, feature); - - if (d < f1) - { - f2 = f1; c2 = c1; - f1 = d; c1 = feature; cellId = neighbor; - } - else if (d < f2) - { - f2 = d; c2 = feature; - } - } - } - } - - // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment - // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. - vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0); - vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(0.0, 0.0, 0.0); - - // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule - // as the jitter above), never from the pcg3d hash that produced its jitter (F4). - ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0)); - - CultCellular result; - result.nearest = vec4(grad1, f1); - result.edge = vec4(grad2 - grad1, f2 - f1); - result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0); - return result; -} - - - - -// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns -// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for -// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening. -// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise. - -// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ). -const float CULTMATH_SNOISE_LIPSCHITZ = 7.9640074; - -// Lipschitz constant of cultmath_snoise(vec2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ). -const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002; - -vec2 cultmath_iv_point(float value) { return vec2(value, value); } -vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); } -vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); } -vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); } - -vec2 cultmath_iv_mul(vec2 a, vec2 b) -{ - float p0 = a.x * b.x; - float p1 = a.x * b.y; - float p2 = a.y * b.x; - float p3 = a.y * b.y; - return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3))); -} - -vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); } - -vec2 cultmath_iv_abs(vec2 a) -{ - if (a.x >= 0.0) - return a; - if (a.y <= 0.0) - return vec2(-a.y, -a.x); - return vec2(0.0, max(-a.x, a.y)); -} - -vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); } -vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); } - -vec2 cultmath_iv_sqr(vec2 a) -{ - float l = a.x * a.x; - float h = a.y * a.y; - if (a.x >= 0.0) - return vec2(l, h); - if (a.y <= 0.0) - return vec2(h, l); - return vec2(0.0, max(l, h)); -} - -vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); } - -// Widened by one ulp on each side: exp is not required to be correctly rounded. -vec2 cultmath_iv_exp(vec2 a) -{ - float lo = exp(a.x); - float hi = exp(a.y); - return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u)); -} - -vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum) -{ - return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum)); -} - -vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); } - -// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is -// monotone only to within one ulp. -vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a) -{ - float s0 = cultmath_smoothstep(minimum, maximum, a.x); - float s1 = cultmath_smoothstep(minimum, maximum, a.y); - float lo = min(s0, s1); - float hi = max(s0, s1); - return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0); -} - -// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates. -vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); } - -// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. -vec2 cultmath_iv_snoise_ball(vec3 centre, float radius) -{ - float n = cultmath_snoise(centre); - float e = CULTMATH_SNOISE_LIPSCHITZ * radius; - return vec2(max(n - e, -1.0), min(n + e, 1.0)); -} - -// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. -vec2 cultmath_iv_snoise_ball(vec2 centre, float radius) -{ - float n = cultmath_snoise(centre); - float e = CULTMATH_SNOISE2_LIPSCHITZ * radius; - return vec2(max(n - e, -1.0), min(n + e, 1.0)); -} - -// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does. -vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - vec2 sum = vec2(0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude)); - frequency *= lacunarity; - amplitude *= gain; - } - - return sum; -} - -// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment -// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z). -// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by -// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs. -vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) -{ - float zm = (z0 + z1) * 0.5; - float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0); - float cx = centreSlope.x * zm; - float cy = centreSlope.y * zm; - float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp; - radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7; - return vec4(cx, cy, zm, radius); -} - - - - - - -// Reduced affine forms with one shared symbol over vec3(x0, a, e): the set { x0 + a eps + e delta : -// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors -// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure -// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after -// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own -// definition of cultmath_snoise_grad. - -// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in -// math.Affine.cs (SNOISE_HESSIAN). -const float CULTMATH_SNOISE_HESSIAN = 55.642143; - -// The absolute rounding of one float32 read of cultmath_snoise_grad, value and gradient; the owner is -// math.Affine.cs (SNOISE_READ_VALUE, SNOISE_READ_GRADIENT), read here by cultmath_af_snoise and in -// CultMath.hlsl by cultmath_span_slack. -const float CULTMATH_SNOISE_READ_VALUE = 6.103515625e-5; -const float CULTMATH_SNOISE_READ_GRADIENT = 2.44140625e-4; - -// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the -// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD). -const float CULTMATH_SNOISE_THIRD = 535.8009; - -vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); } -vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); } - -vec3 cultmath_af_from_iv(vec2 interval) -{ - float x0 = interval.x * 0.5 + interval.y * 0.5; - float e = interval.y * 0.5 - interval.x * 0.5; - return vec3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38); -} - -// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks -// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags. -vec2 cultmath_af_range(vec3 x) -{ - float r = abs(x.y) + x.z; - float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7; - float lo = x.x - r - w; - float hi = x.x + r + w; - return vec2((floatBitsToUint(lo) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0xff800000u) : lo, - (floatBitsToUint(hi) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0x7f800000u) : hi); -} - -vec3 cultmath_af_add(vec3 x, vec3 y) -{ - float x0 = x.x + y.x; - float a = x.y + y.y; - float e = x.z + y.z; - return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); -} - -vec3 cultmath_af_sub(vec3 x, vec3 y) -{ - float x0 = x.x - y.x; - float a = x.y - y.y; - float e = x.z + y.z; - return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); -} - -vec3 cultmath_af_neg(vec3 x) { return vec3(-x.x, -x.y, x.z); } - -vec3 cultmath_af_scale(vec3 x, float s) -{ - float x0 = x.x * s; - float a = x.y * s; - float e = x.z * abs(s); - return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38); -} - -vec3 cultmath_af_add_iv(vec3 x, vec2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); } - -vec3 cultmath_af_mul(vec3 x, vec3 y) -{ - float x0 = x.x * y.x; - float p = x.x * y.y; - float q = y.x * x.y; - float a = p + q; - float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z); - return vec3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38); -} - -// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w, -// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is -// in math.Affine.cs. -vec3 cultmath_af_snoise(vec3 centre, vec3 axis, float radius) -{ - vec4 g = cultmath_snoise_grad(centre); - vec3 gradient = vec3(g.x, g.y, g.z); - float reach = length(axis) + radius; - float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach; - float a = dot(gradient, axis); - float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach; - float allowance = CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_READ_GRADIENT * reach - + CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach); - bool centred = abs(a) + remainder < lipschitz; - float slope = centred ? a : 0.0; - float e = (centred ? remainder : lipschitz) + allowance; - return vec3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7); -} - -// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does. -vec3 cultmath_af_fbm(vec3 centre, vec3 axis, float radius, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - vec3 sum = vec3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude)); - frequency *= lacunarity; - amplitude *= gain; - } - - return sum; -} - -// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of -// cultmath_iv_frustum_ball. -vec3 cultmath_af_frustum_axis(vec2 centreSlope, float z0, float z1) -{ - float h = (z1 - z0) * 0.5; - return vec3(centreSlope.x * h, centreSlope.y * h, h); -} - -// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs. -vec4 cultmath_af_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation) -{ - float zm = (z0 + z1) * 0.5; - float h = (z1 - z0) * 0.5; - float cx = centreSlope.x * zm; - float cy = centreSlope.y * zm; - float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h; - float radius = z1 * footprintPerDepth + warpVariation; - radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7; - return vec4(cx, cy, zm, radius); -} - - - - -// Span stages (invariant 8): the SpanPath, SpanStage and SpanNoise of math.Span.cs, field for field in -// C#'s order, and cultmath_span_stage, its octave stage, statement for statement so the text is a bit-for-bit -// mirror. They follow the includes because the bounds read CULTMATH_SNOISE_LIPSCHITZ, _HESSIAN and _THIRD. -// A shader caller builds a SpanStage itself (SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0)) -// is the identity of math.SpanStage.Identity, which has no shader form); see math.span_stage's comments for -// the span, the transform, the bounds and the allowances. cultmath_span_rounding and cultmath_span_slack -// are math.span_rounding and math.span_slack, the owners of S_o and of the three allowances. -struct SpanPath -{ - vec3 midpoint; - vec3 velocity; - float speed; - float acceleration; - float halfLength; -}; - -struct SpanStage -{ - float frequency; - float stretch; - vec3 axis; - vec3 shift; - vec3 offset; - vec3 octaveStep; -}; - -struct SpanNoise -{ - vec4 field; - float curvature; - float fold; -}; - -float cultmath_span_rounding(float scale, vec3 mq, SpanStage stage, vec3 octaveShift, float nu, float halfLength) -{ - return 9.5367431640625e-7 * (abs(scale) * (cultmath_csum(abs(mq)) + cultmath_csum(abs(stage.shift))) + cultmath_csum(abs(stage.offset)) + cultmath_csum(abs(octaveShift)) + 1.7320508 * nu * halfLength); -} - -vec3 cultmath_span_slack(float rounding) -{ - return vec3(CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_LIPSCHITZ * rounding, - CULTMATH_SNOISE_READ_GRADIENT + CULTMATH_SNOISE_HESSIAN * rounding, - 9.765625e-4 + CULTMATH_SNOISE_THIRD * rounding); -} - -// The octave stage behind cultmath_span_fbm and cultmath_span_ridged, as math.span_stage is behind span_fbm -// and span_ridged. The Frobenius norm of the Hessian is written out in C#'s order (GLSL has no _mRC member -// access); the C# local named step is octaveShift here (it would shadow GLSL's step()). -SpanNoise cultmath_span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) -{ - vec3 q = path.midpoint; - float halfLength = path.halfLength; - octaves = clamp(octaves, 0, 16); - bool stretched = dot(stage.axis, stage.axis) > 0.0 && stage.stretch != 1.0; - vec3 direction = stretched ? normalize(stage.axis) : vec3(0.0, 0.0, 0.0); - float squeeze = stretched ? 1.0 / stage.stretch - 1.0 : 0.0; - float norm = abs(stage.frequency) * (stretched ? max(1.0, 1.0 / stage.stretch) : 1.0); - vec3 mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; - vec3 mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; - float amplitude = 1.0; - float scale = 1.0; - float value = 0.0; - vec3 gradient = vec3(0.0, 0.0, 0.0); - float curvature = 0.0; - float fold = 0.0; - for (int i = 0; i < octaves; i++) - { - vec3 octaveShift = float(i) * stage.octaveStep; - vec3 x = scale * (mq + stage.shift) + (stage.offset + octaveShift); - vec4 n = cultmath_snoise_grad(x); - vec3 g = vec3(n.x, n.y, n.z); - vec3 mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; - if (ridged) - { - float s = sign(n.w); - value += amplitude * (1.0 - abs(n.w)); - gradient += -amplitude * scale * s * mg; - } - else - { - value += amplitude * n.w; - gradient += amplitude * scale * mg; - } - - float weight = abs(amplitude); - float factor = abs(scale) * norm; - float nu = factor * path.speed; - float rounding = cultmath_span_rounding(scale, mq, stage, octaveShift, nu, halfLength); - vec3 slack = cultmath_span_slack(rounding); - float valueSlack = slack.x; - float gradientSlack = slack.y; - float hessianSlack = slack.z; - mat3 hm = cultmath_snoise_hessian(x); - float frobenius = sqrt(hm[0][0] * hm[0][0] + hm[0][1] * hm[0][1] + hm[0][2] * hm[0][2] + hm[1][0] * hm[1][0] + hm[1][1] * hm[1][1] + hm[1][2] * hm[1][2] - + hm[2][0] * hm[2][0] + hm[2][1] * hm[2][1] + hm[2][2] * hm[2][2]); - float hessianBound = min(CULTMATH_SNOISE_HESSIAN, frobenius + hessianSlack + CULTMATH_SNOISE_THIRD * nu * halfLength); - curvature += weight * factor * factor * hessianBound; - if (ridged) - { - vec3 w = scale * mv; - float reach = hessianBound * nu * halfLength; - float gl = min(CULTMATH_SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); - float travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (hessianBound * nu * length(w) + gl * factor * path.acceleration) * (0.5 * halfLength * halfLength) + 2.0 * valueSlack; - fold += 2.0 * weight * max(travel - abs(n.w), 0.0); - } - - scale *= lacunarity; - amplitude *= gain; - } - - SpanNoise result; - result.field = vec4(gradient, value); - result.curvature = curvature; - result.fold = fold; - return result; -} - -SpanNoise cultmath_span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) -{ - return cultmath_span_stage(false, path, stage, octaves, lacunarity, gain); -} - -SpanNoise cultmath_span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) -{ - return cultmath_span_stage(true, path, stage, octaves, lacunarity, gain); -} - -float cultmath_value_noise(vec2 position) -{ - vec2 cell = floor(position); - vec2 local = cultmath_frac(position); - vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local); - - float a = cultmath_hash(cell); - float b = cultmath_hash(cell + vec2(1.0, 0.0)); - float c = cultmath_hash(cell + vec2(0.0, 1.0)); - float d = cultmath_hash(cell + vec2(1.0, 1.0)); - - return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); -} - -float cultmath_value_noise_bicubic(vec2 position) -{ - vec2 cell = floor(position); - vec2 local = cultmath_frac(position); - - float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x); - float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x); - float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x); - float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x); - - return cultmath_catmullrom(y0, y1, y2, y3, local.y); -} - -#endif diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md deleted file mode 100644 index b970f82b..00000000 --- a/src/app/features/scene/shaders/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Vendored CultMath GLSL - -`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2 -lowering of `packages/cultmath/shaders/CultMath.hlsl` and -`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch -`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries -the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch -does not carry the GLSL yet). Never edit them here: regenerate in CultLib and -re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune -Skovbo Johansen's Phacelle noise, see its header), so that file keeps its -header and any change to it is published under MPL-2.0. - -The other `*.glsl` files here are the scene's own stages. They are -concatenated by `../shaderSource.ts` after a prelude; they use no colour -literal (the palette is uniforms) and define no noise. - -`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches -(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage -alone. diff --git a/src/app/features/scene/shaders/atmosphere.frag.glsl b/src/app/features/scene/shaders/atmosphere.frag.glsl deleted file mode 100644 index adbf1b5b..00000000 --- a/src/app/features/scene/shaders/atmosphere.frag.glsl +++ /dev/null @@ -1,90 +0,0 @@ -// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud -// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance. -uniform sampler2D u_depth; // pass 1 hit distance, nearest -uniform int u_steps; // cloud march steps -uniform float u_frame; -uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target -uniform vec2 u_size; // this target's size in pixels -uniform float u_coverage; // weather knob: 0 clear .. 1 overcast - -in vec2 v_uv; -layout(location = 0) out vec4 o_atm; - -float hg(float cosTheta, float g) -{ - float g2 = g * g; - return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5)); -} - -float cloudDensity(vec3 p) -{ - float wind = u_time * 2.5; - float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w; - float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0); - float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h); - return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile; -} - -void main() -{ - vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size; - vec3 rd = camRay(jittered); - vec3 ro = u_camPos; - float depth = texture(u_depth, v_uv).r; - float tEnd = min(depth, W_ATM_FAR); - - // Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray. - float k = W_FOG_FALLOFF; - float dy = rd.y * tEnd; - float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd); - float fogT = exp(-max(tau, 0.0)); - float cosSun = dot(rd, u_sunDir); - vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9; - vec3 inscatter = fogLight * (1.0 - fogT); - float trans = fogT; - - // Clouds: only the part of the ray inside the slab and in front of the surface. - float ta = 0.0; - float tb = tEnd; - if (abs(rd.y) > 1e-4) - { - float t0 = (W_CLOUD_Y0 - ro.y) / rd.y; - float t1 = (W_CLOUD_Y1 - ro.y) / rd.y; - ta = max(0.0, min(t0, t1)); - tb = min(tEnd, max(t0, t1)); - } - else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1) - tb = 0.0; - - if (tb > ta && u_steps > 0) - { - float dt = (tb - ta) / float(u_steps); - float t = ta + dt * ign(gl_FragCoord.xy, u_frame); - float T = 1.0; - vec3 L = vec3(0.0); - float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2); - for (int i = 0; i < u_steps; i++) - { - vec3 p = ro + rd * t; - float d = cloudDensity(p); - if (d > 0.01) - { - // One sample toward the sun stands in for self-shadowing. - float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2); - vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35; - float ext = d * dt * 0.035; - float stepT = exp(-ext); - L += T * (1.0 - stepT) * light; - T *= stepT; - if (T < 0.03) - break; - } - t += dt; - } - // Fog in front of the slab is already in `inscatter`; clouds sit behind it. - inscatter += trans * L; - trans *= T; - } - - o_atm = vec4(inscatter, trans); -} diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl deleted file mode 100644 index 6378a2e6..00000000 --- a/src/app/features/scene/shaders/common.glsl +++ /dev/null @@ -1,31 +0,0 @@ -// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines. -uniform vec3 u_camPos; -uniform mat3 u_camBasis; // columns: right, up, forward -uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2) -uniform float u_time; -uniform vec3 u_sunDir; // unit, towards the sun -uniform vec3 u_sunColor; -uniform vec3 u_skyTop; -uniform vec3 u_skyHorizon; -uniform vec3 u_fogColor; - -vec3 camRay(vec2 uv) -{ - vec2 ndc = uv * 2.0 - 1.0; - return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0)); -} - -float ign(vec2 p, float frame) -{ - p += 5.588238 * mod(frame, 64.0); - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -vec3 skyColor(vec3 rd) -{ - float h = clamp(rd.y, 0.0, 1.0); - vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); - float s = max(dot(rd, u_sunDir), 0.0); - c += u_sunColor * (1.5 * pow(s, 256.0)); - return c; -} diff --git a/src/app/features/scene/shaders/composite.frag.glsl b/src/app/features/scene/shaders/composite.frag.glsl deleted file mode 100644 index 9dce31f1..00000000 --- a/src/app/features/scene/shaders/composite.frag.glsl +++ /dev/null @@ -1,26 +0,0 @@ -// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere. -uniform sampler2D u_solid; // rgb lit, a coverage (bilinear) -uniform sampler2D u_atm; // TAA'd atmosphere (bilinear) -uniform float u_frame; - -in vec2 v_uv; -out vec4 o_frag; - -vec3 tonemap(vec3 x) -{ - // Narkowicz ACES fit. - return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); -} - -void main() -{ - vec3 rd = camRay(v_uv); - vec4 solid = texture(u_solid, v_uv); - vec4 atm = texture(u_atm, v_uv); - vec3 base = mix(skyColor(rd), solid.rgb, solid.a); - vec3 col = base * atm.a + atm.rgb; - col = tonemap(col); - col = pow(col, vec3(1.0 / 2.2)); - col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; - o_frag = vec4(col, 1.0); -} diff --git a/src/app/features/scene/shaders/fullscreen.vert.glsl b/src/app/features/scene/shaders/fullscreen.vert.glsl deleted file mode 100644 index cab0e527..00000000 --- a/src/app/features/scene/shaders/fullscreen.vert.glsl +++ /dev/null @@ -1,8 +0,0 @@ -out vec2 v_uv; -void main() -{ - // One oversized triangle; uv spans 0..1 over the visible part. - vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - v_uv = p; - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl deleted file mode 100644 index 4ca21a2c..00000000 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ /dev/null @@ -1,57 +0,0 @@ -// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's -// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the -// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any -// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball -// around each ray's own point. -uniform int u_maxSteps; -uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it - -in vec2 v_uv; -layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both) - -void main() -{ - vec3 ro = u_camPos; - vec3 rd = camRay(v_uv); - - // Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit - // directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so - // the maximum sits at a corner. The margin is for float rounding. - float spread = 0.0; - for (int c = 0; c < 4; c++) - { - vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0; - spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd)); - } - spread *= 1.05; - - // Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR): - // none can hit anything. - float rise = rd.y - spread; - float tEnd = W_FAR; - if (rise > 0.0) - tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise)); - - float t = 0.0; - float leafness; - bool onTree; - int evals = 0; - for (int i = 0; i < u_maxSteps && t < tEnd; i++) - { - evals++; - // Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so - // no ray of the tile starts inside its own hit radius. - float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006; - if (s <= 0.0) - break; - t += s; - } - float start = t; -#ifdef START_HALF - // The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past - // the true one is a ray that could skip a surface, so shave more than half a ulp off first - // (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds. - start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS); -#endif - o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0); -} diff --git a/src/app/features/scene/shaders/resolve.frag.glsl b/src/app/features/scene/shaders/resolve.frag.glsl deleted file mode 100644 index cc6dd2e0..00000000 --- a/src/app/features/scene/shaders/resolve.frag.glsl +++ /dev/null @@ -1,41 +0,0 @@ -// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the -// current frame's 3x3 neighbourhood, blend. -uniform sampler2D u_cur; -uniform sampler2D u_history; -uniform sampler2D u_depth; -uniform vec3 u_prevPos; -uniform mat3 u_prevBasis; -uniform vec2 u_prevTanHalf; -uniform float u_blend; // weight of the current frame; 1 resets -uniform vec2 u_curSize; - -in vec2 v_uv; -layout(location = 0) out vec4 o_hist; - -void main() -{ - vec2 px = 1.0 / u_curSize; - vec4 cur = texture(u_cur, v_uv); - vec4 lo = cur; - vec4 hi = cur; - for (int y = -1; y <= 1; y++) - for (int x = -1; x <= 1; x++) - { - vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px); - lo = min(lo, s); - hi = max(hi, s); - } - - // Where this pixel's ray ended, reprojected into last frame's camera. - vec3 rd = camRay(v_uv); - float t = min(texture(u_depth, v_uv).r, 600.0); - vec3 v = u_camPos + rd * t - u_prevPos; - vec3 local = transpose(u_prevBasis) * v; - vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0); - vec2 prevUv = prevNdc * 0.5 + 0.5; - bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0))); - - float a = (inside && u_blend < 1.0) ? u_blend : 1.0; - vec4 hist = clamp(texture(u_history, prevUv), lo, hi); - o_hist = mix(hist, cur, a); -} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl deleted file mode 100644 index fd5e6363..00000000 --- a/src/app/features/scene/shaders/sdf.glsl +++ /dev/null @@ -1,95 +0,0 @@ -// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the -// cone prepass, so both step by the same bound. - -// ---- terrain ------------------------------------------------------------------------------- - -float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } - -// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. -float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } - -#if W_ERODE_ENABLED -CultPhasor terrainPhacelle(vec2 xz) -{ - // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. - return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); -} -#endif - -// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the -// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. -float terrainDistance(vec3 p) -{ - float m = terrainMask(p.xz); - // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most - // of their steps here. - if (m <= 0.0) - return p.y * W_SLOPE_ENV_INV; - float hb = m * W_AMP * terrainFbm(p.xz); - float above = p.y - (hb + m * W_ERODE_AMP); - if (above > 0.0) - return above * W_SLOPE_ENV_INV; -#if W_ERODE_ENABLED - hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; -#endif - return (p.y - hb) * W_SLOPE_FULL_INV; -} - -vec3 terrainNormal(vec2 xz) -{ - float r = length(xz); - float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); - float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); - float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; - vec2 dir = r > 0.0 ? xz / r : vec2(0.0); - vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); - float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xy + dm * W_AMP * f * dir; -#if W_ERODE_ENABLED - CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xy + dm * W_ERODE_AMP * ph.cos.w * dir; -#endif - return normalize(vec3(-g.x, 1.0, -g.y)); -} - -// ---- world tree ---------------------------------------------------------------------------- - -float smin(float a, float b, float k) -{ - float h = max(k - abs(a - b), 0.0) / k; - return min(a, b) - h * h * k * 0.25; -} - -// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. -float treeDistance(vec3 p, out float leafness) -{ - vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); - float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); - leafness = 0.0; - if (bound > 8.0) - return bound; - - float ang = atan(p.z, p.x); - float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); - float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); - float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; - - vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; - float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); - float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; - canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) - - float d = smin(trunk, canopy, 6.0); - leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); - return d; -} - -// ---- trace --------------------------------------------------------------------------------- - -float sceneDistance(vec3 p, out float leafness, out bool onTree) -{ - float dt = terrainDistance(p); - float dr = treeDistance(p, leafness); - onTree = dr < dt; - return min(dt, dr); -} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl deleted file mode 100644 index 5946cc4a..00000000 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ /dev/null @@ -1,108 +0,0 @@ -// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage -// and, separately, the hit distance that bounds pass 2. -uniform vec3 u_grass; -uniform vec3 u_rock; -uniform vec3 u_bark; -uniform vec3 u_leaf; -uniform int u_maxSteps; -uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses - -in vec2 v_uv; -layout(location = 0) out vec4 o_color; // rgb lit, a coverage -layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss -#ifdef TRACE_STATS -layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only) -#endif - -// ---- trace --------------------------------------------------------------------------------- - -void main() -{ - vec3 ro = u_camPos; - vec3 rd = camRay(v_uv); - - float t = texture(u_start, v_uv).r; - // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the - // prepass proved empty arrives as W_FAR. - bool missAtOnce = t >= W_FAR; - if (ro.y > W_MAX_HEIGHT) - { - missAtOnce = missAtOnce || rd.y >= 0.0; - t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y); - } - if (missAtOnce) - { - o_color = vec4(0.0); - o_depth = W_FAR; -#ifdef TRACE_STATS - o_steps = 0.0; -#endif - return; - } - - bool hit = false; - bool onTree = false; - float leafness = 0.0; - int i = 0; - int evals = 0; - for (; i < u_maxSteps; i++) - { - evals++; - vec3 p = ro + rd * t; - float d = sceneDistance(p, leafness, onTree); - if (d < 0.0006 * t + 0.01) - { - hit = true; - break; - } - t += d; - if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0)) - break; - } - // The conservative bound makes grazing rays crawl: a ray that ran out of steps while still - // below the highest terrain is a distant surface under thick fog, so it ends there. - if (!hit && i == u_maxSteps && t < W_FAR) - { - hit = true; - sceneDistance(ro + rd * t, leafness, onTree); - } -#ifdef TRACE_STATS - o_steps = float(evals) / 255.0; -#endif - - if (!hit) - { - o_color = vec4(0.0); - o_depth = W_FAR; - return; - } - - vec3 p = ro + rd * t; - vec3 n; - vec3 albedo; - if (onTree) - { - float e = 0.35; - float l; - n = normalize(vec3( - treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l), - treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l), - treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l))); - float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8); - albedo = mix(u_bark, u_leaf * speckle, leafness); - } - else - { - n = terrainNormal(p.xz); - float rockness = smoothstep(0.78, 0.55, n.y); - albedo = mix(u_grass, u_rock, rockness); - } - - float ndl = dot(n, u_sunDir); - float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass - vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45; - vec3 col = albedo * (u_sunColor * wrap * wrap + ambient); - - o_color = vec4(col, 1.0); - o_depth = t; -} diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts deleted file mode 100644 index e38f676a..00000000 --- a/src/app/features/scene/skyBand.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { displayedLuminance, hexToLinear, luma, shapeToBand, SKY_BAND } from './skyBand'; -import type { Linear } from './skyBand'; -import { GRID_FRAG, SHAPE_SKY_GLSL, SKY_FRAG } from './terrain/terrainShaders'; - -const near = (a: number, b: number) => Math.abs(a - b) < 1e-9; - -describe('shapeToBand', () => { - const dark: Linear = [0.02, 0.05, 0.2]; // a deep blue - const { day } = SKY_BAND; - - it('lifts a sky below the floor onto the floor, mixing toward the tint so the hue is kept', () => { - const c = shapeToBand(dark, day); - expect(near(luma(c), day.floor)).toBe(true); - // Between the sky and the tint on every channel: a mix, not a grey clamp. - [0, 1, 2].forEach((i) => { - expect(c[i]).toBeGreaterThanOrEqual(Math.min(dark[i], day.lift[i])); - expect(c[i]).toBeLessThanOrEqual(Math.max(dark[i], day.lift[i])); - }); - expect(c[2]).toBeGreaterThan(c[0]); // still blue - }); - - it('lifts black to the tint scaled in, not to grey', () => { - const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); - expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); - expect(c[2]).toBeGreaterThan(c[0]); // the dusk tint is Day's pale blue - }); - - it('leaves a sky already in the band as it is, and is continuous at the floor', () => { - const ok: Linear = [0.5, 0.6, 0.7]; - expect(shapeToBand(ok, day)).toEqual(ok); - const lo = shapeToBand([0.31, 0.31, 0.31], day); - const below = shapeToBand([0.3099, 0.3099, 0.3099], day); - expect(Math.abs(lo[0] - below[0])).toBeLessThan(1e-3); - }); - - it('scales a sky above the ceiling down to it, keeping its hue', () => { - const c = shapeToBand([0.9, 0.8, 1], SKY_BAND.night); - expect(near(luma(c), SKY_BAND.night.ceiling)).toBe(true); - expect(near(c[0] / c[2], 0.9)).toBe(true); - }); - - it('clamps to 0..1 as the screen does, and never leaves the band when shaped twice', () => { - expect(shapeToBand([4, 4, 4], day)).toEqual([1, 1, 1]); - const once = shapeToBand(dark, day); - shapeToBand(once, day).forEach((v, i) => expect(v).toBeCloseTo(once[i], 12)); - }); -}); - -describe('the bands', () => { - it('have a tint above the floor to lift toward', () => { - Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor)); - }); - - it('keep the floor above the 0.2983 text bound by what gamma 2.2, 8-bit rounding and dither can take', () => { - const grey = shapeToBand([0, 0, 0], SKY_BAND.day); - const shown = displayedLuminance(grey, 'low'); - expect(shown).toBeGreaterThanOrEqual(0.2983); - expect(shown).toBeLessThan(SKY_BAND.day.floor); - }); - - it('decode hex as linear light', () => { - expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); - }); -}); - -describe('the GLSL twin', () => { - it('shapes with the same arithmetic: clamp, luma weights, mix to the floor, scale to the ceiling', () => { - expect(SHAPE_SKY_GLSL).toContain('c = clamp(c, 0.0, 1.0);'); - expect(SHAPE_SKY_GLSL).toContain('const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);'); - expect(SHAPE_SKY_GLSL).toContain( - 'if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));' - ); - expect(SHAPE_SKY_GLSL).toContain('if (l > uBand.y) return c * (uBand.y / l);'); - }); - - it('is applied to the sky colour in both shaders that draw it, the sky pass and the haze that fades into it', () => { - [SKY_FRAG, GRID_FRAG].forEach((src) => { - expect(src).toContain(SHAPE_SKY_GLSL); - expect(src).toMatch(/return shapeSky\(1\.0 - exp\(-45\.0 \* skyScatter\(d\)\)/); - }); - }); -}); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts deleted file mode 100644 index a74784b3..00000000 --- a/src/app/features/scene/skyBand.ts +++ /dev/null @@ -1,90 +0,0 @@ -// The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink -// clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). -// -// day Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. -// The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. -// dusk The same ink, and its link #2a4f27 (L 0.0623), which needs a backdrop of at least 0.4554. The floor is -// the link bound (0.4744 as drawn), so text clears 4.5:1 with room (6.6) and the link clears too. -// night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. -// The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. -// -// Night's link ink is not part of the band: see skyTheme.ts. -// -// The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: -// `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the -// same constants. The ridge sky is a three-stop gradient whose stops are chosen inside the band -// (ridge/skyColour.ts, skyForState.ts), and its test sweeps the blends. -export type FixedSkyState = 'day' | 'dusk' | 'night'; -export type Linear = readonly [number, number, number]; - -/** WCAG luma weights, applied to linear RGB. */ -export const LUMA: Linear = [0.2126, 0.7152, 0.0722]; -export const luma = (c: Linear): number => LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2]; - -/** sRGB hex `#rrggbb` to linear RGB 0..1. */ -export function hexToLinear(hex: string): Linear { - const ch = (at: number) => { - const s = parseInt(hex.slice(at, at + 2), 16) / 255; - return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return [ch(1), ch(3), ch(5)]; -} - -/** How a displayed colour is read: as encoded, or moved one 8-bit step down or up in every channel ('low', 'high'). */ -export type Reading = 'exact' | 'low' | 'high'; - -/** - * Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with - * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. 'low' and 'high' move every - * encoded channel one 8-bit step down or up first: rounding to 8 bits (half a step) plus the sky pass's dither (half a - * step) cannot move a channel further, so they bound what the screen shows. A smooth bound, so a search can climb it. - */ -export function displayedLuminance(c: Linear, reading: Reading = 'exact'): number { - const shift = { exact: 0, low: -1 / 255, high: 1 / 255 }[reading]; - const ch = (v: number) => { - const e = Math.min(1, Math.max(0, Math.min(1, Math.max(0, v)) ** (1 / 2.2) + shift)); - return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; - }; - return luma([ch(c[0]), ch(c[1]), ch(c[2])]); -} - -export interface SkyBand { - /** Linear luma the shaped colour is at least (0: no floor). */ - floor: number; - /** Linear luma the shaped colour is at most (1: no ceiling). */ - ceiling: number; - /** The colour a too-dark sky is mixed toward: the state's own tint, so the hue survives the lift. Its luma is above `floor`. */ - lift: Linear; -} - -/** - * The shaping targets, in linear luma. Day's floor sits 0.019 above the text bound (0.2983), Dusk's 0.019 above the link - * bound (0.4554), which skyForState.test.ts derives from the ink and ties to these: the screen encoding is - * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky - * pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound - * (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows - * (skyState.ts), so a sky the page URL steers at night, scaled down to the ceiling, cannot show lighter than the baked one. - */ -export const SKY_BAND: Record = { - day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - // Dusk lifts toward Day's pale blue rather than a rose: mixing a third of the sky toward rose washed the twilight pink, and - // a rose lighter than the floor shifts hues by 47 to 72 degrees where Day's tint shifts them by 18 (measured, PR body). - dusk: { floor: 0.4744, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, -}; - -/** - * Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount - * that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the - * ceiling. The colour is clamped to 0..1 first, as the screen does. - */ -export function shapeToBand(c: Linear, band: SkyBand): Linear { - const k = [0, 1, 2].map((i) => Math.min(1, Math.max(0, c[i]))) as unknown as Linear; - const l = luma(k); - if (l < band.floor) { - const t = (band.floor - l) / (luma(band.lift) - l); - return [0, 1, 2].map((i) => k[i] + (band.lift[i] - k[i]) * t) as unknown as Linear; - } - if (l > band.ceiling) return [0, 1, 2].map((i) => (k[i] * band.ceiling) / l) as unknown as Linear; - return k; -} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts deleted file mode 100644 index a8199b1b..00000000 --- a/src/app/features/scene/skyForState.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { atStandardTime } from './standardTime.testkit'; -import { baked } from '../../../sky/baked'; -import { AA, luminance } from '../../../sky/skyTokens.js'; -import { skyColours } from './ridge/skyColour'; -import { chooseSky, skyColour } from './terrain/terrainSky'; -import { - DUSK_SUN_ELEVATION, - TERRAIN_REACH_DEG, - ridgeDrawn, - ridgeSkyFor, - terrainDrawn, - terrainSkyFor, -} from './skyForState'; -import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand'; -import type { FixedSkyState } from './skyBand'; -import { setSkyClock, skyBackdrop } from './skyState'; -import { view } from './terrain/terrainWorld'; -import { skyThemeId } from './skyTheme'; - -const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; -const EPS = 1e-9; -const STATES = ['day', 'dusk', 'night'] as const; -const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; - -/** - * The band each state needs, from the ink itself: dark ink needs a backdrop of at least - * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, night 0.0931; dusk is bound by its - * link (0.4554), the darker ink to clear, whose floor also holds the text. - */ -const needs = (state: FixedSkyState) => { - const sky = SKY[skyThemeId(state)]; - const l = luminance(sky.tokens.Background.OnContainer); - const link = luminance(sky.link); - return state === 'night' - ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } - : { floor: AA * ((state === 'dusk' ? link : l) + 0.05) - 0.05, ceiling: 1 }; -}; - -let search = ''; -beforeEach(() => { - search = ''; - vi.stubGlobal('window', { - get location() { - return { search }; - }, - }); -}); -afterEach(() => { - setSkyClock(); - vi.unstubAllGlobals(); -}); - -describe('the bands the text ink needs', () => { - it('are the numbers the shaping was set from', () => { - expect(needs('day').floor).toBeCloseTo(0.2983, 3); - expect(needs('dusk').floor).toBeCloseTo(0.4554, 3); - expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); - }); - - // The drawn floor and the lift are tied to those bounds, not to themselves: nudge either constant and this fails. - it('give Dusk a floor 0.019 above the link bound (as Day has above the text bound) and a lift Day shares', () => { - expect(SKY_BAND.dusk.floor).toBeCloseTo(needs('dusk').floor + 0.019, 4); - expect(SKY_BAND.day.floor).toBeCloseTo(needs('day').floor + 0.019, 3); - expect(SKY_BAND.dusk.lift).toEqual(SKY_BAND.day.lift); - expect(luma(SKY_BAND.dusk.lift)).toBeGreaterThan(SKY_BAND.dusk.floor); - }); - - it.each(['day', 'dusk'] as const)( - '%s: the darkest the shaping can show reads at the bound and under the floor', - (state) => { - const shown = displayedLuminance(shapeToBand([0, 0, 0], SKY_BAND[state]), 'low'); - expect(shown).toBeGreaterThanOrEqual(needs(state).floor); - expect(shown).toBeLessThan(SKY_BAND[state].floor); - } - ); -}); - -describe('ridgeSkyFor', () => { - it('draws the gradient of the sky auto resolves to, never nothing', () => { - setSkyClock(() => atStandardTime(12, 0)); - expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); - setSkyClock(() => atStandardTime(0, 0)); - expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); - setSkyClock(() => atStandardTime(18, 10)); - expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); - }); - - it('draws a lavender, rose and orange twilight for dusk', () => { - const d = ridgeSkyFor('dusk'); - expect(d.ground).toEqual(skyColours().ground); - const [t, m, h] = [d.top, d.mid, d.horizon]; - expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green - expect(t[0]).toBeGreaterThan(t[1]); - expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue - expect(m[2]).toBeGreaterThan(m[1]); - expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue - expect(h[1]).toBeGreaterThan(h[2]); - }); - - it('draws the baked gradients for day and night', () => { - const day = ridgeSkyFor('day'); - const night = ridgeSkyFor('night'); - expect(hex(day.top)).toBe(baked.noonday.gradient[0]); - expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]); - expect(hex(night.top)).toBe(baked.midnight.gradient[0]); - expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]); - }); - - it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => { - const { floor, ceiling } = needs(state); - const r = ridgeDrawn(state); - expect(r.darkest).toBeGreaterThanOrEqual(floor); - expect(r.lightest).toBeLessThanOrEqual(ceiling); - }); -}); - -// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) -// plus a 0.05 degree grid across the sun, five times finer than the generator's. -const dirAt = (el: number, az: number): [number, number, number] => { - const rad = Math.PI / 180; - return [ - Math.cos(el * rad) * Math.sin(az * rad), - Math.sin(el * rad), - -Math.cos(el * rad) * Math.cos(az * rad), - ]; -}; -/** Over the sweep: the darkest the 8-bit screen can show (dither at its worst), the lightest, and the darkest as encoded. */ -const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { - const rad = Math.PI / 180; - const out = { low: Infinity, high: -Infinity, exactMin: Infinity }; - const see = (el: number, az: number) => { - const c = skyColour(sky, dirAt(el, az)); - out.low = Math.min(out.low, displayedLuminance(c, 'low')); - out.high = Math.max(out.high, displayedLuminance(c, 'high')); - out.exactMin = Math.min(out.exactMin, displayedLuminance(c)); - }; - for (let i = 0; i < N; i += 1) { - const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl - see(Math.asin(y) / rad, (i * 137.50776405) % 360); - } - for (let dEl = -3; dEl <= 3; dEl += 0.05) { - for (let dAz = -3; dAz <= 3; dAz += 0.05) { - see(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz); - } - } - return out; -}; - -describe('the terrain camera reach', () => { - it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => { - const tanHalf = Math.tan((55 * Math.PI) / 360); - const tops = [0.4, 0.5, 1, 16 / 9, 2.4].flatMap((aspect) => - [0, 0.125, 0.25, 0.5, 0.75].flatMap((turns) => { - const v = view(aspect, turns); - return [-1, 0, 1].map((nx) => { - const d = [0, 1, 2].map( - (i) => v.forward[i] + nx * tanHalf * aspect * v.right[i] + tanHalf * v.up[i] - ); - return (Math.asin(d[1] / Math.hypot(d[0], d[1], d[2])) * 180) / Math.PI; - }); - }) - ); - const top = Math.max(...tops); - expect(top).toBeCloseTo(14.5, 1); - expect(TERRAIN_REACH_DEG).toBeGreaterThanOrEqual(top); - expect(TERRAIN_REACH_DEG - top).toBeLessThan(2); - }); -}); - -describe('terrainSkyFor', () => { - it('draws the sky auto resolves to, with that state band', () => { - setSkyClock(() => atStandardTime(12, 0)); - expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day')); - setSkyClock(() => atStandardTime(0, 0)); - expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); - }); - - it('lets the page URL steer auto, shaped into the band of the state the clock resolves to', () => { - setSkyClock(() => atStandardTime(0, 0)); // night - search = '?sun=30&az=120'; - expect(terrainSkyFor('auto')).toEqual({ - sun: { elevation: 30, azimuth: 120 }, - band: SKY_BAND.night, - }); - search = '?sky=noonday'; - expect(terrainSkyFor('auto')).toEqual({ sky: 'noonday', band: SKY_BAND.night }); - search = '?sky=garbage'; - expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); - setSkyClock(() => atStandardTime(18, 10)); // dusk, URL azimuth honoured - search = '?sun=2&az=250'; - expect(terrainSkyFor('auto').sun).toEqual({ elevation: 2, azimuth: 250 }); - }); - - it('keeps a URL-chosen sun inside the band of the resolved state', () => { - setSkyClock(() => atStandardTime(12, 0)); // day: floor - search = '?sun=-30'; - const day = sweepOf(chooseSky(terrainSkyFor('auto'))); - expect(day.low).toBeGreaterThanOrEqual(needs('day').floor); - setSkyClock(() => atStandardTime(0, 0)); // night: ceiling - search = '?sun=60'; - const night = sweepOf(chooseSky(terrainSkyFor('auto'))); - expect(night.high).toBeLessThanOrEqual(needs('night').ceiling); - }); - - it('keeps a URL-steered sky at night no lighter than the Night backdrop the text was checked against', () => { - setSkyClock(() => atStandardTime(0, 0)); - ['?sun=60', '?sun=10&az=40', '?sun=2&az=250', '?sky=noonday'].forEach((q) => { - search = q; - expect(terrainDrawn('auto').lightest).toBeLessThanOrEqual(skyBackdrop('night').lightest); - }); - }); - - it('lets an explicit state beat the URL, as before auto had one', () => { - search = '?sun=30&az=120&sky=midnight'; - expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); - expect(terrainSkyFor('dusk').sun).toEqual({ elevation: DUSK_SUN_ELEVATION }); - }); - - it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => { - expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); - expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night }); - expect(terrainSkyFor('dusk')).toEqual({ - sun: { elevation: DUSK_SUN_ELEVATION }, - band: SKY_BAND.dusk, - }); - }); -}); - -describe('the terrain sky lies in the band its text ink needs', () => { - const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); - - it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => { - const { sun } = terrainSkyFor('dusk'); - expect(sweepOf(chooseSky({ sun })).exactMin).toBeLessThan(needs('dusk').floor - 0.02); - expect(sweep('dusk').low).toBeGreaterThanOrEqual(needs('dusk').floor); - }); - - it.each(STATES)('%s', (state) => { - const { floor, ceiling } = needs(state); - const l = sweep(state); - expect(l.low).toBeGreaterThanOrEqual(floor); - expect(l.high).toBeLessThanOrEqual(ceiling); - }); - - it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { - const { floor, ceiling } = needs(state); - for (let e = -90; e <= 90; e += 10) { - const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90); - expect(l.low).toBeGreaterThanOrEqual(floor); - expect(l.high).toBeLessThanOrEqual(ceiling); - } - }); - - it('and the terrain dusk still reaches the sun glow the ridge dusk does not', () => { - expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9); - }); -}); - -describe('skyBackdrop holds the union of what the scenes draw', () => { - // An independent extremum of the screen reading ('low' for the darkest, 'high' for the lightest: one 8-bit step each way) over the terrain camera's reach: not terrainDrawn's grid and compass search but alternating golden-section - // line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun), the bracket - // shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search always - // moves to the better end of its bracket. - const extremes = (state: FixedSkyState) => { - const sky = chooseSky(terrainSkyFor(state)); - const rad = Math.PI / 180; - const f = (el: number, az: number, reading: 'low' | 'high') => { - const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; - const a = az * rad; - return displayedLuminance( - skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]), - reading - ); - }; - const P = 0.6180339887; - const line = (g: (x: number) => number, mid: number, w: number) => { - let a = mid - w; - let b = mid + w; - for (let k = 0; k < 40; k += 1) { - const c = b - P * (b - a); - const d = a + P * (b - a); - if (g(c) > g(d)) b = d; - else a = c; - } - return (a + b) / 2; - }; - const hunt = (sign: 1 | -1) => { - const reading = sign === 1 ? 'high' : 'low'; - let best = -Infinity; - for (let i = 0; i < 60; i += 1) { - let el = - i % 2 - ? sky.elevation + (((i * 7919) % 1600) - 800) / 100 - : Math.asin(((i + 0.5) / 60) * Math.sin(TERRAIN_REACH_DEG * rad)) / rad; - let az = - i % 2 ? sky.azimuth + (((i * 104729) % 1600) - 800) / 100 : (i * 137.50776405) % 360; - for (let w = 8; w > 1e-3; w /= 2) { - const [el0, az0] = [el, az]; - el = line((x) => sign * f(Math.max(0, x), az0, reading), el0, w); - const el1 = el; - az = line((x) => sign * f(el1, x, reading), az0, w); - best = Math.max(best, sign * f(el, az, reading)); - } - } - return sign * best; - }; - return { darkest: hunt(-1), lightest: hunt(1) }; - }; - - // At night a sky the page URL steers (?sun= ?sky=) is shaped to the band's ceiling, so the lightest it can show is one - // 8-bit step above the ceiling's own grey: the Night constant must cover it too, or text is checked against a sky - // lighter than the one it sits on. - const steeredNight = displayedLuminance( - [SKY_BAND.night.ceiling, SKY_BAND.night.ceiling, SKY_BAND.night.ceiling], - 'high' - ); - - const computed = (state: FixedSkyState) => { - const r = [ridgeDrawn(state), terrainDrawn(state)]; - const ind = extremes(state); - return { - darkest: Math.min(...r.map((x) => x.darkest), ind.darkest), - lightest: Math.max( - ...r.map((x) => x.lightest), - ind.lightest, - ...(state === 'night' ? [steeredNight] : []) - ), - }; - }; - - // The constants are the swept extremes rounded outward to six places, so each lies in [extreme, extreme + 1e-6): - // a constant 1e-6 low is under the extreme, one 2e-5 high is over the bound. SEARCH is the searches' own error. - const SEARCH = 1e-8; - it.each(STATES)( - '%s: the constant is the swept extreme rounded outward, within 1e-6 above it', - (state) => { - const c = computed(state); - const b = skyBackdrop(state); - expect(b.darkest).toBeLessThanOrEqual(c.darkest); - expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); - expect(c.darkest - b.darkest).toBeLessThan(1e-6 + SEARCH); - expect(b.lightest - c.lightest).toBeLessThan(1e-6 + SEARCH); - } - ); - - it.each(STATES)('%s: an independent sweep, no looser than 5e-4 from the constant', (state) => { - // The sweep is not the generator's grid (see above), so it cannot share its blind spots. - const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000); - const b = skyBackdrop(state); - const r = ridgeDrawn(state); - expect(l.low).toBeGreaterThanOrEqual(b.darkest - EPS); - expect(l.high).toBeLessThanOrEqual(b.lightest + EPS); - expect(b.lightest - Math.max(l.high, r.lightest)).toBeLessThan(5e-4); - expect(Math.min(l.low, r.darkest) - b.darkest).toBeLessThan(5e-4); - }); -}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts deleted file mode 100644 index 71e07ca6..00000000 --- a/src/app/features/scene/skyForState.ts +++ /dev/null @@ -1,193 +0,0 @@ -// What each scene is told for a sky state. 'auto' is a choice, not a sky: both functions draw the sky it -// resolves to (`resolveSkyState`, the clock), so every scene paints one of day, dusk or night. Each is drawn -// inside that state's luminance band (skyBand.ts): the ridge sky from gradients whose stops and blends lie in -// it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out, -// and the login text was checked against that bound. -import { baked } from '../../../sky/baked'; -import { chooseSky, skyColour, urlSky } from './terrain/terrainSky'; -import type { SkyName } from './terrain/terrainSky'; -import type { SkyColours, Srgb } from './ridge/skyColour'; -import { skyColours } from './ridge/skyColour'; -import { resolveSkyState } from './skyState'; -import type { SkyState } from './skyState'; -import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; -import type { SkyBand } from './skyBand'; -import { luminance } from '../../../sky/skyTokens.js'; -import { CAMERA } from './terrain/terrainWorld'; - -const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; - -const rgb = (hex: string): Srgb => - [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; - -const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ - top: rgb(g[0]), - mid: rgb(g[Math.floor(g.length / 2)]), - horizon: rgb(g[g.length - 1]), - ground, -}); - -/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ -export const DUSK_SUN_ELEVATION = 2; - -const toByte = (v: number): number => - Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)); - -/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */ -const inBand = (c: Srgb, band: SkyBand): Srgb => - shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb; - -/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */ -export function ridgeSkyFor(state: SkyState): SkyColours { - const { ground } = skyColours(); - switch (resolveSkyState(state)) { - case 'dusk': { - const twilight = skyColours(); - const band = SKY_BAND.dusk; - return { - ...twilight, - top: inBand(twilight.top, band), - mid: inBand(twilight.mid, band), - horizon: inBand(twilight.horizon, band), - }; - } - case 'day': - return fromGradient(baked.noonday.gradient, ground); - default: - return fromGradient(baked.midnight.gradient, ground); - } -} - -/** - * The terrain scene's `sky`, `sun` and `band` options. An explicit state draws its own sky and ignores the URL. 'auto' - * draws the sky it resolves to, unless the page URL steers (`?sun=` `?az=` `?sky=`, see `urlSky`): then the URL's sky, - * shaped into the band of the state the clock resolves to, which keeps any sun the URL picks inside that band. - */ -export function terrainSkyFor(state: SkyState): { - sky?: SkyName; - sun?: { elevation: number; azimuth?: number }; - band: SkyBand; -} { - const fixed = resolveSkyState(state); - const band = SKY_BAND[fixed]; - if (state === 'auto') { - const url = urlSky(); - if (url) return { ...url, band }; - } - switch (fixed) { - case 'day': - return { sky: 'noonday', band }; - case 'night': - return { sky: 'midnight', band }; - default: - return { sun: { elevation: DUSK_SUN_ELEVATION }, band }; - } -} - -/** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ -export interface Range { - readonly darkest: number; - readonly lightest: number; -} - -/** - * The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates - * encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled). - */ -export function ridgeDrawn(state: SkyState): Range { - const { top, mid, horizon } = ridgeSkyFor(state); - const l: number[] = []; - [ - [top, mid], - [mid, horizon], - [top, horizon], - ].forEach(([a, b]) => { - for (let k = 0; k <= 100; k += 1) { - l.push( - luminance( - rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb) - ) - ); - } - }); - return { darkest: Math.min(...l), lightest: Math.max(...l) }; -} - -/** - * How high the terrain camera can see, in degrees of elevation: it is frozen at a downward pitch and only turns - * (no pull-back), so the top edge of its view, at the centre column, is the pitch down subtracted from half the - * field of view (14.5 degrees; off-centre columns look lower). Plus a 1.5 degree margin. The terrain's range is - * over 0 to this, all azimuths, not the whole upper sphere; below the horizon the sky is the horizon's colour. - */ -export const TERRAIN_REACH_DEG = Math.ceil((CAMERA.fov / 2 - CAMERA.pitch) * (180 / Math.PI) + 1.5); - -const SAMPLE_DEG = 3; -const SUN_DEG = 0.1; -const SUN_SPAN_DEG = 8; -const HORIZON_DEG = 0.5; -const REFINE_FROM = 6; - -/** - * The terrain sky's range as the 8-bit screen shows it (`displayedLuminance`, read 'low' for the darkest and 'high' for the - * lightest: one 8-bit step each way, rounding and dither at their worst): `skyColour` (the CPU twin of its sky shader) over the part of the sky the camera reaches - * (0 to TERRAIN_REACH_DEG, every azimuth: it turns), for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree - * ring of the horizon, the top of the reach and a 0.25 degree patch around the sun find where the extremes are; each of the - * best few is then refined by a compass search whose step halves from 1 degree to under 5e-7, because the sun's glow peaks - * (and the sky's darkest patch sits) between any fixed grid's samples. - */ -export function terrainDrawn(state: SkyState): Range { - const sky = chooseSky(terrainSkyFor(state)); - const rad = Math.PI / 180; - type Point = { el: number; az: number; read: number; score: number }; - const extreme = (reading: 'low' | 'high', sign: 1 | -1): number => { - const point = (el: number, az: number): Point => { - const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; - const a = az * rad; - const c = skyColour(sky, [ - Math.cos(e) * Math.sin(a), - Math.sin(e), - -Math.cos(e) * Math.cos(a), - ]); - const read = displayedLuminance(c, reading); - return { el, az, read, score: read }; - }; - const seen: Point[] = []; - const at = (el: number, az: number) => seen.push(point(el, az)); - for (let el = 0; el <= TERRAIN_REACH_DEG; el += SAMPLE_DEG) { - for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az); - } - for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az); - at(TERRAIN_REACH_DEG, 0); - for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) { - for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) { - at(sky.elevation + dEl, sky.azimuth + dAz); - } - } - const around = (p: Point, step: number): Point[] => - [ - [step, 0], - [-step, 0], - [0, step], - [0, -step], - ].map(([dEl, dAz]) => - point(Math.min(TERRAIN_REACH_DEG, Math.max(0, p.el + dEl)), p.az + dAz) - ); - const refine = (from: Point) => { - let best = from; - for (let step = 1; step > 5e-7; step /= 2) { - let improved = true; - while (improved) { - const cur = best; - best = around(cur, step).reduce((b, c) => (sign * c.score > sign * b.score ? c : b), cur); - improved = best !== cur; - } - } - return best.read; - }; - const ranked = [...seen].sort((p, q) => sign * (q.score - p.score)); - return sign === 1 - ? Math.max(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine)) - : Math.min(ranked[0].read, ...ranked.slice(0, REFINE_FROM).map(refine)); - }; - return { darkest: extreme('low', -1), lightest: extreme('high', 1) }; -} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts deleted file mode 100644 index ebd117fd..00000000 --- a/src/app/features/scene/skyPlate.css.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { globalStyle, style } from '@vanilla-extract/css'; -import { color, config } from 'folds'; -import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; -import { Editor } from '../../components/editor/Editor.css'; -import { CallStatus } from '../call-status/styles.css'; -import { PLATE_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; - -// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text -// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both -// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate -// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was -// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag. -const scene = ':root[data-scene]'; -const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; -const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; - -/** A region with no class of its own (the room intro, the call view) joins the plate with this. */ -export const SkyPlate = style({}); - -// A recipe's first class is its base style. -const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; - -// Meta, 2026-11-09: no chrome. Title bars, the room list, list pages, the drawer and the bars over the composer are bare -// ink on the sky (dusk is allowed to be low contrast: it is the crossover between the light and dark inks). -export const plateRegions = [ - `.${SkyPlate}`, - // The composer and the call status bar. - `.${Editor}`, - `.${CallStatus}`, - // A message that is not a bubble (Modern, Compact) is plain text on the scene. - `${base(MessageBase)}:not(:has(.${BubbleContent}))`, -]; - -// Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. -const within = (ancestor: string) => - plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); - -globalStyle(within(scene), { - // Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT). - backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`], - backdropFilter: 'blur(12px)', - WebkitBackdropFilter: 'blur(12px)', - color: text, - // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. - vars: { - [color.Background.OnContainer]: text, - [color.Surface.OnContainer]: text, - [color.SurfaceVariant.OnContainer]: text, - }, -}); -globalStyle(`${scene} #root header`, { borderRadius: config.radii.R400 }); - -// Links read against the plate. The Midnight and Noonday themes set their own --tc-link for it; -// the other themes take the fallback, as the sign-in card does. -globalStyle(within(`${scene} body:not(.gamecult-theme)`), { - vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, -}); - -globalStyle(`${scene} #root .${SkyPlate}`, { - borderRadius: config.radii.R400, -}); diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts deleted file mode 100644 index b82a3026..00000000 --- a/src/app/features/scene/skyPlate.ts +++ /dev/null @@ -1,28 +0,0 @@ -// The plate: the opaque surface text sits on wherever the shell is clear over the background -// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour -// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for -// the card holds here. The other themes take these fallbacks. -export const SKY_PLATE_FALLBACK = { - fill: '#0d1a26', - text: '#b7c7d9', - link: '#99c7ff', -} as const; - -/** - * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the - * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The - * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1. The browser - * mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55. The - * terrain is not black: in the shots at 390x844 (terrain scene, tier low) the darkest pixel in the footer's region is - * 0.0203 at Dusk, 0.0936 at Day (dark ink) and 0.0052 at Night (light ink). Measured on the drawn pixels with the band - * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. - */ -export const FOOTER_TINT = 0.82; - -/** - * How much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind a plate (title bars, lists, the - * composer, the sign-in card). Meta, 2026-11-09: "The app shouldn't need chrome, and where it does help for readability it - * should be like the chat bubbles, blur with a bit of tint towards the background color. If that's not working, the fix - * is in the background, not covering it up." - */ -export const PLATE_TINT = 0.45; diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts deleted file mode 100644 index ae4d9e83..00000000 --- a/src/app/features/scene/skyState.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { atStandardTime } from './standardTime.testkit'; -import { baked } from '../../../sky/baked'; -import { luminance } from '../../../sky/skyTokens.js'; -import { - SKY_STATES, - SKY_STATE_KEY, - resolveSkyState, - setSkyClock, - skyBackdrop, - type SkyState, -} from './skyState'; - -type Mod = typeof import('./skyState'); - -function fakeStorage(initial: Record = {}) { - const data = new Map(Object.entries(initial)); - return { - data, - getItem: (k: string) => data.get(k) ?? null, - setItem: (k: string, v: string) => { - data.set(k, v); - }, - }; -} -const fresh = (): Promise => { - vi.resetModules(); - return import('./skyState'); -}; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('sky state', () => { - let storage: ReturnType; - beforeEach(() => { - storage = fakeStorage(); - vi.stubGlobal('localStorage', storage); - }); - - it('defaults to auto when nothing is stored', async () => { - expect((await fresh()).getSkyState()).toBe('auto'); - }); - - it.each(['night', 'day', 'dusk', 'auto'] as const)('restores a stored %s', async (s) => { - storage.data.set(SKY_STATE_KEY, s); - expect((await fresh()).getSkyState()).toBe(s); - }); - - it.each(['', 'noon', 'NIGHT', '"night"', 'null'])( - 'falls back to auto on stored %j', - async (v) => { - storage.data.set(SKY_STATE_KEY, v); - expect((await fresh()).getSkyState()).toBe('auto'); - } - ); - - it('falls back to auto when storage throws', async () => { - vi.stubGlobal('localStorage', { - getItem: () => { - throw new Error('denied'); - }, - setItem: () => { - throw new Error('denied'); - }, - }); - const m = await fresh(); - expect(m.getSkyState()).toBe('auto'); - m.setSkyState('night'); - expect(m.getSkyState()).toBe('night'); - }); - - it('persists a set state under one key and reads it back after a reload', async () => { - (await fresh()).setSkyState('dusk'); - expect([...storage.data.entries()]).toEqual([[SKY_STATE_KEY, 'dusk']]); - expect((await fresh()).getSkyState()).toBe('dusk'); - }); - - it('ignores an invalid state', async () => { - const m = await fresh(); - m.setSkyState('night'); - m.setSkyState('noon' as SkyState); - expect(m.getSkyState()).toBe('night'); - expect(storage.data.get(SKY_STATE_KEY)).toBe('night'); - }); - - it('notifies subscribers on change only, and stops after unsubscribe', async () => { - const m = await fresh(); - const cb = vi.fn(); - const off = m.subscribe(cb); - m.setSkyState('day'); - m.setSkyState('day'); - expect(cb.mock.calls).toEqual([['day']]); - off(); - m.setSkyState('night'); - expect(cb).toHaveBeenCalledTimes(1); - }); - - it('tells a late subscriber nothing until the next change', async () => { - const m = await fresh(); - m.setSkyState('day'); - const cb = vi.fn(); - m.subscribe(cb); - expect(cb).not.toHaveBeenCalled(); - }); -}); - -describe('skyBackdrop', () => { - it('puts night below day and dusk, and dusk reaches the sun glow', () => { - expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); - expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest); - }); - - it('holds the baked gradients for day and night', () => { - const lum = (g: readonly string[]) => g.map(luminance); - expect(skyBackdrop('day').lightest).toBeGreaterThanOrEqual( - Math.max(...lum(baked.noonday.gradient)) - 1e-9 - ); - expect(skyBackdrop('night').darkest).toBeLessThanOrEqual( - Math.min(...lum(baked.midnight.gradient)) + 1e-9 - ); - }); - - it('is the band of the sky auto resolves to, not the union of all three', () => { - const at = (h: number) => setSkyClock(() => atStandardTime(h, 0)); - at(12); - expect(skyBackdrop('auto')).toEqual(skyBackdrop('day')); - at(0); - expect(skyBackdrop('auto')).toEqual(skyBackdrop('night')); - setSkyClock(); - }); - - it('never has darkest above lightest', () => { - SKY_STATES.forEach((s) => - expect(skyBackdrop(s).darkest).toBeLessThanOrEqual(skyBackdrop(s).lightest) - ); - }); -}); - -describe('auto follows the clock', () => { - const local = (h: number, m = 0) => atStandardTime(h, m); // local solar time ~ local clock - let storage: ReturnType; - beforeEach(() => { - vi.useFakeTimers(); - storage = fakeStorage(); - vi.stubGlobal('localStorage', storage); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('resolves noon to day, midnight to night and the twilight after sunset to dusk', () => { - expect(resolveSkyState('auto', local(12))).toBe('day'); - expect(resolveSkyState('auto', local(0))).toBe('night'); - expect(resolveSkyState('auto', local(18, 10))).toBe('dusk'); - expect(resolveSkyState('auto', local(5, 50))).toBe('dusk'); - expect(resolveSkyState('day', local(0))).toBe('day'); - }); - - it('tells listeners the resolved sky when the clock crosses a boundary, and only then', async () => { - const m = await fresh(); - let now = local(17, 0); - m.setSkyClock(() => now); - const cb = vi.fn(); - const off = m.subscribe(cb); - vi.advanceTimersByTime(5 * 60_000); - expect(cb).not.toHaveBeenCalled(); - now = local(18, 10); - vi.advanceTimersByTime(60_000); - expect(cb.mock.calls).toEqual([['dusk']]); - vi.advanceTimersByTime(5 * 60_000); - expect(cb).toHaveBeenCalledTimes(1); - off(); - }); - - it('still tells everyone when a listener comes or goes inside the window before the clock crosses', async () => { - const m = await fresh(); - let now = local(17, 0); - m.setSkyClock(() => now); - const [a, b, late] = [vi.fn(), vi.fn(), vi.fn()]; - m.subscribe(a); - const offB = m.subscribe(b); - vi.advanceTimersByTime(30_000); - now = local(18, 10); // the clock has crossed; no tick has seen it yet - const offLate = m.subscribe(late); - offB(); - vi.advanceTimersByTime(60_000); - expect(a.mock.calls).toEqual([['dusk']]); - expect(b).not.toHaveBeenCalled(); - expect(late.mock.calls).toEqual([['dusk']]); - offLate(); - }); - - it('keeps no timer without listeners or when a fixed state is chosen', async () => { - const m = await fresh(); - const off = m.subscribe(() => undefined); - expect(vi.getTimerCount()).toBe(1); - m.setSkyState('night'); - expect(vi.getTimerCount()).toBe(0); - m.setSkyState('auto'); - expect(vi.getTimerCount()).toBe(1); - off(); - expect(vi.getTimerCount()).toBe(0); - }); - - it('notifies a change of choice with the resolved sky', async () => { - const m = await fresh(); - m.setSkyClock(() => local(12)); - const cb = vi.fn(); - m.subscribe(cb); - m.setSkyState('night'); - m.setSkyState('auto'); - expect(cb.mock.calls).toEqual([['night'], ['day']]); - }); -}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts deleted file mode 100644 index 0b60d99b..00000000 --- a/src/app/features/scene/skyState.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Which sky the signed-out page is drawn against. The choice is one value, kept in one -// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and -// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the -// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the -// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three. -import { sunElevation } from '../../../sky/sun.js'; -import type { FixedSkyState } from './skyBand'; - -export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; - -export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; -export const SKY_STATE_KEY = 'ratatoskr.skyState'; - -const isSkyState = (v: unknown): v is SkyState => SKY_STATES.includes(v as SkyState); - -function load(): SkyState { - try { - const stored = localStorage.getItem(SKY_STATE_KEY); - return isSkyState(stored) ? stored : 'auto'; - } catch { - return 'auto'; - } -} - -export type { FixedSkyState }; - -/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */ -const LAT = 45; -/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */ -export const DAY_FROM = 6; - -let clock: () => Date = () => new Date(); -/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */ -export const standardOffsetMinutes = (now: Date): number => - Math.max( - new Date(now.getFullYear(), 0, 1).getTimezoneOffset(), - new Date(now.getFullYear(), 6, 1).getTimezoneOffset() - ); - -/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */ -export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState { - if (s !== 'auto') return s; - const lon = -standardOffsetMinutes(now) / 4; - const el = sunElevation(now, LAT, lon) as number; - if (el >= DAY_FROM) return 'day'; - return el <= -DAY_FROM ? 'night' : 'dusk'; -} - -let current: SkyState | undefined; -const listeners = new Set<(s: FixedSkyState) => void>(); -let timer: ReturnType | undefined; -let shown: FixedSkyState | undefined; -const TICK_MS = 60_000; - -/** The choice (what the control shows). */ -export const getSkyState = (): SkyState => { - current ??= load(); - return current; -}; - -/** The sky being drawn: the choice, resolved. */ -export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState()); - -const emit = () => { - shown = getResolvedSkyState(); - [...listeners].forEach((cb) => cb(shown as FixedSkyState)); -}; - -// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells -// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the -// first listener arrives, never by a later arrival or departure). Otherwise there is no timer. -function sync(): void { - clearTimeout(timer); - timer = undefined; - if (listeners.size === 0 || getSkyState() !== 'auto') return; - const tick = () => { - if (getResolvedSkyState() !== shown) emit(); - timer = setTimeout(tick, TICK_MS); - }; - timer = setTimeout(tick, TICK_MS); -} - -/** Replace the clock (tests); no argument restores the real one. */ -export function setSkyClock(now?: () => Date): void { - clock = now ?? (() => new Date()); - sync(); -} - -export function setSkyState(s: SkyState): void { - if (!isSkyState(s)) return; - const changed = s !== getSkyState(); - current = s; - try { - localStorage.setItem(SKY_STATE_KEY, s); - } catch { - // Storage is refused (private mode, quota): the choice holds for this page load only. - } - if (changed) { - sync(); - emit(); - } -} - -/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */ -export function subscribe(cb: (s: FixedSkyState) => void): () => void { - if (listeners.size === 0) shown = getResolvedSkyState(); - listeners.add(cb); - sync(); - return () => { - listeners.delete(cb); - sync(); - }; -} - -/** - * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the - * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of - * the sky it resolves to now. - */ -export interface SkyBackdrop { - readonly darkest: number; - readonly lightest: number; -} - -// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from -// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so -// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering -// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts -// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). -// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts). -// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places. -const BACKDROPS: Record = { - day: { darkest: 0.461063, lightest: 0.901108 }, - dusk: { darkest: 0.460352, lightest: 0.968744 }, - night: { darkest: 0.05987, lightest: 0.081642 }, -}; - -export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts deleted file mode 100644 index c7e460f3..00000000 --- a/src/app/features/scene/skyTheme.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { atStandardTime } from './standardTime.testkit'; -import { baked } from '../../../sky/baked'; -import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js'; -import { Editor } from '../../components/editor/Editor.css'; -import { PageContent, PageNavContent } from '../../components/page/style.css'; -import { CallStatus } from '../call-status/styles.css'; -import { MembersDrawer } from '../room/MembersDrawer.css'; -import { RoomViewTyping } from '../room/RoomViewTyping.css'; -import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; -import { SkyPlate, plateRegions } from './skyPlate.css'; -import { - getResolvedSkyState, - setSkyClock, - skyBackdrop, - subscribe as subscribeSky, -} from './skyState'; -import type { SkyBackdrop } from './skyState'; -import type { FixedSkyState } from './skyBand'; -import { sceneThemeId, skyThemeId } from './skyTheme'; - -const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; -const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => { - const id = skyThemeId(s); - return id ? [[s, id] as const] : []; -}); - -// WCAG contrast of an ink against the sky it is worst on, measured as skyState.test.ts does: the -// sky can show any luminance between its darkest and lightest, and contrast is lowest where the -// backdrop is closest to the ink, which is the ink's own luminance clamped into that range. -const against = (ink: string, bg: number) => { - const li = luminance(ink); - const [hi, lo] = li > bg ? [li, bg] : [bg, li]; - return (hi + 0.05) / (lo + 0.05); -}; -const ratio = (ink: string, b: SkyBackdrop) => - against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); -const r2 = (n: number) => n.toFixed(2); - -describe('skyThemeId', () => { - it('picks a theme for each explicit state', () => { - expect(skyThemeId('day')).toBe('noonday-sky'); - expect(skyThemeId('dusk')).toBe('noonday-sky'); - expect(skyThemeId('night')).toBe('midnight-sky'); - }); - - it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { - [0, 6, 12, 18, 23].forEach((h) => { - setSkyClock(() => atStandardTime(h, 10)); - expect(skyThemeId('auto')).toBeUndefined(); - }); - setSkyClock(); - }); - - describe('sceneThemeId, the theme the page wears', () => { - it('a fixed state always picks its theme, whatever the scene or the person chose', () => { - [true, false].forEach((scene) => - [true, false].forEach((chose) => { - expect(sceneThemeId('day', 'night', scene, chose)).toBe('noonday-sky'); - expect(sceneThemeId('night', 'day', scene, chose)).toBe('midnight-sky'); - }) - ); - }); - - it('auto follows the resolved sky only while a scene is on and the person chose no theme', () => { - expect(sceneThemeId('auto', 'day', true, false)).toBe('noonday-sky'); - expect(sceneThemeId('auto', 'dusk', true, false)).toBe('noonday-sky'); - expect(sceneThemeId('auto', 'night', true, false)).toBe('midnight-sky'); - expect(sceneThemeId('auto', 'day', false, false)).toBeUndefined(); - expect(sceneThemeId('auto', 'day', true, true)).toBeUndefined(); - }); - }); - - it('the resolved sky a hook subscribes to changes as the clock crosses, so theme classes follow', () => { - vi.useFakeTimers(); - let hour = 12; - setSkyClock(() => atStandardTime(hour, 10)); - const seen: string[] = []; - const off = subscribeSky((s) => seen.push(s)); - expect(getResolvedSkyState()).toBe('day'); - hour = 0; - setSkyClock(() => atStandardTime(hour, 10)); - vi.advanceTimersByTime(61_000); - expect(seen).toEqual(['night']); - expect(getResolvedSkyState()).toBe('night'); - off(); - vi.useRealTimers(); - setSkyClock(); - }); - - it('is what a wiring that follows the sky reads: the resolved state names a theme', () => { - setSkyClock(() => atStandardTime(12)); - expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky'); - setSkyClock(() => atStandardTime(0)); - expect(skyThemeId(getResolvedSkyState())).toBe('midnight-sky'); - setSkyClock(); - }); -}); - -describe('ink of the theme a state picks, on bare sky', () => { - // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes - // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears - // in every state: the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded as - // they are measured. Midnight's link is brightened in its own hue until it clears the lightest Night sky - // (NIGHT_SKY_LIGHTEST); Dusk's dark link needs a backdrop of about 0.4554, which the dusk floor is set above. - // 'auto' resolves from the clock to one of these three and has the same numbers. - const cells: [FixedSkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'], - ['dusk', 'noonday-sky', '#342d25', '6.59', '#2a4f27', '4.54'], - ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], - ]; - - it.each(cells)( - '%s with %s: text %s is %s, link %s is %s', - (state, id, ink, want, link, wantLink) => { - const sky = SKY[id as keyof typeof SKY]; - expect(sky.tokens.Background.OnContainer).toBe(ink); - expect(sky.link).toBe(link); - expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); - expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); - } - ); - - it.each(cells)('%s: text clears AA', (state, _id, ink) => { - expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); - }); - - it('link clears AA in every state', () => { - const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); - expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); - expect(link('dusk', 'noonday-sky')).toBeGreaterThanOrEqual(AA); - expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); - }); - - it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => { - expect(NIGHT_SKY_LIGHTEST).toBe(skyBackdrop('night').lightest); - }); - - it.each([ - [12, 'day'], - [18, 'dusk'], - [0, 'night'], - ] as const)('auto at %s:00 has the numbers of %s', (hour, state) => { - setSkyClock(() => atStandardTime(hour, hour === 18 ? 10 : 0)); - const id = skyThemeId(getResolvedSkyState()) as keyof typeof SKY; - expect(id).toBe(skyThemeId(state)); - const { tokens, link } = SKY[id]; - const text = tokens.Background.OnContainer; - expect(r2(ratio(text, skyBackdrop('auto')))).toBe(r2(ratio(text, skyBackdrop(state)))); - expect(r2(ratio(link, skyBackdrop('auto')))).toBe(r2(ratio(link, skyBackdrop(state)))); - setSkyClock(); - }); -}); - -describe('the plate, where text cannot rest on bare sky', () => { - // Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill. - const plates = [ - ['midnight-sky', baked.midnight, '12.17', '11.28'], - ['noonday-sky', baked.noonday, '6.70', '4.62'], - ] as const; - - it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => { - expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio); - expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio); - expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); - expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); - }); - - it('the other themes take fallback colours that clear AA', () => { - const { fill, text, link } = SKY_PLATE_FALLBACK; - expect(r2(contrast(text, fill))).toBe('10.21'); - expect(r2(contrast(link, fill))).toBe('10.03'); - }); - - it('every container of the picked themes carries its text at AA', () => { - fixed.forEach(([, id]) => { - Object.values(SKY[id].tokens).forEach((g) => { - expect(contrast(g.OnContainer, g.Container)).toBeGreaterThanOrEqual(AA); - }); - }); - }); -}); - -describe('regions that sit on the plate', () => { - // Every region whose text would otherwise rest on bare sky joins the one plate rule. The ink - // and link of that plate are pinned above; this pins who is on it. - const on = (cls: string) => expect(plateRegions).toContain(cls); - - it('plates the composer, the call status bar and the call view', () => { - on(`.${Editor}`); - on(`.${CallStatus}`); - on(`.${SkyPlate}`); - }); - - it('plates a message that is not a bubble, and nothing else of the signed-in shell', () => { - expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); - expect(plateRegions).toHaveLength(4); - }); - - // Meta, 2026-11-09: no chrome. These are bare ink on the sky, not plates. - it('leaves title bars, the room list, list pages, the drawer and the bars over the composer bare', () => { - [ - `.${MembersDrawer}`, - `.${PageNavContent}`, - `.${PageContent}`, - `.${RoomViewTyping}`, - '#root header', - ].forEach((cls) => expect(plateRegions).not.toContain(cls)); - }); -}); - -describe('the sign-in footer band', () => { - // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene, - // as the browser does: per channel, in encoded sRGB (not in linear light, which flatters dark scenes). The scene - // behind it is the terrain, taken as grey: as dark as 0, as light as the sky state's lightest backdrop. Ink - // contrast is lowest at one of those two ends, so both are checked. - const encode = (l: number) => (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055); - const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); - const hexOf = (c: number[]) => - `#${c - .map((v) => - Math.round(v * 255) - .toString(16) - .padStart(2, '0') - ) - .join('')}`; - const bandOver = (fill: string, behind: number) => - hexOf(channels(fill).map((f) => FOOTER_TINT * f + (1 - FOOTER_TINT) * encode(behind))); - it.each(fixed)( - '%s: the ink clears AA over the darkest and the lightest scene behind it', - (state, id) => { - const { Other, OtherText } = SKY[id].bubbles; - [0, skyBackdrop(state).lightest].forEach((behind) => { - expect(contrast(OtherText, bandOver(Other, behind))).toBeGreaterThanOrEqual(AA); - }); - } - ); -}); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts deleted file mode 100644 index 5dff3e06..00000000 --- a/src/app/features/scene/skyTheme.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Which theme a sky state lights the page with. 'auto' picks none: it never overrides a person's theme -// (the stored one when signed in, Midnight Sky when signed out). A wiring that follows the sky when no theme -// is stored reads `getResolvedSkyState` (skyState.ts) and passes that fixed state here. -// -// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's -// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on -// bare sky: Day at least 0.2983 (dark ink), Night at most 0.0931 (light ink). Dusk's floor is higher, the bound of -// the dark link (0.4554), so its link clears as well. skyTheme.test.ts pins the measured text and link ratios. Night's -// link ink is not in the band, and the themes' link colours are derived outside this code (src/sky/skyTokens.js). -// Midnight Sky's link is derived there against the lightest Night sky -// (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending -// its removal. -import type { FixedSkyState, SkyState } from './skyState'; - -export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; - -const BY_STATE: Record = { - day: 'noonday-sky', - dusk: 'noonday-sky', - night: 'midnight-sky', -}; - -export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - state === 'auto' ? undefined : BY_STATE[state]; - -/** - * The theme the page wears: a fixed sky state always picks its theme; 'auto' follows the resolved sky (`resolved`, the - * clock's state) only while a scene is on and the person has not chosen a theme of their own, and otherwise picks none. - * Signed-out pages choose no theme, so they pass `userChoseTheme: false`. - */ -export const sceneThemeId = ( - state: SkyState, - resolved: FixedSkyState, - sceneOn: boolean, - userChoseTheme: boolean -): SkyThemeId | undefined => { - if (state !== 'auto') return BY_STATE[state]; - return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined; -}; diff --git a/src/app/features/scene/standardTime.testkit.ts b/src/app/features/scene/standardTime.testkit.ts deleted file mode 100644 index a6966238..00000000 --- a/src/app/features/scene/standardTime.testkit.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { standardOffsetMinutes } from './skyState'; - -/** 22 September 2026 at h:m of local solar time as the sky clock reads it (standard time, whatever daylight saving says). */ -export const atStandardTime = (h: number, m = 0): Date => { - const probe = new Date(2026, 8, 22); - return new Date(Date.UTC(2026, 8, 22, h, m) + standardOffsetMinutes(probe) * 60000); -}; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts deleted file mode 100644 index ef1623c4..00000000 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-empty-function -- fake GL and DOM stubs, as the other scene tests */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTerrainScene } from './Terrain'; -import { GRID, triangleCount } from './terrainWorld'; -import { groundLight, SKY, skyAtSun } from './terrainSky'; -import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; -import { SKY_FRAG } from './terrainShaders'; -import { SKY_BAND } from '../skyBand'; -import { terrainSkyFor } from '../skyForState'; -import { startBackgroundScene } from '../index'; - -/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ -function fakeGl(over: Record = {}) { - const calls: Record = {}; - const gl: any = new Proxy( - {}, - { - get(_t, name: string) { - if (name in over) return over[name]; - if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; - if (name === 'getAttribLocation' || name === 'getUniformLocation') return () => 1; - if (name === 'checkFramebufferStatus') return () => 1; - if (name === 'getParameter') return () => 16; - if (/^[A-Z_0-9]+$/.test(name)) return 1; - return (...args: unknown[]) => { - (calls[name] ??= []).push(args); - return {}; - }; - }, - } - ); - return { gl, calls }; -} - -let rafQueue: Array<(t: number) => void>; -let visibility: string; -let search: string; -beforeEach(() => { - rafQueue = []; - visibility = 'visible'; - search = ''; - vi.stubGlobal('document', { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - get visibilityState() { - return visibility; - }, - createElement: () => ({ style: {}, remove() {} }), - }); - vi.stubGlobal('window', { - devicePixelRatio: 1, - innerWidth: 400, - innerHeight: 800, - matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - get location() { - return { search }; - }, - setTimeout, - }); - vi.stubGlobal('performance', { now: () => 0 }); - vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); - vi.stubGlobal('cancelAnimationFrame', () => { - rafQueue = []; - }); -}); -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function canvasWith(gl: unknown) { - return { - width: 0, - height: 0, - clientWidth: 400, - clientHeight: 800, - parentElement: { appendChild: vi.fn() }, - getContext: vi.fn(() => gl), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } as any; -} - -describe('createTerrainScene', () => { - it("returns null at tier 'off' and touches nothing", () => { - const canvas = canvasWith(null); - expect(createTerrainScene({ canvas, tier: 'off' })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - }); - - it('returns null without WebGL2, so the host keeps its CSS ground', () => { - const canvas = canvasWith(null); - expect(createTerrainScene({ canvas, tier: 'full' })).toBeNull(); - expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); - }); - - it('returns null with no vertex texture units, or an incomplete bake target', () => { - expect( - createTerrainScene({ canvas: canvasWith(fakeGl({ getParameter: () => 0 }).gl), tier: 'full' }) - ).toBeNull(); - const { gl, calls } = fakeGl({ checkFramebufferStatus: () => 0 }); - expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })).toBeNull(); - expect(calls.deleteProgram).toHaveLength(3); - }); - - it('reports a shader that does not compile to onCompileFail and returns null', () => { - const { gl } = fakeGl({ getShaderParameter: () => false, getShaderInfoLog: () => 'bad' }); - const onCompileFail = vi.fn(); - expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onCompileFail })).toBeNull(); - expect(onCompileFail).toHaveBeenCalledOnce(); - expect(onCompileFail.mock.calls[0][0].log).toContain('terrain height'); - }); - - it('bakes the height map once, then draws sky and grid each frame and keeps turning', () => { - const { gl, calls } = fakeGl(); - const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'low' })!; - expect(scene).not.toBeNull(); - const bakes = calls.drawArrays.length; - expect(bakes).toBe(1); - rafQueue.shift()!(16); - rafQueue.shift()!(32); - expect(calls.drawArrays).toHaveLength(bakes + 2); // one sky draw per frame - expect(calls.drawElements).toHaveLength(2); - expect(calls.drawElements[0][1]).toBe(triangleCount(GRID.low.grid) * 3); - expect(calls.texImage2D).toHaveLength(1); // the baked map is never re-uploaded - expect(rafQueue).toHaveLength(1); - scene.dispose(); - }); - - it("'still' draws one frame and queues nothing", () => { - const { gl, calls } = fakeGl(); - const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!; - expect(calls.drawElements).toHaveLength(1); - expect(rafQueue).toHaveLength(0); - scene.dispose(); - }); - - it('lights with the sky chosen: option, then ?sky=, then the night default', () => { - const uploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { - const { gl, calls } = fakeGl(); - createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); - // every vec3 uniform draw() sets: right, up, forward, sun, sunColour, glow, ambient, camera, soil... - return calls.uniform3fv - .filter((a) => (a[1] as ArrayLike).length === 3) - .map((a) => Array.from(a[1] as ArrayLike)); - }; - const night = uploaded({}); - const day = uploaded({ sky: 'noonday' }); - const nightA = Array.from(groundLight(SKY.midnight).ambient); - const dayA = Array.from(groundLight(SKY.noonday).ambient); - const has = (all: number[][], v: number[]) => - all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); - expect(has(night, nightA)).toBe(true); - expect(has(night, dayA)).toBe(false); - expect(has(day, dayA)).toBe(true); - search = '?sky=noonday'; - expect(has(uploaded({}), dayA)).toBe(true); - search = '?sky=garbage'; - expect(has(uploaded({}), nightA)).toBe(true); - }); - - it.each(['day', 'dusk', 'night'] as const)( - 'uploads the %s band as uBand (floor, ceiling) and uLift, in the sky pass and the grid pass', - (state) => { - const { gl, calls } = fakeGl(); - const opts = terrainSkyFor(state); - createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); - const { floor, ceiling, lift } = SKY_BAND[state]; - const pairs = calls.uniform2fv.filter( - (a) => - Math.abs((a[1] as number[])[0] - floor) < 1e-6 && - Math.abs((a[1] as number[])[1] - ceiling) < 1e-6 - ); - expect(pairs).toHaveLength(2); - const lifts = calls.uniform3fv.filter((a) => - Array.from(a[1] as ArrayLike).every((x, i) => Math.abs(x - lift[i]) < 1e-6) - ); - expect(lifts).toHaveLength(2); - } - ); - - it('uploads no band (0, 1) and a zero lift without one', () => { - const { gl, calls } = fakeGl(); - createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!.dispose(); - expect(calls.uniform2fv.filter((a) => (a[1] as number[])[1] === 1)).toHaveLength(2); - }); - - it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { - const ambients = (opts: Record) => { - const { gl, calls } = fakeGl(); - createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); - return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike)); - }; - const dusk = skyAtSun(-1, 300); - const has = (all: number[][], v: readonly number[]) => - all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); - expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), groundLight(dusk).ambient)).toBe( - true - ); - search = '?sky=noonday&sun=-1&az=300'; - expect(has(ambients({}), groundLight(dusk).ambient)).toBe(true); - search = '?sun=garbage'; - expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); - }); - - it('uploads the baked sky table and the aerial perspective volume once each, as half floats, to 3D textures', () => { - const { gl, calls } = fakeGl(); - createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); - expect(calls.texImage3D).toHaveLength(2); - const a = calls.texImage3D[0]; - expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); - expect(a[9]).toEqual(skyViewWords()); - const ap = calls.texImage3D[1]; - expect([ap[3], ap[4], ap[5]]).toEqual([AP_VOLUME.width, AP_VOLUME.height, AP_VOLUME.depth]); - expect(ap[9]).toEqual(apVolumeWords()); - expect(calls.deleteTexture).toHaveLength(3); - }); - - it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { - expect(SKY_FRAG).toContain('- 0.5'); - expect(SKY_FRAG).toContain('dither / 255.0'); - }); - - it('falls back to a still frame when the measured p95 is over 33 ms', () => { - const { gl, calls } = fakeGl(); - const onProbe = vi.fn(); - const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onProbe })!; - let t = 0; - for (let i = 0; i < 400 && rafQueue.length; i += 1) rafQueue.shift()!((t += 50)); - expect(rafQueue).toHaveLength(0); - expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); - expect(calls.drawElements.length).toBeLessThan(400); - scene.dispose(); - }); - - it('does not draw while the tab is hidden, or under reduced motion', () => { - const { gl, calls } = fakeGl(); - const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!; - const before = (calls.drawElements ?? []).length; - visibility = 'hidden'; - rafQueue.shift()!(16); - expect((calls.drawElements ?? []).length).toBe(before); - scene.dispose(); - vi.stubGlobal('window', { - ...(window as object), - matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), - }); - visibility = 'visible'; - const g2 = fakeGl(); - const still = createTerrainScene({ canvas: canvasWith(g2.gl), tier: 'full' })!; - expect(rafQueue).toHaveLength(0); - expect(g2.calls.drawElements).toHaveLength(1); // one frame, then still - still.dispose(); - }); - - it('a lost context stops the scene and tells the host', () => { - const { gl, calls } = fakeGl(); - const canvas = canvasWith(gl); - const onContextLost = vi.fn(); - createTerrainScene({ canvas, tier: 'full', onContextLost })!; - const handler = canvas.addEventListener.mock.calls.find( - (c: unknown[]) => c[0] === 'webglcontextlost' - )[1]; - handler({ preventDefault() {} }); - expect(onContextLost).toHaveBeenCalledOnce(); - const n = (calls.drawElements ?? []).length; - rafQueue.splice(0).forEach((f) => f(16)); - expect((calls.drawElements ?? []).length).toBe(n); - }); - - it('dispose frees its programs, buffers and texture and queues nothing more', () => { - const { gl, calls } = fakeGl(); - createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); - expect(calls.deleteProgram).toHaveLength(3); - expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(3); - expect(rafQueue).toHaveLength(0); - }); -}); - -describe("startBackgroundScene style 'terrain'", () => { - it('requests webgl2 and returns a scene once the tier is on', async () => { - const { gl } = fakeGl(); - const canvas = canvasWith(gl); - const scene = await startBackgroundScene({ tier: 'still', style: 'terrain' }, {}, { canvas }); - expect(scene).not.toBeNull(); - expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); - scene!.dispose(); - }); - - it('stays off by default, and the default style is still the ridge (no WebGL2)', async () => { - const canvas = canvasWith(null); - expect(await startBackgroundScene({ style: 'terrain' }, {}, { canvas })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - const { gl } = fakeGl(); - const c2 = { ...canvasWith(gl), getContext: vi.fn(() => null) }; - await startBackgroundScene({ tier: 'still' }, {}, { canvas: c2 }); - expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); - }); -}); - -describe('stars in the terrain scene', () => { - let overlays: any[]; - let arcs: number; - const withOverlays = () => { - overlays = []; - arcs = 0; - (document as any).createElement = () => { - const el: any = { - style: {}, - remove: vi.fn(), - setAttribute() {}, - getContext: () => ({ - clearRect() {}, - beginPath() {}, - fill() {}, - arc: () => (arcs += 1), - }), - }; - overlays.push(el); - return el; - }; - }; - const scene = (sky: 'midnight' | 'noonday', tier: 'still' | 'low' = 'low') => { - withOverlays(); - const canvas = canvasWith(fakeGl().gl); - const s = createTerrainScene({ canvas, tier, sky })!; - return { s, canvas }; - }; - - it('a midnight sky gets one star overlay beside the canvas, painted with the terrain', () => { - const { s, canvas } = scene('midnight'); - expect(overlays).toHaveLength(1); - expect(canvas.parentElement.appendChild).toHaveBeenCalledWith(overlays[0]); - rafQueue.shift()!(16); - expect(arcs).toBeGreaterThan(0); - s.dispose(); - expect(overlays[0].remove).toHaveBeenCalledOnce(); - }); - - it('a noonday sky gets no overlay and paints no stars', () => { - const { s } = scene('noonday'); - expect(overlays).toHaveLength(0); - expect(arcs).toBe(0); - s.dispose(); - }); - - it("'still' paints the stars once at base and queues nothing", () => { - const { s } = scene('midnight', 'still'); - expect(arcs).toBeGreaterThan(0); - expect(rafQueue).toHaveLength(0); - s.dispose(); - }); - - it('a hidden tab or reduced motion paints no more stars than the first frame', () => { - visibility = 'hidden'; - const { s } = scene('midnight'); - const n = arcs; - expect(n).toBeGreaterThan(0); - expect(rafQueue).toHaveLength(0); // the terrain loop never starts, so nothing repaints the stars - s.dispose(); - visibility = 'visible'; - (window as any).matchMedia = () => ({ - matches: true, - addEventListener() {}, - removeEventListener() {}, - }); - const r = scene('midnight'); - expect(rafQueue).toHaveLength(0); - expect(arcs).toBe(n); - r.s.dispose(); - }); - - it('stars turn with the terrain: a later frame is painted at a later heading', () => { - let clock = 0; - vi.stubGlobal('performance', { now: () => (clock += 1000) }); - const { s } = scene('midnight'); - rafQueue.shift()!(16); - const first = arcs; - rafQueue.shift()!(1000); - expect(arcs).toBeGreaterThan(first); - s.dispose(); - }); -}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts deleted file mode 100644 index a1807305..00000000 --- a/src/app/features/scene/terrain/Terrain.ts +++ /dev/null @@ -1,443 +0,0 @@ -import type { Scene, SceneOptions } from '../Scene'; -import type { SkyBand } from '../skyBand'; -import { FrameSampler, FrameStats } from '../ridge/probe'; -import { albedoPalette } from './terrainAlbedo'; -import { sunIndex } from '../../../../sky/scatter.js'; -import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; -import { - nightness, - glowColour, - groundLight, - chooseSky, - SkyName, - START_TURNS, - sunDirection, -} from './terrainSky'; -import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; -import { - FULLSCREEN_VERT, - GRID_FRAG, - GRID_VERT, - heightFragmentSource, - SKY_FRAG, -} from './terrainShaders'; -import { createTerrainStars } from './terrainStars'; -import { overlayRequested } from '../debugOverlays'; -import { showTierLabel } from './tierLabel'; - -export type TerrainOptions = Pick< - SceneOptions, - 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' -> & { - /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else midnight. */ - sky?: SkyName; - /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ - sun?: { elevation: number; azimuth?: number }; - /** The luminance band the sky is shaped into (skyBand.ts), in the sky pass and in the haze that fades into it. Default: none. */ - band?: SkyBand; - /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ - seed?: number; - /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`, where `debug` allows it. */ - probe?: boolean; - /** The diagnostic overlays (readout, tier label) may be asked for by URL (debugOverlays.ts). Default: no. */ - debug?: boolean; - onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; -}; - -/** Same shape as Scene.ts's ShaderCompileError, redeclared so this chunk does not import the ray-march scene. */ -class ShaderCompileError extends Error { - constructor(readonly stage: 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } -} - -function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: string): WebGLProgram { - const make = (type: number, src: string, which: string) => { - const s = gl.createShader(type); - if (!s) throw new Error('createShader failed'); - gl.shaderSource(s, src); - gl.compileShader(s); - if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { - const log = gl.getShaderInfoLog(s) ?? ''; - gl.deleteShader(s); - throw new ShaderCompileError('vertex', `terrain ${name} ${which} shader: ${log}`); - } - return s; - }; - const v = make(gl.VERTEX_SHADER, vs, 'vertex'); - const f = make(gl.FRAGMENT_SHADER, fs, 'fragment'); - const p = gl.createProgram(); - if (!p) throw new Error('createProgram failed'); - gl.attachShader(p, v); - gl.attachShader(p, f); - gl.linkProgram(p); - gl.deleteShader(v); - gl.deleteShader(f); - if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { - const log = gl.getProgramInfoLog(p) ?? ''; - gl.deleteProgram(p); - throw new ShaderCompileError('vertex', `terrain ${name} link: ${log}`); - } - return p; -} - -/** - * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the - * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an - * RGBA8 texture; each frame is two draws: the sky from the baked scattering table, then a displaced grid lit by the - * baked sun and ambient and hazed toward that sky at the horizon. Cadence guard, pauses and the `?probe=1` readout match the - * ridge scene. - */ -export function createTerrainScene(opts: TerrainOptions): Scene | null { - const tier = opts.tier ?? 'full'; - if (tier === 'off') return null; - const { canvas } = opts; - const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); - const gl = canvas.getContext('webgl2', { - antialias: false, - alpha: false, - }) as WebGL2RenderingContext | null; - if (!gl) return null; - const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); - if (typeof units === 'number' && units < 1) return null; - - const sky = chooseSky(opts); - const spec = GRID[tier]; - - let skyProg: WebGLProgram; - let gridProg: WebGLProgram; - let bakeProg: WebGLProgram; - try { - bakeProg = program(gl, 'height', FULLSCREEN_VERT, heightFragmentSource()); - skyProg = program(gl, 'sky', FULLSCREEN_VERT, SKY_FRAG); - gridProg = program(gl, 'grid', GRID_VERT, GRID_FRAG); - } catch (e) { - opts.onCompileFail?.(e as ShaderCompileError); - return null; - } - - // The height map, baked once. - const heightTex = gl.createTexture(); - const fbo = gl.createFramebuffer(); - gl.bindTexture(gl.TEXTURE_2D, heightTex); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - // The baked scattering table, RGB16F, uploaded as it is stored (half floats); the shader divides by the store scale. - const skyTex = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_3D, skyTex); - gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - gl.texImage3D( - gl.TEXTURE_3D, - 0, - gl.RGB16F, - SKY_VIEW.width, - SKY_VIEW.height, - SKY_VIEW.depth, - 0, - gl.RGB, - gl.HALF_FLOAT, - skyViewWords() - ); - gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => - gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) - ); - // The aerial perspective volume, RGBA16F, half floats as stored (src/sky/scatter.js). - const apTex = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_3D, apTex); - gl.texImage3D( - gl.TEXTURE_3D, - 0, - gl.RGBA16F, - AP_VOLUME.width, - AP_VOLUME.height, - AP_VOLUME.depth, - 0, - gl.RGBA, - gl.HALF_FLOAT, - apVolumeWords() - ); - gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => - gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) - ); - gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); - gl.bindTexture(gl.TEXTURE_2D, heightTex); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); - const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; - const free: Array<() => void> = []; - const release = () => { - free.forEach((f) => f()); - gl.deleteTexture(heightTex); - gl.deleteTexture(skyTex); - gl.deleteTexture(apTex); - gl.deleteFramebuffer(fbo); - gl.deleteProgram(bakeProg); - gl.deleteProgram(skyProg); - gl.deleteProgram(gridProg); - }; - if (!complete) { - release(); - return null; - } - gl.viewport(0, 0, spec.tex, spec.tex); - gl.disable(gl.DEPTH_TEST); - gl.useProgram(bakeProg); - gl.uniform1f(gl.getUniformLocation(bakeProg, 'uSpan'), TERRAIN.span); - gl.uniform1f(gl.getUniformLocation(bakeProg, 'uTexels'), spec.tex); - gl.uniform2fv(gl.getUniformLocation(bakeProg, 'uSeed'), seedOffset(opts.seed ?? 0)); - gl.drawArrays(gl.TRIANGLES, 0, 3); - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - - // The displaced grid. - const g = buildGrid(spec.grid); - const vao = gl.createVertexArray(); - const vbuf = gl.createBuffer(); - const ibuf = gl.createBuffer(); - gl.bindVertexArray(vao); - gl.bindBuffer(gl.ARRAY_BUFFER, vbuf); - gl.bufferData(gl.ARRAY_BUFFER, g.uv, gl.STATIC_DRAW); - gl.enableVertexAttribArray(0); - gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); - gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf); - gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, g.indices, gl.STATIC_DRAW); - gl.bindVertexArray(null); - free.push( - () => gl.deleteVertexArray(vao), - () => gl.deleteBuffer(vbuf), - () => gl.deleteBuffer(ibuf) - ); - - const sun = [...sunDirection(sky)]; - const sunLight = [...sky.direct]; - const ground = groundLight(sky); - const ambient = [...ground.ambient]; - const heading = START_TURNS; - const glow = [...glowColour(sky)]; - const band: [number, number] = sky.band ? [sky.band.floor, sky.band.ceiling] : [0, 1]; - const lift = [...(sky.band?.lift ?? [0, 0, 0])]; - const sunTexel = sunIndex(sky.elevation); - const night = nightness(sky.elevation); - const moonTexel = sunIndex(Math.abs(sky.elevation)); - const palette = albedoPalette(sky); - const triangles = triangleCount(spec.grid); - - const stars = createTerrainStars(canvas, sky); - const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); - const sampler = new FrameSampler(); - let still = tier === 'still'; - let disposed = false; - let lost = false; - let raf = 0; - let readout: HTMLElement | undefined; - let resizeTimer = 0; - let turnsAtPause = 0; - let playedFrom: number | undefined; - - /** Size the canvas: low renders at 0.75 of the CSS size; full at device pixel ratio capped to 2. */ - const size = () => { - const dpr = Math.min(window.devicePixelRatio || 1, 2) * (tier === 'low' ? 0.75 : 1); - canvas.width = Math.max(1, Math.round((canvas.clientWidth || window.innerWidth) * dpr)); - canvas.height = Math.max(1, Math.round((canvas.clientHeight || window.innerHeight) * dpr)); - }; - - const draw = (turns: number) => { - if (lost) return; - gl.viewport(0, 0, canvas.width, canvas.height); - const v = view(canvas.width / canvas.height, (turns + heading) % 1); - gl.disable(gl.DEPTH_TEST); - gl.useProgram(skyProg); - const us = (n: string) => gl.getUniformLocation(skyProg, n); - gl.uniform3fv(us('uSun'), sun); - gl.uniform3fv(us('uSunColour'), sunLight); - gl.uniform3fv(us('uGlow'), glow); - gl.uniform2fv(us('uBand'), band); - gl.uniform3fv(us('uLift'), lift); - gl.uniform1f(us('uSunIndex'), sunTexel); - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_3D, skyTex); - gl.uniform1i(us('uSky'), 1); - gl.uniform3fv(us('uRight'), v.right); - gl.uniform3fv(us('uUp'), v.up); - gl.uniform3fv(us('uForward'), v.forward); - gl.uniform2f(us('uSize'), canvas.width, canvas.height); - gl.uniform1f(us('uTanHalf'), Math.tan(CAMERA.fov / 2)); - gl.drawArrays(gl.TRIANGLES, 0, 3); - - gl.enable(gl.DEPTH_TEST); - gl.clear(gl.DEPTH_BUFFER_BIT); - gl.useProgram(gridProg); - const u = (n: string) => gl.getUniformLocation(gridProg, n); - gl.uniformMatrix4fv(u('uViewProj'), false, v.viewProj); - gl.uniform3fv(u('uCamera'), v.eye); - gl.uniform1f(u('uSpan'), TERRAIN.span); - gl.uniform1i(u('uTex'), spec.tex); - gl.uniform3fv(u('uSun'), sun); - gl.uniform3fv(u('uLightDir'), ground.dir); - gl.uniform3fv(u('uLight'), ground.colour); - gl.uniform3fv(u('uAmbient'), ambient); - gl.uniform3fv(u('uGlow'), glow); - gl.uniform2fv(u('uBand'), band); - gl.uniform3fv(u('uLift'), lift); - gl.uniform1f(u('uSunIndex'), sunTexel); - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_3D, skyTex); - gl.uniform1i(u('uSky'), 1); - gl.uniform1f(u('uNight'), night); - gl.uniform1f(u('uMoonIndex'), moonTexel); - gl.activeTexture(gl.TEXTURE2); - gl.bindTexture(gl.TEXTURE_3D, apTex); - gl.uniform1i(u('uAP'), 2); - gl.uniform3fv(u('uSoil'), palette.soil); - gl.uniform3fv(u('uScree'), palette.scree); - gl.uniform3fv(u('uRock'), palette.rock); - gl.uniform3fv(u('uSnow'), palette.snow); - gl.uniform1f(u('uSnowLine'), palette.snowLine); - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, heightTex); - gl.uniform1i(u('uHeight'), 0); - gl.bindVertexArray(vao); - gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); - gl.bindVertexArray(null); - stars?.draw((turns + heading) % 1, moving() ? performance.now() / 1000 : undefined); - }; - - const moving = () => - !still && - !disposed && - !lost && - !(reducedMotion?.matches ?? false) && - document.visibilityState === 'visible'; - - const turnsNow = (now: number) => - turnsAtPause + (playedFrom === undefined ? 0 : (now - playedFrom) / (CAMERA.seconds * 1000)); - - const report = (stillNow: boolean) => { - const s = { ...sampler.stats(), still: stillNow, triangles }; - opts.onProbe?.(s); - if (readout) { - readout.textContent = `terrain · ${s.triangles} tris · n ${s.n} · median ${s.medianMs.toFixed( - 1 - )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; - } - }; - - const settleToStill = () => { - if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); - playedFrom = undefined; - still = true; - cancelAnimationFrame(raf); - raf = 0; - draw(turnsAtPause); - report(true); - }; - - let sinceReport = 0; - // The first window is measured (guard against a slow device); after it the loop only draws. - let measuring = true; - const frame = (now: number) => { - raf = 0; - if (!moving()) return; - draw(turnsNow(now)); - if (!measuring) { - raf = requestAnimationFrame(frame); - return; - } - const full = sampler.push(now); - if (full) { - measuring = false; - if (sampler.slow()) { - settleToStill(); - return; - } - if (probe) report(false); - } else if (probe && now - sinceReport > 500) { - sinceReport = now; - report(false); - } - raf = requestAnimationFrame(frame); - }; - const wake = () => { - if (!moving()) { - if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); - playedFrom = undefined; - return; - } - if (raf) return; - playedFrom = performance.now(); - if (measuring) sampler.reset(); - raf = requestAnimationFrame(frame); - }; - - const onLost = (e: Event) => { - e.preventDefault(); - lost = true; - cancelAnimationFrame(raf); - raf = 0; - opts.onContextLost?.(); - }; - canvas.addEventListener('webglcontextlost', onLost); - - size(); - if (probe && canvas.parentElement) { - readout = document.createElement('div'); - Object.assign(readout.style, { - position: 'absolute', - left: '8px', - top: '8px', - font: '12px monospace', - color: '#fff', - background: 'rgba(0,0,0,.55)', - padding: '2px 6px', - borderRadius: '3px', - pointerEvents: 'none', - }); - canvas.parentElement.appendChild(readout); - } - const removeLabel = showTierLabel(canvas, tier, opts.debug === true); - if (still) settleToStill(); - else wake(); - if (!still && !moving()) draw(0); - - const onVisibility = () => { - sampler.reset(); - wake(); - }; - const onResize = () => { - clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(() => { - if (disposed || lost) return; - size(); - stars?.resize(); - draw(turnsNow(performance.now())); - sampler.reset(); - }, 250); - }; - document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('resize', onResize); - reducedMotion?.addEventListener?.('change', onVisibility); - - return { - login() { - // The camera is the same before and after. - }, - dispose() { - if (disposed) return; - disposed = true; - cancelAnimationFrame(raf); - clearTimeout(resizeTimer); - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('resize', onResize); - reducedMotion?.removeEventListener?.('change', onVisibility); - canvas.removeEventListener('webglcontextlost', onLost); - readout?.remove(); - removeLabel?.(); - stars?.dispose(); - release(); - }, - }; -} diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts deleted file mode 100644 index d85d8f01..00000000 --- a/src/app/features/scene/terrain/TerrainSky2D.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; -import { startBackgroundScene } from '../index'; -import { terrainSkyFor } from '../skyForState'; - -let search: string; -let arcs: number; -let overlay: { remove: ReturnType }; -let windowListeners: Record void>; -beforeEach(() => { - search = ''; - arcs = 0; - windowListeners = {}; - vi.stubGlobal('document', { - createElement: () => { - overlay = { - remove: vi.fn(), - style: {}, - setAttribute() {}, - getContext: () => ({ - clearRect() {}, - beginPath() {}, - fill() {}, - arc: () => { - arcs += 1; - }, - }), - } as any; - return overlay; - }, - }); - vi.stubGlobal('window', { - devicePixelRatio: 1, - innerWidth: 400, - innerHeight: 800, - addEventListener: (n: string, f: () => void) => { - windowListeners[n] = f; - }, - removeEventListener: (n: string) => { - delete windowListeners[n]; - }, - get location() { - return { search }; - }, - setTimeout, - }); -}); -afterEach(() => { - vi.unstubAllGlobals(); -}); - -/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ -function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { - const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; - const ctx = { - createImageData: (width: number, height: number) => ({ - width, - height, - data: new Uint8ClampedArray(width * height * 4), - }), - putImageData: (image: any) => painted.push(image), - }; - const parentAppend = vi.fn(); - const canvas: any = { - width: 0, - height: 0, - clientWidth: 400, - clientHeight: 800, - parentElement: { appendChild: parentAppend }, - getContext: vi.fn((kind: string) => { - if (kind !== '2d') return opts.gl ?? null; - return opts.twoD === false ? null : ctx; - }), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }; - return { canvas, painted, parentAppend }; -} - -const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => - Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); -const mean = (img: { data: Uint8ClampedArray }) => { - let s = 0; - for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; - return s / (img.data.length / 4) / 3; -}; - -describe('createTerrainSkyScene', () => { - it("returns null at tier 'off' and touches nothing", () => { - const { canvas } = canvasWith(); - expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); - expect(canvas.getContext).not.toHaveBeenCalled(); - }); - - it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { - const { canvas } = canvasWith({ twoD: false }); - expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); - }); - - it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { - const { canvas, painted } = canvasWith(); - const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; - expect(painted).toHaveLength(1); - const img = painted[0]; - expect(img.width).toBe(SKY_WIDTH); - expect(canvas.width).toBe(SKY_WIDTH); - expect(canvas.height).toBe(img.height); - for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); - const x = Math.floor(SKY_WIDTH / 2); - expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); - expect(mean(img)).toBeGreaterThan(20); - s.dispose(); - }); - - it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { - const noon = canvasWith(); - createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); - const night = canvasWith(); - createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); - expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); - - const low = canvasWith(); - createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); - const down = canvasWith(); - createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); - expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); - - search = '?sun=30&az=0'; - const q0 = canvasWith(); - createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); - search = '?sun=30&az=180'; - const q1 = canvasWith(); - createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); - expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); - }); - - it('stars only on a dark sky', () => { - const night = canvasWith(); - const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; - expect(night.parentAppend).toHaveBeenCalledTimes(1); - expect(arcs).toBeGreaterThan(0); - a.dispose(); - expect(overlay.remove).toHaveBeenCalled(); - arcs = 0; - const day = canvasWith(); - createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); - expect(day.parentAppend).not.toHaveBeenCalled(); - expect(arcs).toBe(0); - }); - - it('repaints on resize, and dispose stops listening', () => { - vi.useFakeTimers(); - vi.stubGlobal('window', { - ...(window as any), - setTimeout, - addEventListener: (window as any).addEventListener, - }); - const { canvas, painted } = canvasWith(); - const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; - expect(windowListeners.resize).toBeDefined(); - windowListeners.resize(); - vi.advanceTimersByTime(300); - expect(painted).toHaveLength(2); - s.dispose(); - expect(windowListeners.resize).toBeUndefined(); - vi.useRealTimers(); - }); -}); - -describe('the sky-only tier label', () => { - const labels = (parentAppend: ReturnType) => - parentAppend.mock.calls.filter(([e]) => e?.textContent === 'terrain · sky-only'); - - it('shows for ?tierlabel=1 only where the overlays are allowed', () => { - search = '?tierlabel=1'; - const off = canvasWith(); - createTerrainSkyScene({ canvas: off.canvas, tier: 'low' })!.dispose(); - expect(labels(off.parentAppend)).toHaveLength(0); - const on = canvasWith(); - createTerrainSkyScene({ canvas: on.canvas, tier: 'low', debug: true })!.dispose(); - expect(labels(on.parentAppend)).toHaveLength(1); - }); - - it('the door allows it from the config flag, and not from a plain production config', async () => { - vi.stubEnv('DEV', false); - vi.stubEnv('VITE_PREVIEW_DEMO', ''); - search = '?tierlabel=1'; - const plain = canvasWith(); - ( - await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas: plain.canvas }) - )?.dispose(); - expect(labels(plain.parentAppend)).toHaveLength(0); - const flagged = canvasWith(); - ( - await startBackgroundScene( - { tier: 'low', style: 'terrain', debugOverlays: true }, - {}, - { canvas: flagged.canvas } - ) - )?.dispose(); - expect(labels(flagged.parentAppend)).toHaveLength(1); - vi.unstubAllEnvs(); - }); -}); - -describe('startBackgroundScene, terrain style', () => { - it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { - const { canvas, painted } = canvasWith({ gl: null }); - const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); - expect(scene).not.toBeNull(); - expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); - expect(painted).toHaveLength(1); - scene!.dispose(); - }); - - it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { - const gl: any = new Proxy( - {}, - { - get(_t, name: string) { - if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; - if (name === 'checkFramebufferStatus') return () => 1; - if (name === 'getParameter') return () => 16; - if (/^[A-Z_0-9]+$/.test(name)) return 1; - return () => ({}); - }, - } - ); - Object.assign(document, { - addEventListener() {}, - removeEventListener() {}, - visibilityState: 'visible', - }); - vi.stubGlobal('requestAnimationFrame', () => 1); - vi.stubGlobal('cancelAnimationFrame', () => {}); - vi.stubGlobal('performance', { now: () => 0 }); - (window as any).matchMedia = () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, - }); - const { canvas, painted } = canvasWith({ gl }); - const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); - expect(scene).not.toBeNull(); - expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); - expect(painted).toHaveLength(0); - scene!.dispose(); - }); - - it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { - const { canvas } = canvasWith({ gl: null, twoD: false }); - expect( - await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) - ).toBeNull(); - }); -}); - -describe('the 2D sky lies in the band of its state', () => { - // The painted pixels, read back through the sRGB curve as a screen shows them. - const lumOf = (rgb: number[]) => { - const [r, g, b] = rgb.map((v) => { - const e = v / 255; - return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; - }); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; - }; - const painted = (state: 'day' | 'dusk' | 'night') => { - const c = canvasWith(); - createTerrainSkyScene({ canvas: c.canvas, tier: 'low', ...terrainSkyFor(state) })!.dispose(); - const img = c.painted[0]; - const out: number[] = []; - for (let y = 0; y < img.height; y += 1) { - for (let x = 0; x < img.width; x += 1) out.push(lumOf(pixel(img, x, y))); - } - return out; - }; - - it.each([['day'], ['dusk']] as const)('%s: no pixel below the text bound 0.2983', (state) => { - expect(Math.min(...painted(state))).toBeGreaterThanOrEqual(0.2983); - }); - - it('night: no pixel above the text bound 0.0931', () => { - expect(Math.max(...painted('night'))).toBeLessThanOrEqual(0.0931); - }); -}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts deleted file mode 100644 index 4653d470..00000000 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Scene } from '../Scene'; -import type { TerrainOptions } from './Terrain'; -import { chooseSky, skyColour, START_TURNS, Vec3 } from './terrainSky'; -import { createTerrainStars } from './terrainStars'; -import { showTierLabel } from './tierLabel'; -import { CAMERA, view } from './terrainWorld'; - -/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ -export const SKY_WIDTH = 96; - -/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ -export type TerrainSkyOptions = Pick< - TerrainOptions, - 'canvas' | 'tier' | 'sky' | 'sun' | 'band' | 'debug' ->; - -/** - * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the - * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so - * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so - * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already - * holds a WebGL context and gives no 2D one. - */ -export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { - if ((opts.tier ?? 'full') === 'off') return null; - const { canvas } = opts; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - const sky = chooseSky(opts); - const stars = createTerrainStars(canvas, sky); - const heading = START_TURNS; - const tanHalf = Math.tan(CAMERA.fov / 2); - let disposed = false; - let resizeTimer = 0; - - const paint = () => { - const w = SKY_WIDTH; - const cw = canvas.clientWidth || window.innerWidth; - const ch = canvas.clientHeight || window.innerHeight; - const h = Math.max(1, Math.round((w * ch) / cw)); - canvas.width = w; - canvas.height = h; - const v = view(cw / ch, heading); - const image = ctx.createImageData(w, h); - for (let y = 0; y < h; y += 1) { - const ny = 1 - ((y + 0.5) / h) * 2; - for (let x = 0; x < w; x += 1) { - const nx = ((x + 0.5) / w) * 2 - 1; - const d = [0, 1, 2].map( - (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] - ); - const len = Math.hypot(d[0], d[1], d[2]); - const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); - const at = (y * w + x) * 4; - for (let i = 0; i < 3; i += 1) { - image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); - } - image.data[at + 3] = 255; - } - } - ctx.putImageData(image, 0, 0); - stars?.draw(heading); - }; - - const onResize = () => { - clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(() => { - if (disposed) return; - stars?.resize(); - paint(); - }, 250); - }; - window.addEventListener('resize', onResize); - paint(); - const removeLabel = showTierLabel(canvas, 'sky-only', opts.debug === true); - - return { - login() { - // Nothing moves, so there is no pull-back to start. - }, - dispose() { - if (disposed) return; - disposed = true; - clearTimeout(resizeTimer); - window.removeEventListener('resize', onResize); - removeLabel?.(); - stars?.dispose(); - }, - }; -} diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts deleted file mode 100644 index 6afac79d..00000000 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ -import { describe, expect, it } from 'vitest'; -import { SKY } from './terrainSky'; -import { - ALBEDO, - albedoAt, - albedoPalette, - bilinearHeight, - CAVITY, - cavityAt, - curvatureAt, - grainAt, - triWeights, - wobble, - wobbleAt, -} from './terrainAlbedo'; -import { GRID_FRAG } from './terrainShaders'; - -const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => - a.every((v, i) => Math.abs(v - b[i]) < eps); -const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; -// A point where the wobble is zero, so band edges sit exactly where the palette says. -const FLAT_X = Math.PI / 0.043; -// A normal for the xz plane alone (all the weight on it), carrying `ny` for the slope bands. -const facing = (ny: number) => [0, ny, 0] as const; -const at = (sky: keyof typeof SKY, h: number, ny: number) => - albedoAt(albedoPalette(SKY[sky]), h, facing(ny), FLAT_X, 0); - -describe('albedo by slope and height', () => { - it('the test point really has no wobble', () => { - expect(Math.abs(wobble(FLAT_X, 0))).toBeLessThan(1e-9); - }); - - it('steep faces are rock at every height, gentle low ones are soil, gentle high ones are snow', () => { - const p = albedoPalette(SKY.noonday); - for (const h of [10, 90, 160, 220]) expect(near(at('noonday', h, 0.3), p.rock)).toBe(true); - expect(near(at('noonday', 10, 1), p.soil)).toBe(true); - expect(near(at('noonday', 220, 1), p.snow)).toBe(true); - }); - - it('soil grades to scree by height on gentle ground, halfway at the middle of the band', () => { - const p = albedoPalette(SKY.noonday); - const half = p.soil.map((v, i) => (v + p.scree[i]) / 2); - expect(near(at('noonday', (ALBEDO.screeFrom + ALBEDO.screeTo) / 2, 1), half)).toBe(true); - }); - - it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { - const l = [1, 0.8, 0.5].map((ny) => luma(at('noonday', 220, ny))); - expect(l[0]).toBeGreaterThan(l[1]); - expect(l[1]).toBeGreaterThan(l[2]); - }); - - it('is continuous: a small step in height or slope never jumps a channel by more than a tenth', () => { - for (const sky of ['midnight', 'noonday'] as const) - for (let h = 0; h < 250; h += 1) - for (let ny = 0.3; ny <= 1; ny += 0.05) { - const a = at(sky, h, ny); - const b = at(sky, h + 1, ny + 0.01); - a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(0.1)); - } - }); - - it('never returns a negative or a super-white channel', () => { - for (const sky of ['midnight', 'noonday'] as const) - for (let h = -20; h < 300; h += 7) - for (let ny = 0; ny <= 1; ny += 0.1) - for (const v of albedoAt(albedoPalette(SKY[sky]), h, facing(ny), h * 3, ny * 91)) { - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - } - }); - - it('the wobble moves band edges but stays inside its stated amplitude', () => { - for (let i = 0; i < 400; i += 1) - expect(Math.abs(wobble(i * 13.7, i * -9.1))).toBeLessThanOrEqual(1); - }); -}); - -describe('per-sky palette', () => { - const mid = albedoPalette(SKY.midnight); - const noon = albedoPalette(SKY.noonday); - - it('midnight snows lower than noonday', () => { - expect(mid.snowLine).toBeLessThan(noon.snowLine); - }); - - it('midnight is tinted toward the cool horizon haze, noonday toward the warm sun', () => { - // blue share of the same material is higher at night - const blueShare = (c: readonly number[]) => c[2] / (c[0] + c[1] + c[2]); - expect(blueShare(mid.scree)).toBeGreaterThan(blueShare(noon.scree)); - expect(blueShare(mid.snow)).toBeGreaterThan(blueShare(noon.snow)); - }); - - it('a sun at the horizon lands between the two palettes', () => { - const dusk = albedoPalette({ ...SKY.noonday, elevation: 2 }); - expect(dusk.snowLine).toBeLessThan(noon.snowLine); - expect(dusk.snowLine).toBeGreaterThan(mid.snowLine); - }); - - it('keeps every material in 0..1', () => { - for (const p of [mid, noon]) - for (const c of [p.soil, p.scree, p.rock, p.snow]) - c.forEach((v) => { - expect(v).toBeGreaterThan(0); - expect(v).toBeLessThanOrEqual(1); - }); - }); -}); - -describe('shader agrees with the pure function on its numbers', () => { - it('takes every band number from ALBEDO, and reads no texture for colour', () => { - expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); - expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); - expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); - expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); - expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); - expect(GRID_FRAG).toContain(`#define ALB_FINE_WARP ${ALBEDO.fineWarp.toFixed(1)}`); - expect(GRID_FRAG).toContain('waves3(warpq(qy),'); - // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's - expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); - }); -}); - -const UP = [0, 1, 0] as const; -const EAST = [1, 0, 0] as const; - -describe('grain', () => { - const samples = (rock: number, distance: number) => - Array.from({ length: 400 }, (_, i) => - grainAt(i * 1.7, 100 + i * 0.3, i * 0.9, UP, rock, distance) - ); - const spread = (v: number[]) => Math.max(...v) - Math.min(...v); - - it('varies the surface near the eye, never goes negative, and averages about 1', () => { - const v = samples(0, 50); - expect(spread(v)).toBeGreaterThan(0.4); - expect(Math.min(...v)).toBeGreaterThanOrEqual(0); - expect(v.reduce((a, b) => a + b, 0) / v.length).toBeGreaterThan(0.85); - }); - - it('drops its fine octave with distance, so far ground does not shimmer', () => { - const rangeOf = (d: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, 100, i * 0.21, UP, 0, d))); - expect(rangeOf(900)).toBeLessThan(rangeOf(50)); - }); - - it('layers the rock: strata show only where the face is rock', () => { - const height = (rock: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 100 + i * 0.05, 0, UP, rock, 2000))); - expect(height(1)).toBeGreaterThan(0.2); - expect(height(0)).toBeLessThan(1e-9); - }); -}); - -describe('triplanar projection', () => { - it('weights sum to 1 and go to the plane the face looks along', () => { - for (const n of [UP, EAST, [0.6, 0.6, 0.529], [0, 0, 1]] as const) { - const w = triWeights(n); - expect(w[0] + w[1] + w[2]).toBeCloseTo(1, 9); - } - expect(triWeights(UP)).toEqual([0, 1, 0]); - expect(triWeights(EAST)).toEqual([1, 0, 0]); - expect(triWeights([0.8, 0.5, 0.33])[0]).toBeGreaterThan(0.7); - }); - - it('a cliff facing east takes its pattern over (z, y), so it varies with height and not with x: no vertical smear', () => { - const col = (x: number, y: number) => grainAt(x, y, 40, EAST, 0, 50); - const alongY = Array.from({ length: 200 }, (_, i) => col(10, 50 + i * 0.7)); - const alongX = Array.from({ length: 200 }, (_, i) => col(i * 0.7, 50)); - expect(Math.max(...alongY) - Math.min(...alongY)).toBeGreaterThan(0.3); - expect(Math.max(...alongX) - Math.min(...alongX)).toBeLessThan(1e-9); - expect(wobbleAt(5, 80, 40, EAST)).toBeCloseTo(wobbleAt(500, 80, 40, EAST), 12); - expect(wobbleAt(5, 80, 40, EAST)).not.toBeCloseTo(wobbleAt(5, 120, 40, EAST), 3); - }); - - it('level ground takes its pattern over (x, z) alone, as before', () => { - expect(grainAt(12, 10, 30, UP, 0, 50)).toBeCloseTo(grainAt(12, 900, 30, UP, 0, 50), 12); - expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); - }); -}); - -describe('cavity shading', () => { - // A texel grid of heights with one bump; `hAt` is the nearest texel, `bilinearAt` the continuous reading. - const bump = (i: number, j: number) => 6 * Math.exp(-((i - 20) ** 2 + (j - 20) ** 2) / 40); - const nearestAt = (px: number, pz: number) => bump(Math.floor(px + 0.5), Math.floor(pz + 0.5)); - // Largest change in curvature between neighbouring sample positions a hundredth of a texel apart, over a few texels. - const worstStep = (read: (px: number, pz: number) => number) => { - let worst = 0; - for (let px = 14; px < 26; px += 0.01) - worst = Math.max( - worst, - Math.abs(curvatureAt(read, px + 0.01, 20.3, 4) - curvatureAt(read, px, 20.3, 4)) - ); - return worst; - }; - - it('curvature is the mean of four neighbours at the radius minus the centre', () => { - expect(curvatureAt((px, pz) => px * 2 + pz, 7, 9, 3)).toBeCloseTo(0, 12); - expect(curvatureAt((px, pz) => (px - 5) ** 2 + (pz - 5) ** 2, 5, 5, 2)).toBeCloseTo(4, 12); - }); - - it('bilinear height reads the texel exactly at a texel centre and the mean at the middle of four', () => { - const t = (i: number, j: number) => i * 10 + j * 100; - expect(bilinearHeight(t, 3, 4)).toBe(430); - expect(bilinearHeight(t, 3.5, 4.5)).toBeCloseTo(485, 9); - }); - - it('shading from a bilinear read changes smoothly across texel borders; from the nearest texel it jumps', () => { - const smooth = worstStep((px, pz) => bilinearHeight(bump, px, pz)); - const blocky = worstStep(nearestAt); - expect(blocky).toBeGreaterThan(0.1); - expect(smooth).toBeLessThan(0.02); - }); - - it('the cavity factor darkens a hollow, lights a rise and leaves flat ground alone', () => { - expect(cavityAt(0)).toBe(1); - expect(cavityAt(CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); - expect(cavityAt(-CAVITY.range)).toBeCloseTo(1 + CAVITY.light, 12); - expect(cavityAt(10 * CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); - }); - - it('the grid shader reads the cavity through the bilinear height, the same constants as CAVITY', () => { - expect(GRID_FRAG).toContain(`#define CAV_RANGE ${CAVITY.range.toFixed(1)}`); - expect(GRID_FRAG).not.toMatch(/curv[^;]*hAt\(/); - }); -}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts deleted file mode 100644 index 48dc2b42..00000000 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ /dev/null @@ -1,208 +0,0 @@ -// Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader -// (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. -// Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky -// part reads the baked sky (./terrainSky.ts): the snow line drops and every material is tinted toward the sky's colour -// as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. -import { coldness, hazeColour, SkyData, Vec3 } from './terrainSky'; - -const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); -const smooth = (a: number, b: number, x: number) => { - const t = clamp01((x - a) / (b - a)); - return t * t * (3 - 2 * t); -}; -const mix = (a: number, b: number, t: number) => a + (b - a) * t; - -/** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ -export const ALBEDO = { - /** soil fades to scree across these heights. */ - screeFrom: 80, - screeTo: 160, - /** half-width in metres of the snow line's fade. */ - snowBand: 22, - /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ - rockLo: 0.78, - rockHi: 0.95, - /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ - snowLo: 0.74, - snowHi: 0.93, - /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ - wobbleHeight: 14, - wobbleSlope: 0.05, - /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ - grainCoarse: 0.35, - grainFine: 0.3, - /** Metres the fine octave's coordinates are pushed by a slower sine, so its parallel stripes bend and do not read as hatching. */ - fineWarp: 7, - /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ - fineNear: 250, - fineFar: 700, - strata: 0.22, -} as const; - -export interface AlbedoPalette { - soil: Vec3; - scree: Vec3; - rock: Vec3; - snow: Vec3; - /** Metres where snow is half in. */ - snowLine: number; -} - -const BASE = { - soil: [0.045, 0.08, 0.02], - scree: [0.3, 0.235, 0.16], - rock: [0.075, 0.062, 0.06], - snow: [0.72, 0.75, 0.8], -} as const; - -const SNOW_LINE_DAY = 165; -const SNOW_LINE_NIGHT = 135; - -const unit = (c: Vec3): Vec3 => { - const m = Math.max(c[0], c[1], c[2], 1e-6); - return [c[0] / m, c[1] / m, c[2] / m]; -}; - -/** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ -export function albedoPalette(d: SkyData): AlbedoPalette { - const cold = coldness(d); - const sun = unit(d.direct); - const haze = unit(hazeColour(d)); - const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); - const k = mix(0.15, 0.5, cold); - const tinted = (c: Vec3): Vec3 => - [0, 1, 2].map((i) => c[i] * mix(1, tint[i], k)) as unknown as Vec3; - return { - soil: tinted(BASE.soil), - scree: tinted(BASE.scree), - rock: tinted(BASE.rock), - snow: tinted(BASE.snow), - snowLine: mix(SNOW_LINE_DAY, SNOW_LINE_NIGHT, cold), - }; -} - -/** Cheap two-sine value in -1..1 from a 2D world position; stands in for noise so no texture or hash is read. */ -export const wobble = (x: number, z: number): number => - Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); - -/** - * How much each axis-aligned projection plane counts at a surface with unit normal `n`: weights for (yz, xz, xy), summing - * to 1, sharply favouring the plane the face looks along. Procedural patterns are evaluated on all three and blended by - * these (triplanar), so a cliff is not the floor pattern smeared down it. - */ -export function triWeights(n: Vec3): Vec3 { - const w = n.map((c) => Math.abs(c) ** 4); - const sum = w[0] + w[1] + w[2] || 1; - return [w[0] / sum, w[1] / sum, w[2] / sum]; -} - -/** `wobble` blended over the three projection planes, at a surface point with unit normal `n`. */ -export function wobbleAt(x: number, y: number, z: number, n: Vec3): number { - const w = triWeights(n); - return w[0] * wobble(z, y) + w[1] * wobble(x, z) + w[2] * wobble(x, y); -} - -/** - * Linear albedo at a surface point. `y` the height in metres, `n` the unit normal, `x`/`z` world metres. - * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow - * line, thinning to nothing as the face steepens. - */ -export function albedoAt(p: AlbedoPalette, y: number, n: Vec3, x: number, z: number): Vec3 { - const w = wobbleAt(x, y, z, n); - const h = y + ALBEDO.wobbleHeight * w; - const flat = n[1] + ALBEDO.wobbleSlope * w; - const ground = (i: number) => - mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); - const snow = - smooth(p.snowLine - ALBEDO.snowBand, p.snowLine + ALBEDO.snowBand, h) * - smooth(ALBEDO.snowLo, ALBEDO.snowHi, flat); - const rock = 1 - smooth(ALBEDO.rockLo, ALBEDO.rockHi, flat); - return [0, 1, 2].map((i) => - mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) - ) as unknown as Vec3; -} - -const COARSE = [ - [0.31, 0.27], - [-0.22, 0.35], - [0.38, -0.12], -] as const; -const FINE = [ - [1.1, 0.7], - [-0.8, 1.2], - [1.3, -0.6], -] as const; -const waves = (k: ReadonlyArray, a: number, b: number) => - k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; -/** `waves` of coordinates warped by a sine each (the shader's `warpq`): three plane waves alone make a regular lattice. */ -const wavesWarped = (k: ReadonlyArray, a: number, b: number) => - waves( - k, - a + ALBEDO.fineWarp * Math.sin(b * 0.55 + 1.3), - b + ALBEDO.fineWarp * Math.sin(a * 0.47 + 0.7) - ); - -/** - * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit - * normal `n`, `rock` (0..1, how much of the face is rock) and the distance from the eye. Grain is triplanar like the - * wobble. The shader's `ALB_GRAIN_*` block does the same arithmetic. The cavity term beside it in the shader reads the - * height map and has no twin here. - */ -export function grainAt( - x: number, - y: number, - z: number, - n: Vec3, - rock: number, - distance: number -): number { - const w = triWeights(n); - const over = ( - k: ReadonlyArray, - f: typeof waves - ) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); - const coarse = over(COARSE, waves); - const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); - const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); - return Math.max( - 0, - 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers - ); -} - -/** The cavity term's numbers: curvature (metres) at which a hollow is fully shaded / a rise fully lit, and by how much. */ -export const CAVITY = { radius: 12, range: 12, dark: 0.55, light: 0.3 } as const; - -/** Height at a continuous texel position, bilinear over the four texels around it (the shader's `hBil`). */ -export function bilinearHeight( - texel: (i: number, j: number) => number, - px: number, - pz: number -): number { - const i = Math.floor(px); - const j = Math.floor(pz); - const fx = px - i; - const fz = pz - j; - return mix( - mix(texel(i, j), texel(i + 1, j), fx), - mix(texel(i, j + 1), texel(i + 1, j + 1), fx), - fz - ); -} - -/** Mean of the four neighbours `r` texels away minus the centre: positive in a hollow, negative on a rise. */ -export function curvatureAt( - height: (px: number, pz: number) => number, - px: number, - pz: number, - r: number -): number { - return ( - 0.25 * (height(px + r, pz) + height(px - r, pz) + height(px, pz + r) + height(px, pz - r)) - - height(px, pz) - ); -} - -/** The multiplier on lit colour for a curvature: a hollow sits in shade, a rise catches light (the shader's `cavity`). */ -export const cavityAt = (curv: number): number => - 1 - CAVITY.dark * clamp01(curv / CAVITY.range) + CAVITY.light * clamp01(-curv / CAVITY.range); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts deleted file mode 100644 index c153eb5f..00000000 --- a/src/app/features/scene/terrain/terrainScatter.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* eslint-disable no-restricted-syntax -- tests walk table texels */ -import { describe, expect, it } from 'vitest'; -import { baked } from '../../../../sky/baked'; -import { - aerialRay, - AP_ELEVATIONS, - AP_SLICES, - apElevation, - apSliceKm, - decode, - SUN_ELEVATIONS_DEG, - viewElevation, -} from '../../../../sky/scatter.js'; -import { - AERIAL_GLSL, - aerialAt, - AP_VOLUME, - apVolumeWords, - SCATTER_GLSL, - scatterRadiance, - skyViewWords, - SKY_VIEW, - tableWords, - transmittanceAt, -} from './terrainScatter'; - -const S = baked.scatter.sky_view; - -describe('the table upload', () => { - it('decodes the baked base64 to one half-float word per channel per texel', () => { - const w = skyViewWords(); - expect(w.length).toBe(S.width * S.height * S.depth * S.channels); - expect(SKY_VIEW).toEqual({ width: S.width, height: S.height, depth: S.depth }); - expect(tableWords(baked.scatter.transmittance.data)).toHaveLength(32 * 3); - }); - - it('words are little-endian: the base64 bytes lo, hi make a word hi << 8 | lo', () => { - expect(Array.from(tableWords(btoa(String.fromCharCode(0x34, 0x12, 0xff, 0x7b))))).toEqual([ - 0x1234, 0x7bff, - ]); - }); -}); - -describe('scatterRadiance (the CPU twin of the sampler)', () => { - it('returns the stored texel exactly at a texel centre: layout x azimuth, y view, z sun', () => { - const w = skyViewWords(); - for (const [z, y, x] of [ - [10, 31, 0], - [8, 5, 3], - [5, 20, 6], - [11, 0, 2], - ]) { - const base = ((z * S.height + y) * S.width + x) * 3; - const got = scatterRadiance( - S.sun_elevations_deg[z], - viewElevation(y), - x * S.azimuth_step_deg - ); - got.forEach((v, c) => expect(v).toBeCloseTo(decode(w[base + c]), 9)); - } - }); - - it('interpolates between texels and clamps beyond the table', () => { - const a = scatterRadiance(25, 10, 0)[2]; - const b = scatterRadiance(45, 10, 0)[2]; - const mid = scatterRadiance(35, 10, 0)[2]; // 35 is halfway between 25 and 45 on the sun axis - expect(mid).toBeCloseTo((a + b) / 2, 9); - expect(scatterRadiance(500, 10, 400)).toEqual(scatterRadiance(90, 10, 180)); - expect(scatterRadiance(30, 10, -90)).toEqual(scatterRadiance(30, 10, 90)); - }); - - it('is black under the planet’s shadow limit and blue at a high noon zenith', () => { - scatterRadiance(-18, 30, 0).forEach((v) => expect(v).toBeLessThan(1e-6)); - const noon = scatterRadiance(45, 85, 90); - expect(noon[2]).toBeGreaterThan(1.5 * noon[0]); - }); -}); - -describe('transmittanceAt', () => { - it('is the zenith transmittance exp(-TAU) overhead and redder at the horizon', () => { - const z = transmittanceAt(90); - [0.06, 0.12, 0.26].forEach((tau, i) => expect(z[i]).toBeCloseTo(Math.exp(-tau), 2)); - const h = transmittanceAt(2); - expect(h[2] / h[0]).toBeLessThan(0.5 * (z[2] / z[0])); - }); - - it('is zero where the ground is in the way', () => { - expect(transmittanceAt(-4)).toEqual([0, 0, 0]); - }); -}); - -describe('the GLSL sampler', () => { - it('uses the layout the README documents: texel-centre coordinates over a 7 x 32 x 12 texture, decoded by the store scale', () => { - expect(SCATTER_GLSL).toContain('(x + 0.5) / 7.0'); - expect(SCATTER_GLSL).toContain('(y + 0.5) / 32.0'); - expect(SCATTER_GLSL).toContain('(uSunIndex + 0.5) / 12.0'); - expect(SCATTER_GLSL).toContain('/ 64.0'); - expect(SCATTER_GLSL).toContain('sqrt(clamp((el - (-4.0)) / 94.0, 0.0, 1.0)) * 31.0'); - expect(SCATTER_GLSL).toContain('* 6.0'); - }); -}); - -describe('the aerial perspective volume as the terrain reads it', () => { - it('uploads RGBA half floats in the baked layout', () => { - const a = baked.scatter.aerial_perspective; - expect(apVolumeWords()).toHaveLength(a.width * a.height * a.depth * 4); - expect(AP_VOLUME).toEqual({ width: a.width, height: a.height, depth: a.depth }); - }); - - it('on a texel centre the CPU twin returns the baked ray exactly (to half-float precision)', () => { - const sunI = 8; - const ei = 5; - const slice = 6; - const want = aerialRay(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90)[slice]; - const got = aerialAt(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90, apSliceKm(slice)); - for (let c = 0; c < 3; c += 1) expect(got.L[c]).toBeCloseTo(want[c], 3); - expect(got.alpha).toBeCloseTo(want[3], 3); - }); - - it('between texels it is near the model: the filtering error is small against the signal', () => { - const want = aerialRay(10, -1.3, 75)[AP_SLICES - 2]; - const got = aerialAt(10, -1.3, 75, apSliceKm(AP_SLICES - 2)); - expect(Math.abs(got.alpha - want[3])).toBeLessThan(0.03); - expect(got.L[0] / want[0]).toBeGreaterThan(0.6); - expect(got.L[0] / want[0]).toBeLessThan(1.6); - }); - - it('fades to nothing at the observer, and clamps past the last slice', () => { - expect(aerialAt(45, -2, 90, 0)).toEqual({ L: [0, 0, 0], alpha: 0 }); - const last = aerialAt(45, -2, 90, apSliceKm(AP_SLICES - 1)); - const beyond = aerialAt(45, -2, 90, 1e6); - expect(beyond.alpha).toBeCloseTo(last.alpha, 9); - }); - - it('the shader lookup carries the same constants as the table', () => { - const a = baked.scatter.aerial_perspective; - expect(AERIAL_GLSL).toContain(`/ ${a.height}.0`); - expect(AERIAL_GLSL).toContain(`${a.max_km.toFixed(1)}`); - expect(AERIAL_GLSL).toContain(`${AP_ELEVATIONS}.0 +`); - }); -}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts deleted file mode 100644 index 72ebfb5c..00000000 --- a/src/app/features/scene/terrain/terrainScatter.ts +++ /dev/null @@ -1,179 +0,0 @@ -// The baked scattering tables (src/sky/baked.ts `scatter`, layout in src/sky/README.md) as the terrain scene -// uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and -// for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. -import { baked } from '../../../../sky/baked'; -import { - AP_DISTANCE_SCALE, - AP_ELEVATIONS, - AP_SLICES, - apElevationIndex, - apSliceIndex, - decode, - sunIndex, - viewIndex, -} from '../../../../sky/scatter.js'; -import type { Vec3 } from './terrainSky'; - -const S = baked.scatter; -const AP = S.aerial_perspective; -const SKY = S.sky_view; -const VIEW = S.view_elevation_deg; - -/** Raw little-endian half-float words of a base64 table, ready for `texImage3D(..., HALF_FLOAT, words)`. */ -export function tableWords(b64: string): Uint16Array { - const bytes = atob(b64); - return Uint16Array.from( - { length: bytes.length / 2 }, - (_, i) => bytes.charCodeAt(2 * i) + 256 * bytes.charCodeAt(2 * i + 1) - ); -} - -export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth } as const; -export const STORE_SCALE = S.store_scale; -export const skyViewWords = (): Uint16Array => tableWords(SKY.data); - -/** The aerial perspective volume (layout in src/sky/scatter.js): RGBA16F, x azimuth from the light, y slice-major block of elevations, z light elevation. */ -export const AP_VOLUME = { width: AP.width, height: AP.height, depth: AP.depth } as const; -export const apVolumeWords = (): Uint16Array => tableWords(AP.data); -export { AP_DISTANCE_SCALE }; - -/** Decode a stored table to values, once. */ -const decoded = (data: string) => { - let values: Float32Array | undefined; - return (): Float32Array => { - if (!values) values = Float32Array.from(tableWords(data), decode); - return values; - }; -}; -const transmittanceTable = decoded(S.transmittance.data); -const skyTable = decoded(SKY.data); -const apTable = decoded(AP.data); - -/** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ -export function transmittanceAt(elevationDeg: number): Vec3 { - const t = transmittanceTable(); - const f = Math.min(viewIndex(elevationDeg), VIEW.samples - 1); - const i = Math.min(Math.floor(f), VIEW.samples - 2); - const k = f - i; - return [0, 1, 2].map((c) => t[i * 3 + c] * (1 - k) + t[(i + 1) * 3 + c] * k) as unknown as Vec3; -} - -/** - * Sky radiance per steradian for a sun of unit irradiance, trilinear in (azimuth from the sun, view - * elevation, sun elevation) on texel centres, clamped at the edges: what the GPU's LINEAR sampler returns. - */ -export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDeg: number): Vec3 { - const t = skyTable(); - const at = [ - Math.min(Math.abs(azFromSunDeg) / 180, 1) * (SKY.width - 1), - viewIndex(viewElDeg), - sunIndex(sunElDeg), - ]; - const lo = at.map((v, a) => Math.min(Math.floor(v), [SKY.width, SKY.height, SKY.depth][a] - 2)); - const f = at.map((v, a) => v - lo[a]); - const out = [0, 0, 0]; - for (let corner = 0; corner < 8; corner += 1) { - const dx = corner % 2; - const dy = Math.floor(corner / 2) % 2; - const dz = Math.floor(corner / 4); - const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); - const base = (((lo[2] + dz) * SKY.height + lo[1] + dy) * SKY.width + lo[0] + dx) * 3; - for (let c = 0; c < 3; c += 1) out[c] += w * t[base + c]; - } - return [out[0], out[1], out[2]]; -} - -/** - * Aerial perspective along a view ray: in-scatter luminance per channel (for a light of unit irradiance) and opacity - * (1 - mean transmittance), for a light at `lightElDeg`, a ray `viewElDeg` (0 and below) and `azDeg` from the light, - * `km` of baked path along it. Trilinear in (azimuth, elevation, light elevation) and linear between slices, the arithmetic the - * shader's `aerial()` does on the GPU; below the first slice it fades to nothing at the observer (Hillaire's near-slice fade). - */ -export function aerialAt( - lightElDeg: number, - viewElDeg: number, - azDeg: number, - km: number -): { L: Vec3; alpha: number } { - const t = apTable(); - const x = Math.min(Math.abs(azDeg) / 180, 1) * (AP.width - 1); - const e = apElevationIndex(viewElDeg); - const z = sunIndex(lightElDeg); - const texel = (slice: number): number[] => { - const lo = [ - Math.min(Math.floor(x), AP.width - 2), - Math.min(Math.floor(e), AP_ELEVATIONS - 2), - Math.min(Math.floor(z), AP.depth - 2), - ]; - const f = [x - lo[0], e - lo[1], z - lo[2]]; - const out = [0, 0, 0, 0]; - for (let corner = 0; corner < 8; corner += 1) { - const dx = corner % 2; - const dy = Math.floor(corner / 2) % 2; - const dz = Math.floor(corner / 4); - const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); - const base = - (((lo[2] + dz) * AP.height + slice * AP_ELEVATIONS + lo[1] + dy) * AP.width + lo[0] + dx) * - 4; - for (let c = 0; c < 4; c += 1) out[c] += w * t[base + c]; - } - return out; - }; - const f = apSliceIndex(km); - const fs = Math.min(Math.max(f, 0), AP_SLICES - 1); - const k = Math.min(Math.floor(fs), AP_SLICES - 2); - const a = texel(k); - const b = texel(k + 1); - const fade = Math.min(Math.max(f + 1, 0), 1); - const v = [0, 1, 2, 3].map((c) => (a[c] + (b[c] - a[c]) * (fs - k)) * fade); - return { L: [v[0], v[1], v[2]], alpha: v[3] }; -} - -/** - * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree - * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the - * terrain's haze both take the colour here, flat to the horizon, and meet without a seam. - */ -export const HORIZON_FLOOR = 1.5; - -/** The sky shader's lookup, in GLSL: `skyScatter(viewDirection)` is unit-sun radiance, needs uSky, uSunIndex, uSun. */ -export const SCATTER_GLSL = ` -uniform highp sampler3D uSky; -uniform float uSunIndex; -uniform vec3 uSun; -vec3 skyScatter(vec3 d) { - float el = max(degrees(asin(clamp(d.y, -1.0, 1.0))), ${HORIZON_FLOOR.toFixed(1)}); - vec2 h = d.xz, s = uSun.xz; - float az = (length(h) < 1e-4 || length(s) < 1e-4) ? 0.0 - : degrees(acos(clamp(dot(normalize(h), normalize(s)), -1.0, 1.0))); - float y = sqrt(clamp((el - (${VIEW.min.toFixed(1)})) / ${(VIEW.max - VIEW.min).toFixed( - 1 -)}, 0.0, 1.0)) * ${(VIEW.samples - 1).toFixed(1)}; - float x = clamp(az / 180.0, 0.0, 1.0) * ${(SKY.width - 1).toFixed(1)}; - return texture(uSky, vec3((x + 0.5) / ${SKY.width}.0, (y + 0.5) / ${ - SKY.height -}.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; -}`; - -/** - * The aerial perspective lookup, in GLSL: `aerial(lightIndex, viewElDeg, azDeg, km)` is `aerialAt` on the GPU. Needs uAP. - * Each slice is one hardware-filtered fetch, kept inside its block of elevation rows so the filter never reads a - * neighbouring slice; two fetches and a lerp per call. - */ -export const AERIAL_GLSL = ` -uniform highp sampler3D uAP; -vec4 apSlice(float k, float e, float x, float z) { - return texture(uAP, vec3((x + 0.5) / ${AP.width}.0, (k * ${AP.elevations}.0 + e + 0.5) / ${ - AP.height -}.0, (z + 0.5) / ${AP.depth}.0)) / ${S.store_scale.toFixed(1)}; -} -vec4 aerial(float lightIndex, float viewElDeg, float azDeg, float km) { - float e = ${(AP.elevations - 1).toFixed( - 1 - )} * (1.0 - sqrt(clamp(-viewElDeg / ${(-AP.elevation_min_deg).toFixed(1)}, 0.0, 1.0))); - float x = clamp(abs(azDeg) / 180.0, 0.0, 1.0) * ${(AP.width - 1).toFixed(1)}; - float f = ${AP.slices}.0 * sqrt(max(km, 0.0) / ${AP.max_km.toFixed(1)}) - 1.0; - float fs = clamp(f, 0.0, ${(AP.slices - 1).toFixed(1)}); - float k = min(floor(fs), ${(AP.slices - 2).toFixed(1)}); - return mix(apSlice(k, e, x, lightIndex), apSlice(k + 1.0, e, x, lightIndex), fs - k) * clamp(f + 1.0, 0.0, 1.0); -}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts deleted file mode 100644 index f6bcce37..00000000 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ /dev/null @@ -1,255 +0,0 @@ -// GLSL ES 3.00. The height map is baked once by CultMath's Phacelle noise (vendored, unedited, MPL-2.0: Rune -// Skovbo Johansen's Phacelle noise, https://blog.runevision.com/2026/01/) composed into a slope-directed gully -// filter after the description in his March 2026 erosion-filter post. The composition is ours, written from -// that description; it is not his filter and has not been compared against it (see harness terrainDemo.ts on -// the terrain-tier-spike branch, from which this is promoted). The height is packed in two bytes of an RGBA8 -// target, so no float colour-buffer extension is needed. -import cultmath from '../shaders/CultMath.glsl?raw'; -import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; -import { trimGlsl } from '../cultmathTrim'; -import { LUMA } from '../skyBand'; -import { ALBEDO, CAVITY } from './terrainAlbedo'; -import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; -import { TERRAIN } from './terrainWorld'; - -const HEADER = - '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; -const f1 = (x: number) => x.toFixed(1); -const lit = (x: number) => (Number.isInteger(x) ? x.toFixed(1) : String(x)); - -export const FULLSCREEN_VERT = `${HEADER} -void main() { - vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -}`; - -const HEIGHT_STAGE = ` -uniform float uSpan; -uniform vec2 uSeed; -uniform float uTexels; -out vec4 o; - -const float BASE_AMP = 60.0; -const float BASE_FREQ = 1.0 / 520.0; -const float BASE_LIFT = 110.0; -const float CELL0 = 160.0; -const float SIDE_LEN = 3.0; -const float NORM = 0.5; -const float SLOPE_REF = 0.55; -const float DETAIL = 1.5; -const float AMP0 = 11.0; -const float HLO = 20.0, HHI = 200.0; - -float easeOut(float t) { t = clamp(t, 0.0, 1.0); return 1.0 - (1.0 - t) * (1.0 - t); } -float powInv(float t, float p) { return 1.0 - pow(1.0 - t, p); } - -float height(vec2 xz) { - vec4 fg = cultmath_fbm_grad(vec3(xz * BASE_FREQ, 0.0), 4, 2.0, 0.45); - float h = BASE_LIFT + BASE_AMP * fg.w; - vec2 g = BASE_AMP * BASE_FREQ * fg.xy; - float target = clamp((h - HLO) / (HHI - HLO) * 2.0 - 1.0, -1.0, 1.0); - float combi = 1.0; - float amp = AMP0; - float cell = CELL0; - for (int i = 0; i < OCTAVES; i++) { - float slope = length(g); - vec2 downhill = slope > 1e-4 ? -g / slope : vec2(1.0, 0.0); - vec2 across = vec2(-downhill.y, downhill.x); - CultPhasor ph = cultmath_phacelle(vec3(xz / cell, 0.5), vec3(across * SIDE_LEN, 0.0), 0.0, NORM); - float mask = easeOut(slope / SLOPE_REF) * combi; - float faded = mix(target, ph.cos.w, mask); - h += amp * faded; - g += amp * mask * ph.cos.xy / cell; - combi = powInv(combi, DETAIL) * (1.0 - abs(ph.sin.w)); - target = faded; - amp *= 0.5; - cell *= 0.5; - } - return h; -} - -void main() { - vec2 uv = gl_FragCoord.xy / vec2(uTexels); - float h = clamp(height((uv - 0.5) * uSpan + uSeed) / ${f1(TERRAIN.hmax)}, 0.0, 1.0); - float q = floor(h * 65535.0 + 0.5); - o = vec4(floor(q / 256.0) / 255.0, mod(q, 256.0) / 255.0, 0.0, 1.0); -}`; - -/** The height-bake fragment shader: header, the part of CultMath that Phacelle and the stage reach, Phacelle, the stage. */ -export function heightFragmentSource(): string { - const math = trimGlsl(cultmath, [phacelle, HEIGHT_STAGE]); - return `${HEADER}#define OCTAVES 4\n${math}\n${phacelle}\n${HEIGHT_STAGE}`; -} - -const FETCH = ` -uniform sampler2D uHeight; -uniform int uTex; -float hAt(ivec2 t) { - t = clamp(t, ivec2(0), ivec2(uTex - 1)); - vec4 s = texelFetch(uHeight, t, 0); - return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( - TERRAIN.hmax - )}; -} -// The height at a point in texel units, bilinear over the four texels around it (the packed texture cannot be filtered by the GPU). -float hBil(vec2 p) { - ivec2 i = ivec2(floor(p)); - vec2 f = fract(p); - return mix(mix(hAt(i), hAt(i + ivec2(1, 0)), f.x), mix(hAt(i + ivec2(0, 1)), hAt(i + ivec2(1, 1)), f.x), f.y); -}`; - -export const GRID_VERT = `${HEADER} -layout(location = 0) in vec2 a_uv; -uniform mat4 uViewProj; -uniform float uSpan; -out vec2 v_uv; -out vec3 v_world; -${FETCH} -void main() { - // The grid is packed toward the centre, where the camera stands (terrainWorld.warpUv): s = 2u - 1, 0.5 + 0.5 (0.5 s + 0.5 s |s|). - vec2 s = a_uv * 2.0 - 1.0; - vec2 uv = 0.5 + 0.5 * (0.5 * s + 0.5 * s * abs(s)); - // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. - float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(uv.x - 0.5), abs(uv.y - 0.5))); - float h = hBil(uv * float(uTex - 1)) * rim; - v_uv = uv; - v_world = vec3((uv.x - 0.5) * uSpan, h, (uv.y - 0.5) * uSpan); - gl_Position = uViewProj * vec4(v_world, 1.0); -}`; - -// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient; hazed toward the -// scattered sky at the horizon in the ray's azimuth (SKY_COLOUR_GLSL). -// The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. -const ALBEDO_DEFINES = ` -#define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} -#define ALB_SCREE_TO ${lit(ALBEDO.screeTo)} -#define ALB_SNOW_BAND ${lit(ALBEDO.snowBand)} -#define ALB_ROCK_HI ${lit(ALBEDO.rockHi)} -#define ALB_ROCK_LO ${lit(ALBEDO.rockLo)} -#define ALB_SNOW_HI ${lit(ALBEDO.snowHi)} -#define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} -#define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} -#define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} -#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} -#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} -#define ALB_FINE_WARP ${lit(ALBEDO.fineWarp)} -#define ALB_STRATA ${lit(ALBEDO.strata)} -#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} -#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} -#define CAV_RADIUS ${lit(CAVITY.radius)} -#define CAV_RANGE ${lit(CAVITY.range)} -#define CAV_DARK ${lit(CAVITY.dark)} -#define CAV_LIGHT ${lit(CAVITY.light)} -`; -// The GLSL twin of skyBand.shapeToBand: clamp to 0..1, mix toward uLift to land on the floor uBand.x, or scale down to the ceiling uBand.y. -// No band is uBand = (0, 1). -export const SHAPE_SKY_GLSL = ` -uniform vec2 uBand; -uniform vec3 uLift; -const vec3 LUMA = vec3(${LUMA.map(lit).join(', ')}); -vec3 shapeSky(vec3 c) { - c = clamp(c, 0.0, 1.0); - float l = dot(c, LUMA); - if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l)); - if (l > uBand.y) return c * (uBand.y / l); - return c; -}`; -// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow, shaped into -// the state's luminance band. The same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already -// scaled by how dark the sun is. -const SKY_COLOUR_GLSL = `${SCATTER_GLSL}${SHAPE_SKY_GLSL} -uniform vec3 uGlow; -vec3 skyColour(vec3 d) { - return shapeSky(1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( - GLOW_ZENITH -)}, sqrt(clamp(d.y, 0.0, 1.0)))); -}`; - -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} -in vec2 v_uv; -in vec3 v_world; -uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; -uniform float uSpan, uSnowLine, uNight, uMoonIndex; -out vec4 o; -${FETCH} -// Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). -float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } -float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } -// The fine octave's coordinates pushed by a slower sine each (terrainAlbedo wavesWarped): three plane waves alone make a regular lattice. -vec2 warpq(vec2 q) { return q + ALB_FINE_WARP * vec2(sin(q.y * 0.55 + 1.3), sin(q.x * 0.47 + 0.7)); } -void main() { - // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. - vec2 p = v_uv * float(uTex - 1); - float texel = uSpan / float(uTex - 1); - float dx = hBil(p + vec2(1.5, 0.0)) - hBil(p - vec2(1.5, 0.0)); - float dz = hBil(p + vec2(0.0, 1.5)) - hBil(p - vec2(0.0, 1.5)); - vec3 n = normalize(vec3(-dx, 3.0 * texel, -dz)); - vec3 tw = pow(abs(n), vec3(4.0)); - tw /= tw.x + tw.y + tw.z; - vec2 qx = v_world.zy, qy = v_world.xz, qz = v_world.xy; - // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). - float w = tw.x * wob2(qx) + tw.y * wob2(qy) + tw.z * wob2(qz); - float h = v_world.y + ALB_WOBBLE_H * w; - float flat_ = n.y + ALB_WOBBLE_S * w; - vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); - float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); - float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); - vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); - // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). - float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); - float fine = tw.x * waves3(warpq(qx), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(warpq(qy), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(warpq(qz), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); - float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); - float layers = sin(v_world.y * 0.9 + w * 3.0); - albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); - // Cavity: a point lower than its surroundings sits in shade, one higher catches light (terrainAlbedo.cavityAt). Shader-only, from the height map, - // read bilinear at a continuous position so it does not snap to a texel (that made the shading blocks of one texel, 3 m). - float cr = CAV_RADIUS / texel; - float curv = 0.25 * (hBil(p + vec2(cr, 0.0)) + hBil(p - vec2(cr, 0.0)) + hBil(p + vec2(0.0, cr)) + hBil(p - vec2(0.0, cr))) - hBil(p); - float cavity = 1.0 - CAV_DARK * clamp(curv / CAV_RANGE, 0.0, 1.0) + CAV_LIGHT * clamp(-curv / CAV_RANGE, 0.0, 1.0); - vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); - vec3 toSurface = v_world - uCamera; - float dist = length(toSurface); - // Aerial perspective (terrainSky.aerialPerspective): the baked volume by this ray's elevation, azimuth from the light and path. - vec3 ray = toSurface / dist; - float el = min(degrees(asin(clamp(ray.y, -1.0, 1.0))), 0.0); - vec2 hz = ray.xz, sz = uSun.xz; - float az = (length(hz) < 1e-4 || length(sz) < 1e-4) ? 0.0 : degrees(acos(clamp(dot(normalize(hz), normalize(sz)), -1.0, 1.0))); - float km = dist * ${lit(AP_DISTANCE_SCALE / 1000)}; - vec4 ap = aerial(uSunIndex, el, az, km); - vec3 L = ap.rgb; - // The moon lights the haze from where the sun is mirrored above the horizon, sharing its azimuth; a starlight floor stays. - if (uNight > 0.0) L += uNight * vec3(${MOON_HAZE.map(lit).join( - ', ' - )}) * aerial(uMoonIndex, el, az, km).rgb; - vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( - lit -).join(', ')}); - // Past SKY_FADE the surface is the sky at the horizon in this ray's azimuth, the colour the sky pass draws there (terrainSky.aerialPerspective). - float toSky = smoothstep(${lit(SKY_FADE[0])}, ${lit(SKY_FADE[1])}, dist); - vec3 horizon = skyColour(vec3(ray.x, 0.0, ray.z)); - o = vec4(pow(mix(lit * (1.0 - ap.a) + haze, horizon, toSky), vec3(1.0 / 2.2)), 1.0); -}`; - -// The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a -// dither of +-0.5/255 on the 8-bit output so the dusk glow does not band. -const DISC = { - inner: Math.cos((0.5 * Math.PI) / 180), - outer: Math.cos((1.1 * Math.PI) / 180), - gain: 6.0, -}; -export const SKY_FRAG = `${HEADER}${SKY_COLOUR_GLSL} -uniform vec3 uSunColour; -uniform vec3 uRight, uUp, uForward; -uniform vec2 uSize; -uniform float uTanHalf; -out vec4 o; -void main() { - vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; - vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); - vec3 c = skyColour(d) + uSunColour * ${lit(DISC.gain)} * smoothstep(${DISC.outer}, ${ - DISC.inner -}, dot(d, uSun)); - float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5; - o = vec4(pow(c, vec3(1.0 / 2.2)) + dither / 255.0, 1.0); -}`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts deleted file mode 100644 index f409471a..00000000 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ -import { AP_DISTANCE_SCALE } from './terrainScatter'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { baked } from '../../../../sky/baked'; -import { - chooseSky, - coldness, - glowColour, - hazeColour, - hexToLinear, - nightness, - shade, - SKY, - skyAtSun, - skyData, - skyColour, - sunDirection, - aerialPerspective, - SKY_FADE, - metresToKm, - groundLight, - HAZE_K, - HAZE_POWER, - START_TURNS, - Vec3, -} from './terrainSky'; - -const UP = [0, 1, 0] as const; -const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; -/** Unit view direction at an elevation (degrees) and an azimuth clockwise from north. */ -const look = (el: number, az: number): Vec3 => { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; -}; - -describe('the baked sky is read, not copied', () => { - it('SKY carries Void’s numbers: sun, ambient, horizon', () => { - for (const name of ['midnight', 'noonday'] as const) { - const b = baked[name]; - expect(SKY[name].elevation).toBe(b.sun.elevation_deg); - expect(SKY[name].azimuth).toBe(b.sun.azimuth_deg); - expect(SKY[name].direct).toEqual(b.sun.color_linear); - expect(SKY[name].ambient).toEqual(b.ambient_linear); - expect(SKY[name].horizon).toEqual(hexToLinear(b.horizon_hex)); - } - }); - - it('keeps the two moments apart: night sun below the horizon, noon sun well above', () => { - expect(SKY.midnight.elevation).toBeLessThan(0); - expect(SKY.noonday.elevation).toBeGreaterThan(30); - }); - - it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { - for (const d of Object.values(SKY)) { - const s = sunDirection(d); - expect(Math.hypot(...s)).toBeCloseTo(1, 9); - expect(s[1]).toBeCloseTo(Math.sin((d.elevation * Math.PI) / 180), 9); - } - expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 0 })[2]).toBeCloseTo(-1, 9); - expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 180 })[2]).toBeCloseTo(1, 9); - }); - - it('hexToLinear decodes sRGB', () => { - expect(hexToLinear('#000000')).toEqual([0, 0, 0]); - expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); - expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); - }); -}); - -describe('a sun at any elevation (skyAtSun)', () => { - it('lights nothing under the horizon and fades in across the last degrees, as sun.js does', () => { - expect(skyAtSun(-2).direct).toEqual([0, 0, 0]); - expect(skyAtSun(-30).direct).toEqual([0, 0, 0]); - const at = (e: number) => luma(skyAtSun(e).direct); - expect(at(0.3)).toBeGreaterThan(0); - expect(at(0.3)).toBeLessThan(at(1.5)); - expect(at(1.5)).toBeLessThan(at(60)); - }); - - it('agrees with the baked noon sun colour where they overlap, to a couple of percent', () => { - const want = SKY.noonday.direct; - const got = skyAtSun(SKY.noonday.elevation).direct; - got.forEach((v, i) => expect(Math.abs(v / want[i] - 1)).toBeLessThan(0.02)); - }); - - it('the sun goes red as it sets: less blue left in the direct light at 2 degrees than at 45', () => { - const lo = skyAtSun(2).direct; - const hi = skyAtSun(45).direct; - expect(lo[2] / lo[0]).toBeLessThan(0.6 * (hi[2] / hi[0])); - }); - - it('ambient and horizon run from the midnight bake to the noonday one as the sun climbs', () => { - expect(luma(skyAtSun(-30).ambient)).toBeCloseTo(luma(SKY.midnight.ambient), 9); - expect(luma(skyAtSun(60).ambient)).toBeCloseTo(luma(SKY.noonday.ambient), 9); - expect(luma(skyAtSun(5).ambient)).toBeGreaterThan(luma(SKY.midnight.ambient)); - expect(luma(skyAtSun(5).ambient)).toBeLessThan(luma(SKY.noonday.ambient)); - expect(skyAtSun(10, 270).azimuth).toBe(270); - }); -}); - -describe('lighting', () => { - it('noonday lights an upward face with the sun; midnight has none', () => { - expect(luma(SKY.noonday.direct)).toBeGreaterThan(0.5); - expect(SKY.midnight.direct).toEqual([0, 0, 0]); - }); - - it('shade never goes negative on any normal at either sky, at any distance', () => { - for (const d of [...Object.values(SKY), skyAtSun(-3), skyAtSun(1)]) - for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) - for (const dist of [0, 100, 5000]) - for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist, look(-5, 40))) - expect(ch).toBeGreaterThanOrEqual(0); - }); - - it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { - const toSun = sunDirection(SKY.noonday); - const away = [-toSun[0], 0, -toSun[2]] as const; - const view = look(-5, 0); - const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0, view)); - const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0, view)); - expect(lit).toBeGreaterThan(3 * dark); - const m = (n: readonly [number, number, number]) => - shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); - expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black - expect(luma(m(UP))).toBeLessThan(0.12); - }); - - it('noon is much brighter than midnight, ambient included', () => { - expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); - const view = look(-5, 0); - expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( - 3 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) - ); - }); - - it('the cold tint (albedo) is 0 with the sun up and 1 once it is below -6', () => { - expect(coldness(SKY.noonday)).toBe(0); - expect(coldness(SKY.midnight)).toBe(1); - expect(hazeColour(SKY.noonday)).toEqual(SKY.noonday.horizon); - }); -}); - -describe('the scattered sky', () => { - it('noon is blue overhead: more blue than red, much brighter than the night glow', () => { - const zenith = skyColour(SKY.noonday, [0, 1, 0]); - expect(zenith[2]).toBeGreaterThan(1.5 * zenith[0]); - expect(luma(zenith)).toBeGreaterThan(5 * luma(skyColour(SKY.midnight, [0, 1, 0]))); - zenith.forEach((v) => expect(v).toBeLessThan(1)); - }); - - it('a sun at the horizon reddens the sky toward it and darkens it away', () => { - const dusk = skyAtSun(-1, 270); - const toward = skyColour(dusk, look(3, 270)); - const away = skyColour(dusk, look(3, 90)); - const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); - expect(blueShare(toward)).toBeLessThan(blueShare(away)); - expect(luma(toward)).toBeGreaterThan(3 * luma(away)); - }); - - it('under a deep night sun the tables are black and only the glow remains, brightest at the horizon', () => { - const d = SKY.midnight; - const horizon = skyColour(d, look(0, 180)); - const zenith = skyColour(d, [0, 1, 0]); - horizon.forEach((v, i) => expect(v).toBeCloseTo(d.horizon[i], 6)); - expect(luma(zenith)).toBeLessThan(luma(horizon)); - expect(luma(zenith)).toBeGreaterThan(0.5 * luma(horizon)); - }); - - it('the night glow fades out as the sun reaches the horizon', () => { - expect(nightness(-10)).toBe(1); - expect(nightness(5)).toBe(0); - expect(glowColour(SKY.noonday)).toEqual([0, 0, 0]); - expect(glowColour(SKY.midnight)).toEqual(SKY.midnight.horizon); - expect(luma(glowColour(skyAtSun(-2)))).toBeLessThan(luma(glowColour(skyAtSun(-6)))); - }); - - it('is continuous in the sun’s elevation through dusk: no step between neighbours', () => { - const at = (e: number) => luma(skyColour(skyAtSun(e, 270), look(10, 200))); - for (let e = -20; e < 30; e += 0.5) { - expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.05); - } - }); -}); - -describe('aerial perspective', () => { - const ray = (el: number, az: number): Vec3 => look(el, az); - const along = (d: typeof SKY.midnight, el: number, az: number, m: number) => - aerialPerspective(d, ray(el, az), m); - - it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { - let prev = along(SKY.noonday, -2, 200, 0).alpha; - expect(prev).toBe(0); - for (const m of [10, 100, 400, 800, 1000, 1300]) { - const a = along(SKY.noonday, -2, 200, m).alpha; - expect(a).toBeGreaterThan(prev); - expect(a).toBeLessThanOrEqual(1); - prev = a; - } - }); - - it('leaves the near ground its own colour and takes the far ridges', () => { - expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); - expect(along(SKY.noonday, -2, 200, SKY_FADE[0]).alpha).toBeGreaterThan(0.1); - }); - - it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { - const far = along(SKY.noonday, -2, 90, SKY_FADE[0]).colour; - expect(far[2]).toBeGreaterThan(far[0]); - }); - - it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { - const dusk = skyAtSun(2, 270); - const toward = along(dusk, -1, 270, SKY_FADE[0]).colour; - const away = along(dusk, -1, 90, SKY_FADE[0]).colour; - expect(luma(toward)).toBeGreaterThan(3 * luma(away)); - expect(toward[0]).toBeGreaterThan(toward[2]); - }); - - it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { - const c = along(SKY.midnight, -2, 0, SKY_FADE[0]).colour; - expect(luma(c)).toBeGreaterThan(0.0003); - expect(luma(c)).toBeLessThan(0.08); - expect(c[2]).toBeGreaterThan(2.5 * c[0]); - expect(luma(along(SKY.noonday, -2, 0, SKY_FADE[0]).colour)).toBeGreaterThan(5 * luma(c)); - }); - - it('moonlight lights the night haze toward the moon more than away from it', () => { - const moon = groundLight(SKY.midnight).dir; - const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], SKY_FADE[0]).colour; - const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], SKY_FADE[0]).colour; - expect(luma(toward)).toBeGreaterThan(luma(away)); - }); - - it('with distance a dark surface rises toward the haze and its own colour is lost', () => { - const d = SKY.noonday; - const view = ray(-4, 200); - const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(SKY_FADE[0])).toBeGreaterThan(dark(100)); - const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; - expect(own(SKY_FADE[0])).toBeLessThan(0.9 * own(100)); - }); - - it('is continuous in the sun elevation through dusk: no step between neighbours', () => { - const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, SKY_FADE[0]).colour); - for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); - }); - - it('far terrain is the sky: past the fade it is the horizon sky colour in its azimuth, by day, dusk and night, whatever it was', () => { - for (const [d, az] of [ - [SKY.midnight, 0], - [skyAtSun(-1, 90), 90], - [skyAtSun(2, 270), 200], - [SKY.noonday, 120], - ] as const) { - const view = ray(-0.5, az); - const hz = skyColour(d, [view[0], 0, view[2]]); - const far = aerialPerspective(d, view, SKY_FADE[1]); - expect(far.alpha).toBe(1); - for (let i = 0; i < 3; i += 1) expect(far.colour[i]).toBeCloseTo(hz[i], 9); - expect(shade(d, [0.5, 0.5, 0.5], UP, SKY_FADE[1] + 500, view)).toEqual(far.colour); - } - }); - - it('the metres-to-km law is the scene metres times the baked distance scale', () => { - expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); - expect(metresToKm(-5)).toBe(0); - }); -}); - -describe('ground light and haze', () => { - it("at night the ground has a moon above the horizon on the sun's mirrored side, and a lifted ambient; by day it is the sun", () => { - const night = groundLight(SKY.midnight); - expect(night.dir[1]).toBeGreaterThan(0.5); - expect(luma(night.colour)).toBeGreaterThan(0.03); - expect(luma(night.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); - const day = groundLight(SKY.noonday); - expect(Array.from(day.dir)).toEqual(Array.from(sunDirection(SKY.noonday))); - expect(Array.from(day.colour)).toEqual(Array.from(SKY.noonday.direct)); - expect(Array.from(day.ambient)).toEqual(Array.from(SKY.noonday.ambient)); - }); - - it('the ground light is continuous through sunset', () => { - const at = (e: number) => luma(groundLight(skyAtSun(e)).colour); - for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); - }); - - it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { - const yaw = 2 * Math.PI * START_TURNS; - const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; - const sun = sunDirection(SKY.noonday); - const along = (forward[0] * sun[0] + forward[2] * sun[2]) / Math.hypot(sun[0], sun[2]); - expect(Math.abs(along)).toBeLessThan(0.4); - }); -}); - -describe('chooseSky: an explicit sky wins over the page URL', () => { - const at = (search: string) => vi.stubGlobal('window', { location: { search } }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('lets the URL steer when no option is given', () => { - at('?sun=40&az=90'); - expect(chooseSky({})).toEqual(skyAtSun(40, 90)); - at('?sky=noonday'); - expect(chooseSky({})).toEqual(skyData('noonday')); - }); - - it('prefers an explicit baked sky to ?sun= and ?sky=', () => { - at('?sun=40&az=90&sky=noonday'); - expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); - at('?sky=noonday'); - expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); - }); - - it('prefers an explicit sun to ?sun=, and takes no azimuth from ?az=', () => { - at('?sun=40&az=90&sky=noonday'); - expect(chooseSky({ sun: { elevation: 2 } })).toEqual(skyAtSun(2, undefined)); - expect(chooseSky({ sun: { elevation: 2, azimuth: 10 } })).toEqual(skyAtSun(2, 10)); - }); -}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts deleted file mode 100644 index 92a2ab22..00000000 --- a/src/app/features/scene/terrain/terrainSky.ts +++ /dev/null @@ -1,253 +0,0 @@ -// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's -// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. -import { baked } from '../../../../sky/baked'; -import { hexToLinear, shapeToBand } from '../skyBand'; -import type { SkyBand } from '../skyBand'; -import { - AP_DISTANCE_SCALE, - aerialAt, - HORIZON_FLOOR, - scatterRadiance, - transmittanceAt, -} from './terrainScatter'; - -export type SkyName = 'midnight' | 'noonday'; -export type Vec3 = readonly [number, number, number]; - -export interface SkyData { - /** Degrees above the horizon, no refraction. Negative: the sun is down. */ - elevation: number; - /** Degrees clockwise from north. */ - azimuth: number; - /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ - direct: Vec3; - /** Sky ambient on an upward surface, linear RGB; at night a moonlit floor, not black. */ - ambient: Vec3; - /** The horizon, linear RGB: the baked `horizon_hex`. */ - horizon: Vec3; - /** The luminance band `skyColour` shapes the sky into (skyBand.ts); absent: the physical sky as it is. */ - band?: SkyBand; -} - -const RAD = Math.PI / 180; -const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); -const smooth = (a: number, b: number, x: number) => { - const t = clamp01((x - a) / (b - a)); - return t * t * (3 - 2 * t); -}; -const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => - [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; - -export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; - -export { hexToLinear }; - -const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ - elevation: b.sun.elevation_deg, - azimuth: b.sun.azimuth_deg, - direct: b.sun.color_linear, - ambient: b.ambient_linear, - horizon: hexToLinear(b.horizon_hex), -}); - -export const SKY: Record = { - midnight: fromBaked(baked.midnight), - noonday: fromBaked(baked.noonday), -}; - -export const skyData = (name: SkyName): SkyData => SKY[name]; - -/** - * A sky for a sun at any elevation, between the two baked ones. The sun's colour is the baked transmittance at - * that elevation, faded out across the disc's last degrees the way sun.js does (gone at -0.83, whole by 1.5); - * ambient and horizon blend from the midnight bake to the noonday one as the sun climbs from -8 to 25 degrees. - */ -export function skyAtSun(elevation: number, azimuth: number = SKY.noonday.azimuth): SkyData { - const day = smooth(-8, 25, elevation); - const fade = smooth(-0.83, 1.5, elevation); - const t = transmittanceAt(Math.max(elevation, -4)); - return { - elevation, - azimuth, - direct: [t[0] * fade, t[1] * fade, t[2] * fade], - ambient: mixVec(SKY.midnight.ambient, SKY.noonday.ambient, day), - horizon: mixVec(SKY.midnight.horizon, SKY.noonday.horizon, day), - }; -} - -/** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ -export function sunDirection(d: SkyData): Vec3 { - const el = d.elevation * RAD; - const az = d.azimuth * RAD; - return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; -} - -/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette shares. */ -export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); - -/** The colour distance fades to when the air has nothing to scatter: the baked horizon. Albedo tints toward it at night. */ -export const hazeColour = (d: SkyData): Vec3 => d.horizon; - -/** Sky radiance (see the sun's `skyScatter` in terrainScatter.ts) is for unit sun irradiance; this lifts it to the image. EYEBALLED. */ -export const SKY_EXPOSURE = 45; -/** How far the night glow dims from the horizon to the zenith (1 at the horizon). EYEBALLED. */ -export const GLOW_ZENITH = 0.75; - -/** 1 with the sun below -4 degrees, 0 above 4: where the midnight horizon colour stands in for light the single-scattering tables lack. */ -export const nightness = (elevation: number): number => 1 - smooth(-4, 4, elevation); - -/** The night glow colour: the midnight bake's horizon, fading out as the sun nears the horizon (whatever horizon the day blend gives `d`). */ -export const glowColour = (d: SkyData): Vec3 => { - const k = nightness(d.elevation); - const h = SKY.midnight.horizon; - return [h[0] * k, h[1] * k, h[2] * k]; -}; - -/** - * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering - * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits - * brightest at the horizon; at and below the horizon, the horizon's colour; then shaped into `d.band` when it has one. - * The fragment shader does the same. - */ -export function skyColour(d: SkyData, dir: Vec3): Vec3 { - const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); - const sun = sunDirection(d); - const h = Math.hypot(dir[0], dir[2]); - const s = Math.hypot(sun[0], sun[2]); - const az = - h < 1e-4 || s < 1e-4 - ? 0 - : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; - const r = scatterRadiance(d.elevation, el, az); - const glow = glowColour(d); - const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); - const c = [0, 1, 2].map( - (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape - ) as unknown as Vec3; - return d.band ? shapeToBand(c, d.band) : c; -} - -/** - * Aerial perspective: the baked volume of src/sky/scatter.js (after Hillaire 2020, see there for what follows the paper - * and what does not), looked up by the view ray relative to the light and the metres to the surface. The surface keeps - * (1 - alpha) of its own colour (alpha = 1 - mean transmittance, a grey transmittance as the paper) and gains the in-scatter L, - * lifted by the sky's exposure and tone-mapped the way the sky is, so far ground meets the sky's colour. SHORTCUT: the path is - * the scene's metres times AP_DISTANCE_SCALE (a real atmosphere is nearly clear over 3 km). - */ -export const metresToKm = (metres: number): number => - (Math.max(0, metres) / 1000) * AP_DISTANCE_SCALE; - -/** Moonlight's irradiance on the haze, relative to the sun's 1, at full night. EYEBALLED, far above a real moon (about 3e-6) as the sky exposure is. */ -export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; -/** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ -export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; - -/** - * Metres from the eye over which the terrain gives itself up to the sky: past the far end a surface is the sky's own - * colour at the horizon in its azimuth (`skyColour` flat along the horizon, night glow included), so the patch's rim - * meets the sky above and beyond it with no seam. The volume alone is only ~40% opaque by then. EYEBALLED. - */ -export const SKY_FADE: readonly [number, number] = [600, 1350]; - -/** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ -export function aerialPerspective( - d: SkyData, - dir: Vec3, - distance: number -): { colour: Vec3; alpha: number } { - const el = Math.min(0, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); - const sun = sunDirection(d); - const h = Math.hypot(dir[0], dir[2]); - const s = Math.hypot(sun[0], sun[2]); - const az = - h < 1e-4 || s < 1e-4 - ? 0 - : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; - const km = metresToKm(distance); - const night = nightness(d.elevation); - const day = aerialAt(d.elevation, el, az, km); - const L = [...day.L]; - if (night > 0) { - // The moon stands where the sun is mirrored above the horizon (groundLight), so it shares the sun's azimuth. - const moon = aerialAt(Math.abs(d.elevation), el, az, km); - for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; - } - const horizon = skyColour(d, [dir[0], 0, dir[2]]); - const f = smooth(SKY_FADE[0], SKY_FADE[1], distance); - const colour = [0, 1, 2].map( - (i) => - (1 - f) * (1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha) + - f * horizon[i] - ) as unknown as Vec3; - return { colour, alpha: (1 - f) * day.alpha + f }; -} - -/** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ -export const NIGHT_AMBIENT_LIFT = 3; -/** Cool light from the mirror of the sun's below-horizon position, linear, scaled by `nightness`: gives the terrain a lit side at night. */ -export const MOON_COLOUR: Vec3 = [0.05, 0.07, 0.13]; - -/** The light on the ground: the sun, or at night the moon standing where the sun is mirrored above the horizon. Continuous through sunset. */ -export function groundLight(d: SkyData): { dir: Vec3; colour: Vec3; ambient: Vec3 } { - const sun = sunDirection(d); - const k = nightness(d.elevation); - return { - dir: [sun[0], Math.abs(sun[1]), sun[2]], - colour: [0, 1, 2].map((i) => d.direct[i] + MOON_COLOUR[i] * k) as unknown as Vec3, - ambient: [0, 1, 2].map((i) => d.ambient[i] * (1 + NIGHT_AMBIENT_LIFT * k)) as unknown as Vec3, - }; -} - -/** - * The heading the camera starts at, in turns from north: the noonday sun a hundred degrees to its right, so the light - * rakes across the relief instead of flattening it from behind. Fixed, so `?az=` still moves the sun in the picture. - */ -export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; - -/** - * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from - * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), - * then aerial perspective (`aerialPerspective`). `normal` is a unit vector. Never negative. - */ -export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { - const light = groundLight(d); - const sun = light.dir; - const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); - const hemi = 0.5 + 0.5 * normal[1]; - const haze = aerialPerspective(d, view, distance); - return [0, 1, 2].map((i) => { - const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); - return lit * (1 - haze.alpha) + haze.colour[i]; - }) as unknown as Vec3; -} - -/** What the page URL asks of the sky, if anything: `?sun=` (with `?az=`) wins over `?sky=midnight|noonday`; a bad value asks nothing. */ -export function urlSky(): - | { sky?: SkyName; sun?: { elevation: number; azimuth?: number } } - | undefined { - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = number('az'); - return { sun: { elevation, azimuth: Number.isFinite(azimuth) ? azimuth : undefined } }; - } - const name = q.get('sky'); - return isSkyName(name) ? { sky: name } : undefined; -} - -/** - * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun - * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer - * (`urlSky`), else midnight. A band, when given, shapes whichever sky that is. - */ -export function chooseSky(opts: { - sky?: SkyName; - sun?: { elevation: number; azimuth?: number }; - band?: SkyBand; -}): SkyData { - const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d); - const want = opts.sun || opts.sky ? opts : urlSky() ?? { sky: 'midnight' as const }; - if (want.sun) return banded(skyAtSun(want.sun.elevation, want.sun.azimuth)); - return banded(skyData(want.sky ?? 'midnight')); -} diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts deleted file mode 100644 index c1231bd8..00000000 --- a/src/app/features/scene/terrain/terrainStars.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SKY, skyAtSun } from './terrainSky'; -import { - BRIGHTEN, - createTerrainStars, - elevationOf, - ELEVATION_TOP, - FRAME_MS, - horizonY, - PERIOD, - nightColours, - project, - starGain, -} from './terrainStars'; -import { CAMERA, view } from './terrainWorld'; -import { isNight, makeStars } from '../ridge/stars'; - -/** A 2D context that only counts: arcs painted and clears since the last reset. */ -function fakeCtx() { - const c = { - arcs: [] as Array<[number, number, number]>, - clears: 0, - alpha: 1, - alphas: [] as number[], - }; - const ctx: any = { - clearRect: () => { - c.clears += 1; - c.arcs = []; - c.alphas = []; - }, - beginPath() {}, - fill() { - c.alphas.push(ctx.globalAlpha); - }, - arc: (x: number, y: number, r: number) => c.arcs.push([x, y, r]), - globalAlpha: 1, - fillStyle: '', - }; - return { c, ctx }; -} - -let overlay: any; -let ctx2d: ReturnType; -let parent: any; -beforeEach(() => { - ctx2d = fakeCtx(); - overlay = { - style: {}, - width: 0, - height: 0, - setAttribute: vi.fn(), - getContext: vi.fn(() => ctx2d.ctx), - remove: vi.fn(), - }; - parent = { appendChild: vi.fn() }; - vi.stubGlobal('document', { createElement: () => overlay }); - vi.stubGlobal('window', { devicePixelRatio: 1, innerWidth: 390, innerHeight: 844 }); -}); -afterEach(() => vi.unstubAllGlobals()); - -const glCanvas = (w = 390, h = 844) => - ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); - -describe('which skies get stars', () => { - it('the field is placed against the baked night gradient, which the ridge module calls dark', () => { - expect(isNight(nightColours())).toBe(true); - }); - - it('starGain: full below -12 degrees, none above -6, smooth and monotone between', () => { - expect(starGain(-44.6)).toBe(1); - expect(starGain(-12)).toBe(1); - expect(starGain(-6)).toBe(0); - expect(starGain(45.2)).toBe(0); - expect(starGain(-9)).toBeCloseTo(0.5, 9); - let prev = 1; - for (let e = -12; e <= -6; e += 0.25) { - expect(starGain(e)).toBeLessThanOrEqual(prev); - prev = starGain(e); - } - }); - - it('noonday gets no layer and no element', () => { - expect(createTerrainStars(glCanvas(), SKY.noonday)).toBeNull(); - expect(parent.appendChild).not.toHaveBeenCalled(); - }); - - it('twilight is a fade, not a gate: a sun at -9 degrees paints the stars at half alpha', () => { - createTerrainStars(glCanvas(), skyAtSun(-9))?.draw(0); - const half = Math.max(...ctx2d.c.alphas); - ctx2d.c.alphas = []; - createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); - expect(half).toBeGreaterThan(0); - expect(half / Math.max(...ctx2d.c.alphas)).toBeCloseTo(0.5, 6); - expect(createTerrainStars(glCanvas(), skyAtSun(-6.5))).not.toBeNull(); - expect(createTerrainStars(glCanvas(), skyAtSun(-6))).toBeNull(); - }); - - it('midnight adds one overlay to the canvas parent; dispose removes it', () => { - const s = createTerrainStars(glCanvas(), SKY.midnight)!; - expect(parent.appendChild).toHaveBeenCalledWith(overlay); - s.dispose(); - expect(overlay.remove).toHaveBeenCalledOnce(); - }); - - it('is absent without a parent or a 2D context', () => { - expect( - createTerrainStars({ ...glCanvas(), parentElement: null } as any, SKY.midnight) - ).toBeNull(); - overlay.getContext = () => null; - expect(createTerrainStars(glCanvas(), SKY.midnight)).toBeNull(); - expect(parent.appendChild).not.toHaveBeenCalled(); - }); -}); - -describe('sky only', () => { - it('the overlay is as tall as the horizon line is low, never the whole screen', () => { - createTerrainStars(glCanvas(), SKY.midnight); - expect(overlay.height).toBe(Math.ceil(horizonY(844))); - expect(overlay.height).toBeLessThan(844 / 2); - expect(overlay.style.pointerEvents).toBe('none'); - }); - - it('a star at the horizon lands on the horizon line, one above it lands above', () => { - for (const aspect of [0.46, 1.78]) { - const h = 800; - const w = h * aspect; - const onLine = project(0.2, 0, w, h)!; - expect(onLine.y).toBeCloseTo(horizonY(h), 5); - expect(project(0.2, 0.05, w, h)!.y).toBeLessThan(horizonY(h)); - } - }); - - it('the camera eye clears the peaks, so the horizon line bounds the terrain', () => { - expect(CAMERA.eye).toBeGreaterThan(256); - }); - - it('every painted star is above the horizon line, at any heading', () => { - const s = createTerrainStars(glCanvas(), SKY.midnight)!; - for (let k = 0; k < 40; k += 1) { - s.draw(k / 40); - for (const [, y] of ctx2d.c.arcs) expect(y).toBeLessThanOrEqual(overlay.height); - } - }); - - it('agrees with the camera the terrain draws: the projection matches view()', () => { - const w = 390; - const h = 844; - const turns = 0.137; - const v = view(w / h, turns); - const yaw = 2 * Math.PI * turns; - const az = yaw + 0.1; - const e = 0.15; - const d = [Math.sin(az) * Math.cos(e), Math.sin(e), -Math.cos(az) * Math.cos(e)]; - const dot = (a: number[], b: readonly number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; - const t = Math.tan(CAMERA.fov / 2); - const f = dot(d, v.forward); - const nx = dot(d, v.right) / f / (t * (w / h)); - const ny = dot(d, v.up) / f / t; - const at = project(0.1, e, w, h)!; - expect(at.x).toBeCloseTo(((nx + 1) / 2) * w, 6); - expect(at.y).toBeCloseTo(((1 - ny) / 2) * h, 6); - }); -}); - -describe('turning with the camera', () => { - it('stars are on screen, and slide sideways as the camera turns', () => { - const s = createTerrainStars(glCanvas(), SKY.midnight)!; - s.draw(0); - const first = ctx2d.c.arcs.slice(); - expect(first.length).toBeGreaterThan(10); - s.draw(0.002); - const later = ctx2d.c.arcs; - expect(later).not.toEqual(first); - const m = (a: number[][]) => a.reduce((t, p) => t + p[0], 0) / a.length; - expect(m(later)).not.toBeCloseTo(m(first), 1); - }); - - it('repeats every PERIOD of heading', () => { - const s = createTerrainStars(glCanvas(), SKY.midnight)!; - s.draw(0.1); - const a = ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3))); - s.draw(0.1 + PERIOD / (2 * Math.PI)); - expect(ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3)))).toEqual(a); - }); - - it('the field fills the screen top and thins toward the horizon, inside the elevation band', () => { - expect(elevationOf(0)).toBeCloseTo(ELEVATION_TOP, 9); - expect(elevationOf(0.78)).toBeCloseTo(0, 9); - }); -}); - -describe('brightness', () => { - it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { - createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); - const want = makeStars(nightColours(), 390, 844).map((st) => Math.min(1, st.alpha * BRIGHTEN)); - ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); - // and it is brighter than the ridge field's own top alpha - const top = Math.max(...makeStars(nightColours(), 390, 844).map((st) => st.alpha)); - expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); - }); -}); - -describe('motion', () => { - it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { - const s = createTerrainStars(glCanvas(), SKY.midnight)!; - s.draw(0.3); - const alphas = ctx2d.c.alphas.slice(); - s.draw(0.3); - expect(ctx2d.c.alphas).toEqual(alphas); - }); - - it('with the clock the twinklers swing away from base, and the repaint is throttled', () => { - const s = createTerrainStars(glCanvas(), SKY.midnight)!; - s.draw(0.3); - const still = ctx2d.c.alphas.slice(); - let moved = false; - for (let t = 1; t < 60 && !moved; t += 1.7) { - s.draw(0.3, t); - moved = ctx2d.c.alphas.some((a, i) => a !== still[i]); - } - expect(moved).toBe(true); - const clears = ctx2d.c.clears; - s.draw(0.3, 100); - s.draw(0.3, 100 + FRAME_MS / 4000); - expect(ctx2d.c.clears).toBe(clears + 1); - }); - - it('resize refits the canvas and the next draw repaints at once', () => { - const c = glCanvas(); - const s = createTerrainStars(c, SKY.midnight)!; - s.draw(0.1, 5); - c.clientHeight = 400; - s.resize(); - expect(overlay.height).toBe(Math.ceil(horizonY(400))); - const clears = ctx2d.c.clears; - s.draw(0.1, 5.001); - expect(ctx2d.c.clears).toBe(clears + 1); - }); -}); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts deleted file mode 100644 index a7275062..00000000 --- a/src/app/features/scene/terrain/terrainStars.ts +++ /dev/null @@ -1,173 +0,0 @@ -// The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the -// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, faded by the sun's elevation (starGain). -// -// The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the -// horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak -// (TERRAIN.hmax), so terrain never rises above that line and a star above the horizon is never over terrain. -// -// Stars turn with the camera, as sky does: the ridge field's x becomes azimuth, repeated every PERIOD (so 400 -// stars fill a sky that only ever shows 55 deg to ~120 deg of it), and y becomes elevation from the horizon -// up to ELEVATION_TOP, the top of the screen. Placement is projected with the terrain's own camera (CAMERA, -// the yaw the terrain draws at), nothing is baked, and the whole overlay is redrawn at most every FRAME_MS: a -// visible star is a few arcs on a canvas a quarter of the screen tall. The stars stop when the terrain stops: -// the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) -// paints every star at base brightness. - -import { baked } from '../../../../sky/baked'; -import type { SkyColours, Srgb } from '../ridge/skyColour'; -import { activeAt, makeStars, SKY_STOPS, Star } from '../ridge/stars'; -import { SkyData } from './terrainSky'; -import { CAMERA } from './terrainWorld'; - -/** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ -export const PERIOD = (2 * Math.PI) / 3; -/** Elevation of the field's top, radians: a little above the top edge of the screen at the camera's pitch. */ -export const ELEVATION_TOP = (16 * Math.PI) / 180; -/** The overlay repaints at most this often while the terrain turns (12 px/s on a phone: under half a pixel a step). */ -export const FRAME_MS = 33; -/** The twinkle schedule is re-read this often; the swings are 2 to 7 s. */ -const TWINKLE_MS = 50; - -const hex = (h: string): Srgb => - [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; - -/** - * How much of the starfield shows with the sun at `elevation` degrees: all of it below -12 (where the baked - * scattering goes dark: the planet's shadow covers every sunlit air path), none above -6 (civil twilight), a smooth - * fade between. Not a dark/day gate: a sun at -9 shows the stars at half. - */ -export function starGain(elevation: number): number { - const t = Math.min(1, Math.max(0, (elevation + 12) / 6)); - return 1 - t * t * (3 - 2 * t); -} - -/** The ridge star module's view of the night sky it places stars against: the baked midnight gradient's zenith, middle and horizon stops. */ -export function nightColours(): SkyColours { - const g = baked.midnight.gradient; - return { - top: hex(g[0]), - mid: hex(g[Math.floor(g.length / 2)]), - horizon: hex(g[g.length - 1]), - ground: [0, 0, 0], - }; -} - -/** Screen height in px below which there is no sky: where the horizon plane meets the image, a straight line. */ -export const horizonY = (height: number): number => - height * (0.5 - Math.tan(CAMERA.pitch) / Math.tan(CAMERA.fov / 2) / 2); - -export interface Placed { - x: number; - y: number; -} - -/** - * Where a star at azimuth `da` from the camera's heading and elevation `e` (radians) lands in a `w` x `h` px - * viewport, or null if it is behind or off the screen. The same camera as `view()` in terrainWorld.ts - * (forward, right and up are its basis written out for a camera with yaw and pitch only). - */ -export function project(da: number, e: number, w: number, h: number): Placed | null { - const p = CAMERA.pitch; - const ce = Math.cos(e); - const front = ce * Math.cos(p) * Math.cos(da) - Math.sin(e) * Math.sin(p); - if (front <= 0.01) return null; - const tanHalf = Math.tan(CAMERA.fov / 2); - const nx = (ce * Math.sin(da)) / front / (tanHalf * (w / h)); - const ny = (ce * Math.sin(p) * Math.cos(da) + Math.sin(e) * Math.cos(p)) / front / tanHalf; - if (Math.abs(nx) > 1.05) return null; - return { x: ((nx + 1) / 2) * w, y: ((1 - ny) / 2) * h }; -} - -/** Elevation in radians for a ridge-field height fraction y (0 zenith side .. SKY_STOPS.horizon at the horizon). */ -export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_STOPS.horizon); - -const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); - -/** The terrain overlay draws the ridge field's stars brighter and a little larger: a phone's small, hazy sky needs it to read. */ -export const BRIGHTEN = 2.4; -export const GROW = 1.3; - -export interface TerrainStars { - /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ - draw(turns: number, seconds?: number): void; - /** Re-fit the overlay to the canvas's current size; the next draw repaints. */ - resize(): void; - dispose(): void; -} - -/** The layer for `canvas`'s parent, or null: the sun is too high for any star, no parent, no 2D context. */ -export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { - const colours = nightColours(); - const gain = starGain(sky.elevation); - const parent = canvas.parentElement; - if (!parent || gain <= 0) return null; - const el = document.createElement('canvas'); - const ctx = el.getContext?.('2d'); - if (!ctx) return null; - el.setAttribute?.('aria-hidden', 'true'); - Object.assign(el.style, { position: 'absolute', left: '0', top: '0', pointerEvents: 'none' }); - parent.appendChild(el); - - let w = 1; - let h = 1; - let scale = 1; - let field: Star[] = []; - let lit: Array<{ star: Star; az: number; e: number }> = []; - let gains = new Map(); - let gainsAt = -Infinity; - let last = -Infinity; - - const fit = () => { - w = canvas.clientWidth || window.innerWidth; - h = canvas.clientHeight || window.innerHeight; - scale = Math.min(window.devicePixelRatio || 1, 2); - const sky = Math.max(1, Math.ceil(horizonY(h))); - el.width = Math.max(1, Math.round(w * scale)); - el.height = Math.max(1, Math.round(sky * scale)); - el.style.width = `${w}px`; - el.style.height = `${sky}px`; - field = makeStars(colours, w, h); - lit = field.map((star) => ({ star, az: star.x * PERIOD, e: elevationOf(star.y) })); - gains = new Map(); - gainsAt = -Infinity; - }; - - const paint = (turns: number, seconds: number | undefined) => { - ctx.clearRect(0, 0, el.width, el.height); - if (seconds !== undefined && seconds * 1000 - gainsAt >= TWINKLE_MS) { - gains = new Map(activeAt(field.length, seconds).map((a) => [a.star, a.gain])); - gainsAt = seconds * 1000; - } - const yaw = 2 * Math.PI * turns; - lit.forEach(({ star, az, e }, i) => { - const da = wrap(az - yaw); - const twinkle = seconds === undefined ? 1 : gains.get(i) ?? 1; - ctx.globalAlpha = Math.min(1, star.alpha * twinkle * BRIGHTEN) * gain; - ctx.fillStyle = star.fill; - [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { - const at = project(d, e, w, h); - if (!at || at.y < 0 || at.y > el.height / scale) return; - ctx.beginPath(); - ctx.arc(at.x * scale, at.y * scale, star.r * GROW * scale, 0, 2 * Math.PI); - ctx.fill(); - }); - }); - ctx.globalAlpha = 1; - }; - - fit(); - return { - draw(turns, seconds) { - if (seconds !== undefined && seconds * 1000 - last < FRAME_MS) return; - last = seconds === undefined ? -Infinity : seconds * 1000; - paint(turns, seconds); - }, - resize() { - fit(); - last = -Infinity; - }, - dispose() { - el.remove(); - }, - }; -} diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts deleted file mode 100644 index 70a9d763..00000000 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ -import { describe, expect, it } from 'vitest'; -import { - buildGrid, - CAMERA, - GRID, - seedOffset, - TERRAIN, - triangleCount, - view, - warpUv, -} from './terrainWorld'; -import { heightFragmentSource } from './terrainShaders'; - -describe('seed', () => { - it('same seed, same offset, so the baked height map is the same; a different seed moves it', () => { - expect(seedOffset(7)).toEqual(seedOffset(7)); - expect(seedOffset(0)).toEqual([0, 0]); - expect(seedOffset(1)).not.toEqual(seedOffset(2)); - }); - - it('the height-bake source is a pure function of nothing but the world constants', () => { - expect(heightFragmentSource()).toBe(heightFragmentSource()); - const src = heightFragmentSource(); - expect(src.startsWith('#version 300 es')).toBe(true); - expect(src).toContain('cultmath_phacelle'); - expect(src).toContain(`/ ${TERRAIN.hmax.toFixed(1)}`); - expect(src).toContain('uSeed'); - }); -}); - -describe('grid', () => { - it('has (n+1)^2 vertices in 0..1 and 2n^2 triangles that index them all in range', () => { - const n = 8; - const g = buildGrid(n); - expect(g.uv.length).toBe((n + 1) * (n + 1) * 2); - expect(g.indices.length / 3).toBe(triangleCount(n)); - expect(Math.max(...g.indices)).toBe((n + 1) * (n + 1) - 1); - expect(Math.min(...g.uv)).toBe(0); - expect(Math.max(...g.uv)).toBe(1); - }); - - it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - // 524k at 512 quads (low), 1.18M at 768 (full): Meta asked for denser terrain on 2026-11-10, up from 205k at 320. No - // phone frame time is measured (SwiftShader only); kept as a ceiling so a bigger grid is a deliberate change. - expect(triangleCount(GRID.low.grid)).toBeLessThan(540000); - expect(triangleCount(GRID.full.grid)).toBeLessThan(1200000); - (['still', 'low', 'full'] as const).forEach((t) => - expect((GRID[t].grid + 1) ** 2).toBeLessThan(2 ** 32) - ); - expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); - expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); - }); -}); - -describe('camera', () => { - const project = (m: Float32Array, p: number[]) => { - const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]; - const y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]; - const z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]; - const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; - return { x: x / w, y: y / w, z: z / w, w }; - }; - - it('a point straight ahead lands in the middle of the screen, in front, inside the depth range', () => { - for (const turns of [0, 0.25, 0.6]) { - const v = view(0.46, turns); - const p = v.eye.map((e, i) => e + 800 * v.forward[i]); - const s = project(v.viewProj, p); - expect(s.w).toBeGreaterThan(0); - expect(s.x).toBeCloseTo(0, 5); - expect(s.y).toBeCloseTo(0, 5); - expect(Math.abs(s.z)).toBeLessThan(1); - } - }); - - it('looks down by the pitch, and one full turn comes back to the same heading', () => { - const a = view(1, 0); - const b = view(1, 1); - expect(a.forward[1]).toBeCloseTo(-Math.sin(CAMERA.pitch), 9); - for (let i = 0; i < 3; i += 1) expect(a.forward[i]).toBeCloseTo(b.forward[i], 9); - }); - - it('sits above the highest possible terrain, so the eye is never inside a hill', () => { - expect(CAMERA.eye).toBeGreaterThan(TERRAIN.hmax); - }); - - it('the basis is orthonormal', () => { - const v = view(0.5, 0.37); - const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; - expect(dot(v.right, v.up)).toBeCloseTo(0, 9); - expect(dot(v.right, v.forward)).toBeCloseTo(0, 9); - expect(dot(v.up, v.forward)).toBeCloseTo(0, 9); - expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); - }); -}); - -describe('grid resolution', () => { - // The mesh must carry what the height map shows: where a quad is wider than a couple of texels the shading (read from the - // height map) has gullies the polygons cannot, and the near ground reads as hard-edged facets (the 'terrain · low' phone - // shots, 2026-11-09). Metres from the patch centre to the nearest ground the camera can see: the bottom view ray meets - // the highest terrain, hmax below the eye, at (eye - hmax) / tan(pitch + fov / 2). - const nearest = (CAMERA.eye - TERRAIN.hmax) / Math.tan(CAMERA.pitch + CAMERA.fov / 2); - const spacingAt = (grid: number, metres: number) => { - for (let i = 0; i < grid; i += 1) { - const x = (warpUv((i + 1) / grid) - 0.5) * TERRAIN.span; - if (x >= metres) return (warpUv((i + 1) / grid) - warpUv(i / grid)) * TERRAIN.span; - } - throw new Error('beyond the patch'); - }; - - it.each(['still', 'low', 'full'] as const)( - 'the %s tier has a quad no wider than two height texels at the nearest ground the camera can see', - (tier) => { - const { grid, tex } = GRID[tier]; - expect(spacingAt(grid, nearest)).toBeLessThanOrEqual((2 * TERRAIN.span) / (tex - 1)); - } - ); - - it('a dearer tier never has a coarser mesh', () => { - expect(GRID.low.grid).toBeGreaterThanOrEqual(GRID.still.grid); - expect(GRID.full.grid).toBeGreaterThanOrEqual(GRID.low.grid); - }); -}); - -describe('warpUv', () => { - it('maps the patch onto itself, monotonic, centre fixed', () => { - expect(warpUv(0)).toBe(0); - expect(warpUv(1)).toBe(1); - expect(warpUv(0.5)).toBe(0.5); - for (let i = 0; i < 100; i += 1) expect(warpUv((i + 1) / 100)).toBeGreaterThan(warpUv(i / 100)); - }); - - it('packs the vertices toward the camera at the centre: finer there, coarser at the rim, than a plain grid', () => { - const plain = 1 / 192; - const step = (u: number) => warpUv(u + 1 / 192) - warpUv(u); - expect(step(0.5)).toBeLessThan(0.6 * plain); - expect(step(0.99)).toBeGreaterThan(1.3 * plain); - }); -}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts deleted file mode 100644 index 82a16bfb..00000000 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ /dev/null @@ -1,140 +0,0 @@ -// The terrain scene's fixed numbers, grid and camera, as pure functions. No DOM, no GL. -// The height field itself is a function of world position written in GLSL (terrainShaders.ts), baked once -// into a texture; the CPU owns only where it is looked at from, and the seed offset it is sampled at. - -/** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ -export const TERRAIN = { span: 3000, hmax: 256 }; - -/** - * Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. The - * mesh is dense enough that a quad is at most two texels wide at the nearest ground (terrainWorld.test.ts); a coarser mesh - * under shading read from the height map shows as hard-edged facets and stepped crests. - */ -export const GRID = { - still: { grid: 320, tex: 1024 }, - low: { grid: 512, tex: 1024 }, - full: { grid: 768, tex: 2048 }, -} as const; - -/** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ -export const CAMERA = { - eye: 330, - pitch: (13 * Math.PI) / 180, - fov: (55 * Math.PI) / 180, - near: 10, - far: 6000, - /** Seconds for one full turn. */ - seconds: 420, -}; - -/** The height map's sample offset in metres for a seed: same seed, same terrain. Seed 0 is the origin. */ -export function seedOffset(seed: number): [number, number] { - const s = Math.trunc(seed); - return [(s * 7919) % 65536, (s * 104729) % 65536]; -} - -/** - * Where grid coordinate `u` (0..1 across the patch) lands in the patch, 0..1. The camera stands at the centre, so the - * grid is packed toward it: with s = 2u - 1 the spacing grows as 0.5 + |s| of the plain grid's, about half of it at the - * centre and half again as much at the rim. The vertex shader does the same arithmetic (terrainShaders.ts GRID_VERT). - */ -export const warpUv = (u: number): number => { - const s = 2 * u - 1; - return 0.5 + 0.5 * (0.5 * s + 0.5 * s * Math.abs(s)); -}; - -export const triangleCount = (grid: number): number => 2 * grid * grid; - -/** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ -export function buildGrid(grid: number): { uv: Float32Array; indices: Uint32Array } { - const uv = new Float32Array((grid + 1) * (grid + 1) * 2); - for (let j = 0, k = 0; j <= grid; j += 1) - for (let i = 0; i <= grid; i += 1, k += 2) { - uv[k] = i / grid; - uv[k + 1] = j / grid; - } - const indices = new Uint32Array(grid * grid * 6); - for (let j = 0, k = 0; j < grid; j += 1) - for (let i = 0; i < grid; i += 1, k += 6) { - const a = j * (grid + 1) + i; - indices.set([a, a + grid + 1, a + 1, a + 1, a + grid + 1, a + grid + 2], k); - } - return { uv, indices }; -} - -export interface View { - /** Column-major perspective * view. */ - viewProj: Float32Array; - eye: [number, number, number]; - /** Camera basis in world space, for the sky pass. */ - right: [number, number, number]; - up: [number, number, number]; - forward: [number, number, number]; -} - -const norm = (a: number[]): [number, number, number] => { - const l = Math.hypot(a[0], a[1], a[2]) || 1; - return [a[0] / l, a[1] / l, a[2] / l]; -}; -const cross = (a: number[], b: number[]): [number, number, number] => [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], -]; - -/** The camera `turns` full turns round (0..1 wraps), for a viewport aspect (width / height). */ -export function view(aspect: number, turns: number): View { - const yaw = 2 * Math.PI * turns; - const cp = Math.cos(CAMERA.pitch); - const forward = norm([Math.sin(yaw) * cp, -Math.sin(CAMERA.pitch), -Math.cos(yaw) * cp]); - const right = norm(cross(forward, [0, 1, 0])); - const up = cross(right, forward); - const eye: [number, number, number] = [0, CAMERA.eye, 0]; - const t = (a: number[]) => -(a[0] * eye[0] + a[1] * eye[1] + a[2] * eye[2]); - const v = [ - right[0], - up[0], - -forward[0], - 0, - right[1], - up[1], - -forward[1], - 0, - right[2], - up[2], - -forward[2], - 0, - t(right), - t(up), - -t(forward), - 1, - ]; - const f = 1 / Math.tan(CAMERA.fov / 2); - const { near: n, far } = CAMERA; - const p = [ - f / aspect, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - (far + n) / (n - far), - -1, - 0, - 0, - (2 * far * n) / (n - far), - 0, - ]; - const viewProj = new Float32Array(16); - for (let c = 0; c < 4; c += 1) - for (let r = 0; r < 4; r += 1) { - let s = 0; - for (let k = 0; k < 4; k += 1) s += p[k * 4 + r] * v[c * 4 + k]; - viewProj[c * 4 + r] = s; - } - return { viewProj, eye, right, up, forward }; -} diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts deleted file mode 100644 index e3f18871..00000000 --- a/src/app/features/scene/terrain/tierLabel.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any -- DOM stubs, as the other scene tests */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { showTierLabel } from './tierLabel'; - -let search: string; -let appended: any[]; -const canvas = (parent: boolean) => - ({ - parentElement: parent - ? { - appendChild: (e: any) => appended.push(e), - } - : null, - } as any); - -beforeEach(() => { - search = ''; - appended = []; - vi.stubGlobal('window', { - get location() { - return { search }; - }, - }); - vi.stubGlobal('document', { - createElement: () => ({ style: {}, remove: vi.fn(), textContent: '' }), - }); -}); -afterEach(() => vi.unstubAllGlobals()); - -describe('showTierLabel', () => { - it('adds nothing in a normal build', () => { - expect(showTierLabel(canvas(true), 'full', true)).toBeNull(); - expect(appended).toHaveLength(0); - }); - - it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { - search = '?tierlabel=1'; - const remove = showTierLabel(canvas(true), 'sky-only', true); - expect(appended).toHaveLength(1); - expect(appended[0].textContent).toBe('terrain · sky-only'); - remove!(); - expect(appended[0].remove).toHaveBeenCalled(); - }); - - it('ignores ?tierlabel=1 where the overlays are not allowed (a production page)', () => { - search = '?tierlabel=1'; - expect(showTierLabel(canvas(true), 'full', false)).toBeNull(); - expect(appended).toHaveLength(0); - }); - - it('has nowhere to go without a parent', () => { - search = '?tierlabel=1'; - expect(showTierLabel(canvas(false), 'full', true)).toBeNull(); - }); -}); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts deleted file mode 100644 index 4afc8a69..00000000 --- a/src/app/features/scene/terrain/tierLabel.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { overlayRequested } from '../debugOverlays'; - -// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), -// so a screenshot of a preview says what it shows. Built into preview builds (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1` -// where the diagnostic overlays are allowed (debugOverlays.ts). -const wanted = (allowed: boolean): boolean => - import.meta.env?.VITE_PREVIEW_DEMO === '1' || overlayRequested('tierlabel', allowed); - -/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ -export function showTierLabel( - canvas: HTMLCanvasElement, - text: string, - allowed: boolean -): (() => void) | null { - const parent = canvas.parentElement; - if (!parent || !wanted(allowed)) return null; - const el = document.createElement('div'); - el.textContent = `terrain · ${text}`; - Object.assign(el.style, { - position: 'absolute', - left: '2px', - top: '16px', - font: '10px monospace', - color: 'rgba(255,255,255,.75)', - background: 'rgba(0,0,0,.4)', - padding: '1px 5px', - borderRadius: '3px', - pointerEvents: 'none', - }); - parent.appendChild(el); - return () => el.remove(); -} diff --git a/src/app/features/scene/tier.test.ts b/src/app/features/scene/tier.test.ts deleted file mode 100644 index d35b6bb6..00000000 --- a/src/app/features/scene/tier.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveTier } from './tier'; - -describe('scene tier', () => { - it('is off unless the config turns it on, which is the feature flag', () => { - expect(resolveTier(undefined)).toBe('off'); - expect(resolveTier({})).toBe('off'); - expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off'); - expect(resolveTier({ tier: 'full' })).toBe('full'); - }); - - it('lets the device lower the configured tier and never raise it', () => { - expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still'); - expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low'); - expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low'); - expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still'); - expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe( - 'still' - ); - expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low'); - }); - - it('treats unknown capabilities as capable', () => { - expect(resolveTier({ tier: 'full' }, {})).toBe('full'); - }); -}); diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts deleted file mode 100644 index ba338f37..00000000 --- a/src/app/features/scene/tier.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both -// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the -// device can only lower it. - -export type SceneTier = 'off' | 'still' | 'low' | 'full'; - -/** Cheapest first. */ -export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full']; - -export interface Capabilities { - /** `navigator.connection.saveData`. */ - saveData?: boolean; - /** `navigator.deviceMemory` in GiB (Chromium only). */ - deviceMemory?: number; - hardwareConcurrency?: number; -} - -/** - * 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and - * clouds. 'terrain': a baked height map on a displaced grid, lit and hazed by the baked sky (WebGL2). - */ -export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; - -export interface BackgroundSceneConfig { - tier?: SceneTier; - style?: SceneStyle; - /** Let the page URL switch on the diagnostic overlays (`?probe=1`, `?tierlabel=1`) in a production build. Default: no. */ - debugOverlays?: boolean; -} - -export function readCapabilities(nav: Navigator): Capabilities { - const n = nav as Navigator & { - deviceMemory?: number; - connection?: { saveData?: boolean }; - }; - return { - saveData: n.connection?.saveData, - deviceMemory: n.deviceMemory, - hardwareConcurrency: n.hardwareConcurrency, - }; -} - -/** The most the device is allowed to run, before the configured ceiling is applied. */ -function deviceCeiling(c: Capabilities): SceneTier { - if (c.saveData) return 'still'; - if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low'; - return 'full'; -} - -/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */ -export function resolveTier( - config: BackgroundSceneConfig | undefined, - capabilities: Capabilities = {} -): SceneTier { - const configured = config?.tier ?? 'off'; - const ceiling = deviceCeiling(capabilities); - return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling; -} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts deleted file mode 100644 index caa04fad..00000000 --- a/src/app/features/scene/world.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import common from './shaders/common.glsl?raw'; -import terrain from './shaders/terrain.frag.glsl?raw'; -import atmosphere from './shaders/atmosphere.frag.glsl?raw'; -import resolve from './shaders/resolve.frag.glsl?raw'; -import composite from './shaders/composite.frag.glsl?raw'; -import { terrainBounds, WORLD, worldDefines } from './world'; - -describe('world defines', () => { - it('define every W_ name any scene shader uses', () => { - const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); - [common, terrain, atmosphere, resolve, composite] - .flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)]) - .forEach((m) => expect(defined, m[0]).toContain(m[0])); - }); - - it('emit only valid GLSL numbers', () => { - expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/); - }); -}); - -describe('terrain step bound', () => { - it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => { - const b = terrainBounds(); - expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope); - const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 }); - expect(off.slopeFull).toBe(off.slopeEnvelope); - }); - - it('puts every surface under the height the tracer skips to', () => { - const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]); - expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight); - expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]); - }); - - it('leaves the erosion bound turned on only when a bound exists', () => { - expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1'); - expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0'); - }); -}); diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts deleted file mode 100644 index e9594a68..00000000 --- a/src/app/features/scene/world.ts +++ /dev/null @@ -1,117 +0,0 @@ -// The scene's world constants. One owner: the shader prelude below is generated from this -// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the -// sphere-trace step safe are computed beside the numbers they bound. - -// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored -// CultMath.glsl (provenance in CultLib math.Interval.cs). -export const SNOISE_LIPSCHITZ = 7.9640074; - -export const WORLD = { - far: 1400, - // Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)). - amp: 70, - freq: 1 / 520, - octaves: 4, - lacunarity: 2.0, - gain: 0.45, - // Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login - // camera stays inside plateauInner, so it can never end up inside the terrain. - plateauInner: 135, - plateauOuter: 380, - erodeAmp: 2.5, - erodeFreq: 1 / 38, - // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. - erodeSide: [1.7, 0, 1] as const, - erodeNorm: 0.5, - // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a - // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. - // The sup was measured, not proved. The shader passes roughness n = 0 as a literal, so the doc - // envelope max(10, 1.25|side|)/(1-n) needs no 1/(1-n) factor here; if n ever becomes nonzero, - // divide this bound by (1 - n). - // |value| <= 1 holds by construction (the output is a normalized phasor). - // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. - phacelleGradBound: 7.0 as number, - // World tree at the origin. - tree: { - trunkHeight: 38, - baseRadius: 3.4, - topRadius: 1.9, - canopyY: 52, - canopyRadius: [30, 17, 30] as const, - }, - // Atmosphere. - atmosphereFar: 1400, - cloudBase: 130, - cloudTop: 230, - fogDensity: 0.0028, - fogFalloff: 0.011, -} as const; - -const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); - -/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */ -function slopeSum(octaves: number, lacunarity: number, gain: number): number { - let s = 0; - for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i; - return s; -} - -/** Sum over octaves of gain^i: the fbm value bound. */ -function valueSum(octaves: number, gain: number): number { - let s = 0; - for (let i = 0; i < octaves; i += 1) s += gain ** i; - return s; -} - -export interface TerrainBounds { - /** Lipschitz bound of the envelope surface (no erosion). */ - slopeEnvelope: number; - /** Lipschitz bound of the eroded surface. */ - slopeFull: number; - /** Highest point the terrain can reach. */ - maxHeight: number; -} - -export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds { - const fbmMax = valueSum(w.octaves, w.gain); - const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope - const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain); - const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope; - const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound; - return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp }; -} - -/** The #defines every stage sees. */ -export function worldDefines(w: typeof WORLD = WORLD): string { - const b = terrainBounds(w); - const t = w.tree; - const lines: Array<[string, string]> = [ - ['W_FAR', f(w.far)], - ['W_AMP', f(w.amp)], - ['W_FREQ', f(w.freq)], - ['W_OCTAVES', String(w.octaves)], - ['W_LACUNARITY', f(w.lacunarity)], - ['W_GAIN', f(w.gain)], - ['W_PLATEAU_IN', f(w.plateauInner)], - ['W_PLATEAU_OUT', f(w.plateauOuter)], - ['W_ERODE_AMP', f(w.erodeAmp)], - ['W_ERODE_FREQ', f(w.erodeFreq)], - ['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`], - ['W_ERODE_NORM', f(w.erodeNorm)], - ['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'], - ['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))], - ['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))], - ['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))], - ['W_TRUNK_H', f(t.trunkHeight)], - ['W_TRUNK_R0', f(t.baseRadius)], - ['W_TRUNK_R1', f(t.topRadius)], - ['W_CANOPY_Y', f(t.canopyY)], - ['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`], - ['W_ATM_FAR', f(w.atmosphereFar)], - ['W_CLOUD_Y0', f(w.cloudBase)], - ['W_CLOUD_Y1', f(w.cloudTop)], - ['W_FOG_DENSITY', f(w.fogDensity)], - ['W_FOG_FALLOFF', f(w.fogFalloff)], - ]; - return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n'); -} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 9ec74305..d3dcde96 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,8 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { LogoMark } from '../../../components/logo/logoMark.css'; -import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; +import CinnySVG from '../../../../../public/res/svg/cinny.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -38,25 +37,24 @@ export function About({ requestClose }: AboutProps) { Ratatoskr logo - Ratatoskr + Cinny v4.12.8 - GameCult's Matrix client. + Yet another matrix client. - - - Based on{' '} - } > - Cinny - {' '} - 4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU - Affero General Public License v3; its source is at the Source Code link above. - + Support + +
diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 4fcdacdb..00128c8f 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -28,7 +28,7 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'ratatoskr-keys.txt'); + FileSaver.saveAs(blob, 'cinny-keys.txt'); }, [mx] ) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 35184656..b861a060 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -709,10 +709,6 @@ function DateAndTime() { function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [composerSpellcheck, setComposerSpellcheck] = useSetting( - settingsAtom, - 'composerSpellcheck' - ); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -728,15 +724,6 @@ function Editor() { after={} /> - - - } - /> - - - } - /> - - (deviceMemory !== undefined && deviceMemory < 4) || - (hardwareConcurrency !== undefined && hardwareConcurrency < 4); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts deleted file mode 100644 index 947d2bb5..00000000 --- a/src/app/features/soundscape/frame.ts +++ /dev/null @@ -1,108 +0,0 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head -// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows -// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human -// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until -// Bifrost's steward cuts the list. - -export type Estate = 'human' | 'mind' | 'persona'; -export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; -export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; -export type Size = 'S' | 'M' | 'L'; -export type Override = 'None' | 'Muted'; - -interface FrameBase { - mind: string; - estate: Estate; - override: Override; - seq: number; - /** Producer observation time, whole seconds (grammar: 1 s resolution). */ - at: number; -} -/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */ -export interface NeutralFrame extends FrameBase { - neutral: true; - size?: undefined; - kind?: undefined; - energy?: undefined; -} -export interface LiveFrame extends FrameBase { - neutral: false; - size: Size; - kind: Kind; - energy: Energy; -} -export type ActivityFrame = NeutralFrame | LiveFrame; - -const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; -const KINDS: readonly Kind[] = [ - 'Idle', - 'Reading', - 'Thinking', - 'Working', - 'Speaking', - 'Waiting', - 'Alert', -]; -const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak']; -const SIZES: readonly Size[] = ['S', 'M', 'L']; - -/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */ -export const STALE_AFTER_S = 10; - -const oneOf = (set: readonly T[], v: unknown): T | undefined => - set.find((s) => s === v); - -/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ -export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; - -/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ -const clampKind = (estate: Estate, kind: unknown): Kind | undefined => - estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); - -/** A human's energy is three buckets: Peak reads as High. */ -const clampEnergy = (estate: Estate, energy: Energy): Energy => - estate === 'human' && energy === 'Peak' ? 'High' : energy; - -/** - * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a - * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent - * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). - */ -export const readFrame = (raw: unknown): ActivityFrame | undefined => { - if (typeof raw !== 'object' || raw === null) return undefined; - const r = raw as Record; - if (typeof r.neutral !== 'boolean') return undefined; - const estate = oneOf(ESTATES, r.estate); - const override = - r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !override) return undefined; - if (typeof r.mind !== 'string' || r.mind === '') return undefined; - if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number }; - if (r.neutral) return { ...base, neutral: true }; - const kind = clampKind(estate, r.kind); - const energy = oneOf(ENERGIES, r.energy); - const size = oneOf(SIZES, r.size); - if (!kind || !energy || !size) return undefined; - return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) }; -}; - -/** Latest frame per mind (S1: consumers drop seq <= last). */ -export class FrameTable { - private frames = new Map(); - - offer(raw: unknown): boolean { - const f = readFrame(raw); - if (!f) return false; - const last = this.frames.get(f.mind); - if (last && f.seq <= last.seq) return false; - this.frames.set(f.mind, f); - return true; - } - - /** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */ - fresh(nowS: number): ActivityFrame[] { - return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S); - } -} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts deleted file mode 100644 index 027cc27a..00000000 --- a/src/app/features/soundscape/scene.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ActivityFrame, Size } from './frame'; - -/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */ -export const PLACEHOLDER_SIZE_SCALE: Record = { S: 0.7, M: 1, L: 1.4 }; - -/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */ -export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] => - frames.filter((f) => !f.neutral); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts deleted file mode 100644 index e1d23010..00000000 --- a/src/app/features/soundscape/socket.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FrameTable } from './frame'; -import { drawnCreatures } from './scene'; -import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices'; -import { isWeakDevice, type DeviceHints } from './device'; - -/** - * Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the - * Heimdall session feed exists the only source is `silentSource`. A source never reads a room's - * member list (A1): being in a room says nothing about being active. - */ -export interface ActivitySource { - subscribe(onFrame: (raw: unknown) => void): () => void; -} -const noop = () => undefined; -export const silentSource: ActivitySource = { subscribe: () => noop }; - -/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */ -export interface VoiceSink { - play(commands: readonly VoiceCommand[]): void; -} - -export interface AudioSocketDeps { - source: ActivitySource; - sink: VoiceSink; - prefs: () => SoundscapePrefs; - device: () => DeviceHints; - /** Whole seconds. */ - nowS: () => number; -} - -export const createAudioSocket = (deps: AudioSocketDeps) => { - const table = new FrameTable(); - const unsubscribe = deps.source.subscribe((raw) => table.offer(raw)); - return { - /** Called by the scheduler tick; a hidden page simply stops calling it (S5). */ - tick(): VoiceCommand[] { - const prefs = deps.prefs(); - const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) }; - const commands = planVoices(table.fresh(deps.nowS()), effective); - deps.sink.play(commands); - return commands; - }, - /** What the orbit renderer draws: the same table, neutral removed. */ - drawn: () => drawnCreatures(table.fresh(deps.nowS())), - close: unsubscribe, - }; -}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts deleted file mode 100644 index 294456a1..00000000 --- a/src/app/features/soundscape/soundscape.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; -import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; -import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; -import { isWeakDevice } from './device'; -import { createAudioSocket, silentSource, type ActivitySource } from './socket'; - -const frame = (o: Partial = {}): LiveFrame => ({ - mind: 'void', - estate: 'mind', - kind: 'Working', - energy: 'Mid', - size: 'M', - neutral: false, - override: 'None', - seq: 1, - at: 100, - ...o, -}); -const neutralFrame = (o: Partial = {}): ActivityFrame => - ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); -/** A raw frame as a producer sends it, with the named keys omitted. */ -const without = (f: object, ...keys: string[]) => - Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); -const noop = () => undefined; -const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; - -describe('neutral overrides everything', () => { - it('draws nothing and sounds nothing for any kind, energy, size or estate', () => { - const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => - (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => - (['High', 'Peak'] as const).flatMap((energy) => - (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) - ) - ) - ); - expect(cases).toHaveLength(54); - cases.forEach((f) => { - expect(drawnCreatures([f])).toEqual([]); - expect(planVoices([f], on)).toEqual([]); - }); - }); - it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => { - const t = new FrameTable(); - t.offer(frame({ at: 100 })); - expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]); - expect(drawnCreatures(t.fresh(100)).length).toBe(1); - }); -}); - -describe('neutral is read first', () => { - it('yields no size, kind or energy, even when the producer sent them', () => { - (['human', 'mind', 'persona'] as const).forEach((estate) => { - const f = readFrame({ - ...frame({ estate, kind: 'Speaking', energy: 'High' }), - neutral: true, - }); - expect(f).toBeDefined(); - expect(f?.neutral).toBe(true); - expect(f?.size).toBeUndefined(); - expect(Object.keys(f ?? {})).not.toContain('size'); - expect(f?.kind).toBeUndefined(); - expect(f?.energy).toBeUndefined(); - expect(Object.keys(f ?? {})).not.toContain('kind'); - expect(Object.keys(f ?? {})).not.toContain('energy'); - }); - }); - it('a neutral human with kind omitted is not an Idle', () => { - const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); - const f = readFrame({ ...bare, neutral: true }); - expect(f?.kind).toBeUndefined(); - expect(drawnCreatures([f as ActivityFrame])).toEqual([]); - expect(planVoices([f as ActivityFrame], on)).toEqual([]); - }); -}); - -describe('human kinds are the provisional subset', () => { - it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { - expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); - }); - it('keeps Idle, Working and Speaking', () => { - (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => - expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) - ); - }); - it('an omitted kind is Idle for a non-neutral human only', () => { - const noKind = without(frame({ estate: 'human' }), 'kind'); - expect(readFrame(noKind)?.kind).toBe('Idle'); - expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); - }); - it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { - (['mind', 'persona'] as const).forEach((estate) => { - (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( - (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) - ); - const noKind = without(frame({ estate }), 'kind'); - expect(readFrame(noKind)).toBeUndefined(); - }); - }); -}); - -describe('human energy is clamped to three buckets by the consumer', () => { - it('turns Peak into High for humans only', () => { - expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); - expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak'); - expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak'); - }); - it('keeps the clamp through the voice plan', () => { - const t = new FrameTable(); - t.offer(frame({ estate: 'human', energy: 'Peak' })); - expect(planVoices(t.fresh(100), on)[0].energy).toBe('High'); - }); -}); - -describe('activity is a stub, never the room member list', () => { - it('the silent source and the socket yield nothing, however many members a room has', () => { - const members = ['@a:x', '@b:x', '@c:x']; - const sock = createAudioSocket({ - source: silentSource, - sink: { play: noop }, - prefs: () => on, - device: () => ({}), - nowS: () => 100, - }); - expect(members.length).toBeGreaterThan(0); - expect(sock.tick()).toEqual([]); - expect(sock.drawn()).toEqual([]); - }); - it('only frames the source pushes produce voices', () => { - const pushes: ((raw: unknown) => void)[] = []; - const source: ActivitySource = { - subscribe: (cb) => { - pushes.push(cb); - return noop; - }, - }; - const sock = createAudioSocket({ - source, - sink: { play: noop }, - prefs: () => on, - device: () => ({}), - nowS: () => 100, - }); - pushes[0](frame({ mind: 'aqua' })); - expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']); - }); -}); - -describe('no vote weights', () => { - it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => { - const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' }); - expect(Object.keys(f ?? {}).sort()).toEqual( - ['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort() - ); - const t = new FrameTable(); - t.offer({ ...frame(), weight: 9 }); - expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5); - }); -}); - -describe('size scales are placeholders', () => { - it('live in the renderer table, not in the frame, and change nothing about voices', () => { - expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 }); - const s = planVoices([frame({ size: 'S' })], on); - const l = planVoices([frame({ size: 'L' })], on); - expect(s).toEqual(l); - }); -}); - -describe('socket defaults and limits', () => { - it('ships off, muted, with the proposed cap', async () => { - vi.stubGlobal('localStorage', { getItem: () => null }); - const { getSettings } = await import('../../state/settings'); - const s = getSettings(); - expect(s.soundscapeEnabled).toBe(false); - expect(s.soundscapeMuted).toBe(true); - expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP); - }); - it('master mute, disabled flag and zero volume all silence', () => { - const f = [frame()]; - expect(planVoices(f, { ...on, muted: true })).toEqual([]); - expect(planVoices(f, { ...on, enabled: false })).toEqual([]); - expect(planVoices(f, { ...on, volume: 0 })).toEqual([]); - }); - it('caps voices, dropping the lowest energy first', () => { - const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) => - frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] }) - ); - const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind); - expect(voices).toHaveLength(4); - expect(voices).not.toContain('a'); - }); - it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => { - const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })]; - expect(planVoices(f, on)).toEqual([]); - expect(drawnCreatures(f)).toHaveLength(2); - }); - it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => { - expect(isWeakDevice({ deviceMemory: 2 })).toBe(true); - expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true); - expect(isWeakDevice({})).toBe(false); - const played: number[] = []; - const sock = createAudioSocket({ - source: { - subscribe: (cb) => { - cb(frame()); - return noop; - }, - }, - sink: { play: (c) => played.push(c.length) }, - prefs: () => on, - device: () => ({ deviceMemory: 1 }), - nowS: () => 100, - }); - expect(sock.tick()).toEqual([]); - expect(played).toEqual([0]); - }); - it('drops frames with seq <= last, and malformed frames', () => { - const t = new FrameTable(); - expect(t.offer(frame({ seq: 5 }))).toBe(true); - expect(t.offer(frame({ seq: 5 }))).toBe(false); - expect(t.offer(frame({ seq: 4 }))).toBe(false); - expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); - }); -}); - -describe('neutral carries no size', () => { - it('reads without a size, and a live frame without one is malformed', () => { - const raw = frame(); - expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined(); - expect(readFrame(without(raw, 'size'))).toBeUndefined(); - }); -}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts deleted file mode 100644 index 676ae853..00000000 --- a/src/app/features/soundscape/voices.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; - -/** Grammar V3 (proposed, unmeasured): voices audible at once. */ -export const DEFAULT_VOICE_CAP = 4; - -/** Local listener preferences. Never written back into a frame (O3). */ -export interface SoundscapePrefs { - /** The feature flag. Default off. */ - enabled: boolean; - /** Master mute. Default on. */ - muted: boolean; - /** 0..1 */ - volume: number; - voiceCap: number; -} - -export interface VoiceCommand { - mind: string; - kind: Kind; - energy: Energy; - gain: number; -} - -const ENERGY_RANK: Record = { Low: 0, Mid: 1, High: 2, Peak: 3 }; -/** Grammar section 3: Idle and Waiting default silent. */ -const SILENT_KINDS: ReadonlySet = new Set(['Idle', 'Waiting']); - -const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0); - -/** - * Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2); - * over the cap the lowest energy drops first (V3, ties by mind id so the result is stable). - * Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight. - * Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen. - */ -export const planVoices = ( - frames: readonly ActivityFrame[], - prefs: SoundscapePrefs -): VoiceCommand[] => { - if (!prefs.enabled || prefs.muted) return []; - const gain = clamp01(prefs.volume); - if (gain === 0) return []; - const cap = Math.max(0, Math.floor(prefs.voiceCap)); - return frames - .filter( - (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) - ) - .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) - .slice(0, cap) - .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); -}; diff --git a/src/app/features/steward-groups/stewardGroups.test.ts b/src/app/features/steward-groups/stewardGroups.test.ts new file mode 100644 index 00000000..d0d4b7c3 --- /dev/null +++ b/src/app/features/steward-groups/stewardGroups.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups'; + +const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId }); +const space = (roomId: string) => ({ roomId, space: true as const }); + +const base = { + spaceId: '!top', + isGroupOpen: () => true, + keepVisible: () => false, +}; + +const names = (out: StewardEntry<{ roomId: string }>[]) => + out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId)); + +describe('groupByStewards', () => { + const stewards: Record = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' }; + const stewardOf = (id: string) => stewards[id]; + + it('groups by steward, sorted by name, with unstewarded rooms first', () => { + const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], { + ...base, + stewardOf, + }); + expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']); + }); + + it('collapsed groups hide rooms except kept-visible ones', () => { + const out = groupByStewards([room('!a'), room('!b'), room('!c')], { + ...base, + stewardOf, + isGroupOpen: () => false, + keepVisible: (id) => id === '!c', + }); + expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']); + }); + + it('degrades to the original order when no room has steward state', () => { + const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], { + ...base, + stewardOf: () => undefined, + }); + expect(names(out)).toEqual(['!1', '!s', '!2']); + }); + + it('does not merge runs separated by a space header', () => { + const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], { + ...base, + stewardOf, + }); + expect(out.filter((e) => e.type === 'group')).toHaveLength(2); + }); +}); + +describe('parseSteward', () => { + it('accepts a string and rejects junk', () => { + expect(parseSteward({ steward: ' Void ' })).toBe('Void'); + expect(parseSteward({ steward: 3 })).toBeUndefined(); + expect(parseSteward({ steward: ' ' })).toBeUndefined(); + expect(parseSteward(undefined)).toBeUndefined(); + expect(parseSteward(null)).toBeUndefined(); + }); +}); diff --git a/src/app/features/steward-groups/stewardGroups.ts b/src/app/features/steward-groups/stewardGroups.ts new file mode 100644 index 00000000..c95dd935 --- /dev/null +++ b/src/app/features/steward-groups/stewardGroups.ts @@ -0,0 +1,91 @@ +/** + * Splits runs of rooms that carry an `org.gamecult.steward` state event into + * collapsible per-steward groups. Pure: the caller supplies the lookups, so the + * grouping is testable without a Matrix client. + */ +export const STEWARD_EVENT_TYPE = 'org.gamecult.steward'; + +export type HierarchyLike = { roomId: string; parentId?: string; space?: true }; + +export type StewardEntry = + | { type: 'item'; item: T } + | { type: 'group'; id: string; label: string; open: boolean; count: number }; + +export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) => + `${spaceId}|${parentId}|steward:${steward}`; + +export type StewardGroupOptions = { + spaceId: string; + /** steward name of a room, or undefined when the room has no (valid) steward state */ + stewardOf: (roomId: string) => string | undefined; + isGroupOpen: (groupId: string) => boolean; + /** rooms that stay visible inside a collapsed group (selected, unread, in a call) */ + keepVisible: (roomId: string) => boolean; +}; + +const byLabel = (a: string, b: string) => + a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b); + +export const groupByStewards = ( + items: T[], + { spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions +): StewardEntry[] => { + const out: StewardEntry[] = []; + + const flush = (run: T[]) => { + if (run.length === 0) return; + const groups = new Map(); + const loose: T[] = []; + run.forEach((item) => { + const steward = stewardOf(item.roomId); + if (steward === undefined) { + loose.push(item); + return; + } + const list = groups.get(steward); + if (list) list.push(item); + else groups.set(steward, [item]); + }); + + // No steward state anywhere in this run: leave the list exactly as Cinny built it. + if (groups.size === 0) { + run.forEach((item) => out.push({ type: 'item', item })); + return; + } + + loose.forEach((item) => out.push({ type: 'item', item })); + Array.from(groups.keys()) + .sort(byLabel) + .forEach((steward) => { + const rooms = groups.get(steward) ?? []; + const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward); + const open = isGroupOpen(id); + out.push({ type: 'group', id, label: steward, open, count: rooms.length }); + rooms + .filter((item) => open || keepVisible(item.roomId)) + .forEach((item) => out.push({ type: 'item', item })); + }); + }; + + let run: T[] = []; + items.forEach((item) => { + if (item.space) { + flush(run); + run = []; + out.push({ type: 'item', item }); + return; + } + run.push(item); + }); + flush(run); + return out; +}; + +/** Reads `steward` from raw state event content; anything unusable is "no steward". */ +export const parseSteward = (content: unknown): string | undefined => { + if (typeof content !== 'object' || content === null) return undefined; + const { steward } = content as { steward?: unknown }; + if (typeof steward !== 'string') return undefined; + const trimmed = steward.trim(); + return trimmed === '' ? undefined : trimmed; +}; diff --git a/src/app/features/steward-groups/useStewardGroups.ts b/src/app/features/steward-groups/useStewardGroups.ts new file mode 100644 index 00000000..ae1d2a3d --- /dev/null +++ b/src/app/features/steward-groups/useStewardGroups.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; +import { atomWithStorage } from 'jotai/utils'; +import { useAtom } from 'jotai'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + groupByStewards, + HierarchyLike, + parseSteward, + StewardEntry, + STEWARD_EVENT_TYPE, +} from './stewardGroups'; + +// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere. +const stewardGroupChoicesAtom = atomWithStorage>( + 'gamecultStewardGroupOpen', + {} +); + +type Options = { + mx: MatrixClient; + spaceId: string; + items: T[]; + keepVisible: (roomId: string) => boolean; +}; + +export const useStewardGroups = ({ + mx, + spaceId, + items, + keepVisible, +}: Options) => { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const [choices, setChoices] = useAtom(stewardGroupChoicesAtom); + const [stateVersion, setStateVersion] = useState(0); + + useEffect(() => { + const onState = (mEvent: MatrixEvent) => { + if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1); + }; + mx.on(RoomStateEvent.Events, onState); + return () => { + mx.removeListener(RoomStateEvent.Events, onState); + }; + }, [mx]); + + const stewardOf = useCallback( + (roomId: string) => + // stateVersion is read so a late-arriving steward event re-derives this lookup + stateVersion >= 0 + ? parseSteward( + mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent() + ) + : undefined, + [mx, stateVersion] + ); + + const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]); + + const entries: StewardEntry[] = useMemo( + () => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }), + [items, spaceId, stewardOf, isGroupOpen, keepVisible] + ); + + const toggleGroup = useCallback( + (id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })), + [setChoices, mobile] + ); + + return { entries, toggleGroup }; +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6c793e65..e5fc6cc6 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,5 +1,4 @@ import { createContext, useContext } from 'react'; -import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { enabled?: boolean; @@ -19,11 +18,6 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; - - backgroundScene?: BackgroundSceneConfig; - - // Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo). - previewDemo?: boolean; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 11f379dc..c02b0e94 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,6 +15,7 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, + isRoomAlias, isRoomId, isServerName, isUserId, @@ -25,7 +26,6 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -227,7 +227,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), + exe: async (payload) => { + const rawIds = splitWithSpace(payload); + const roomIdOrAliases = rawIds.filter( + (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) + ); + roomIdOrAliases.forEach(async (idOrAlias) => { + await mx.joinRoom(idOrAlias); + }); + }, }, [Command.Leave]: { name: Command.Leave, diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts new file mode 100644 index 00000000..16aba225 --- /dev/null +++ b/src/app/hooks/useSidebarItems.ts @@ -0,0 +1,138 @@ +import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { useMatrixClient } from './useMatrixClient'; +import { getAccountData, isSpace } from '../utils/room'; +import { Membership } from '../../types/matrix/room'; +import { useAccountDataCallback } from './useAccountDataCallback'; + +export type ISidebarFolder = { + name?: string; + id: string; + content: string[]; +}; +export type TSidebarItem = string | ISidebarFolder; +export type SidebarItems = Array; + +export type InCinnySpacesContent = { + shortcut?: string[]; + sidebar?: SidebarItems; +}; + +export const parseSidebar = ( + mx: MatrixClient, + orphanSpaces: string[], + content?: InCinnySpacesContent +) => { + const sidebar = content?.sidebar ?? content?.shortcut ?? []; + const orphans = new Set(orphanSpaces); + + const items: SidebarItems = []; + + const safeToAdd = (spaceId: string): boolean => { + if (typeof spaceId !== 'string') return false; + const space = mx.getRoom(spaceId); + if (space?.getMyMembership() !== Membership.Join) return false; + return isSpace(space); + }; + + sidebar.forEach((item) => { + if (typeof item === 'string') { + if (safeToAdd(item) && !items.includes(item)) { + orphans.delete(item); + items.push(item); + } + return; + } + if ( + typeof item === 'object' && + typeof item.id === 'string' && + Array.isArray(item.content) && + !items.find((i) => (typeof i === 'string' ? false : i.id === item.id)) + ) { + const safeContent = item.content.filter(safeToAdd); + safeContent.forEach((i) => orphans.delete(i)); + items.push({ + ...item, + content: Array.from(new Set(safeContent)), + }); + } + }); + + orphans.forEach((spaceId) => items.push(spaceId)); + return items; +}; + +export const useSidebarItems = ( + orphanSpaces: string[] +): [SidebarItems, Dispatch>] => { + const mx = useMatrixClient(); + + const [sidebarItems, setSidebarItems] = useState(() => { + const inCinnySpacesContent = getAccountData( + mx, + AccountDataEvent.CinnySpaces + )?.getContent(); + return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); + }); + + useEffect(() => { + const inCinnySpacesContent = getAccountData( + mx, + AccountDataEvent.CinnySpaces + )?.getContent(); + setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); + }, [mx, orphanSpaces]); + + useAccountDataCallback( + mx, + useCallback( + (mEvent) => { + if (mEvent.getType() === AccountDataEvent.CinnySpaces) { + const newContent = mEvent.getContent(); + setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); + } + }, + [mx, orphanSpaces] + ) + ); + + return [sidebarItems, setSidebarItems]; +}; + +export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { + const newItems: SidebarItems = items + .map((item) => { + if (typeof item === 'string') { + if (item === roomId) return null; + return item; + } + if (item.content.includes(roomId)) { + const newContent = item.content.filter((id) => id !== roomId); + if (newContent.length === 0) return null; + return { + ...item, + content: newContent, + }; + } + return item; + }) + .filter((item) => item !== null) as SidebarItems; + + return newItems; +}; + +export const makeCinnySpacesContent = ( + mx: MatrixClient, + items: SidebarItems +): InCinnySpacesContent => { + const currentInSpaces = + getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; + + const newSpacesContent: InCinnySpacesContent = { + ...currentInSpaces, + sidebar: items, + }; + + return newSpacesContent; +}; diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts deleted file mode 100644 index c32979ae..00000000 --- a/src/app/hooks/useSkyState.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useSyncExternalStore } from 'react'; -import { - getResolvedSkyState, - getSkyState, - subscribe, - type SkyState, -} from '../features/scene/skyState'; -import type { FixedSkyState } from '../features/scene/skyBand'; -import { readCapabilities, resolveTier } from '../features/scene/tier'; -import { useClientConfig } from './useClientConfig'; - -/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `useResolvedSkyState`. */ -export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); - -/** The sky being drawn: day, dusk or night. Unlike the choice it changes as the clock crosses, so it re-renders. */ -export const useResolvedSkyState = (): FixedSkyState => - useSyncExternalStore(subscribe, getResolvedSkyState); - -/** Whether a background scene runs on this device and config (the tier the scene host will mount). */ -export const useSceneOn = (): boolean => - resolveTier(useClientConfig().backgroundScene, readCapabilities(navigator)) !== 'off'; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 59889c1c..ad34e3f4 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -276,8 +276,7 @@ export type FetchSpaceHierarchyLevelData = { }; export const useFetchSpaceHierarchyLevel = ( roomId: string, - enable: boolean, - maxDepth = 1 + enable: boolean ): FetchSpaceHierarchyLevelData => { const mx = useMatrixClient(); const pageNoRef = useRef(0); @@ -287,13 +286,13 @@ export const useFetchSpaceHierarchyLevel = ( string[], string | undefined > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam), - [roomId, maxDepth, mx] + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), + [roomId, mx] ); const queryResponse = useInfiniteQuery({ refetchOnMount: enable, - queryKey: [roomId, 'hierarchy_level', maxDepth], + queryKey: [roomId, 'hierarchy_level'], initialPageParam: undefined, queryFn: fetchLevel, getNextPageParam: (result) => { diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 60f89757..4bfe01da 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,19 +1,10 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { - butterTheme, - darkTheme, - midnightSkyTheme, - noondaySkyTheme, - silverTheme, -} from '../../colors.css'; +import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css'; import '../../gamecult.css'; -import '../../sky.css'; -import { defaultThemeId, settingsAtom } from '../state/settings'; +import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; -import { sceneThemeId } from '../features/scene/skyTheme'; -import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -48,23 +39,14 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; -export const MidnightSkyTheme: Theme = { - id: 'midnight-sky', +export const GameCultTheme: Theme = { + id: 'gamecult-theme', kind: ThemeKind.Dark, - classNames: ['gamecult-theme', 'midnight-sky', midnightSkyTheme, onDarkFontWeight, 'prism-dark'], -}; - -export const NoondaySkyTheme: Theme = { - id: 'noonday-sky', - kind: ThemeKind.Light, - classNames: ['gamecult-theme', 'noonday-sky', noondaySkyTheme, onLightFontWeight, 'prism-light'], + classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], }; export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo( - () => [MidnightSkyTheme, NoondaySkyTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], - [] - ); + const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); return themes; }; @@ -72,8 +54,7 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ - [MidnightSkyTheme.id]: 'Midnight Sky', - [NoondaySkyTheme.id]: 'Noonday Sky', + [GameCultTheme.id]: 'GameCult', [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -102,42 +83,24 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -const toTheme = (id: ReturnType): Theme | undefined => - id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; - -/** A theme the person chose: the system scheme, or one other than the default. A saved Midnight Sky is the default. */ -const chosenTheme = (useSystemTheme: boolean, themeId?: string) => - useSystemTheme || (themeId !== undefined && themeId !== defaultThemeId); - -/** The theme the sky asks for on a page with no theme of its own (signed out), or undefined for none. */ -export const useSkyTheme = (): Theme | undefined => - toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); - export const useActiveTheme = (): Theme => { - const sky = useSkyState(); - const resolved = useResolvedSkyState(); - const sceneOn = useSceneOn(); const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); - // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. - const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); - - if (skyTheme) return skyTheme; if (!systemTheme) { - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme; + const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme; return selectedTheme; } const selectedTheme = systemThemeKind === ThemeKind.Dark - ? themes.find((theme) => theme.id === darkThemeId) ?? MidnightSkyTheme - : themes.find((theme) => theme.id === lightThemeId) ?? NoondaySkyTheme; + ? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme + : themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme; return selectedTheme; }; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 7b0a1f91..52ec7f20 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -11,7 +11,6 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; -import { BackgroundScene } from '../features/scene/BackgroundScene'; import { useCompositionEndTracking } from '../hooks/useComposingCheck'; const queryClient = new QueryClient(); @@ -36,7 +35,6 @@ function App() { > {(clientConfig) => ( - diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 225b1b80..ca947ac6 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,6 +1,28 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; +import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; + +type MobileFriendlyClientNavProps = { + children: ReactNode; +}; +export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { + const screenSize = useScreenSizeContext(); + const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); + const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); + const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); + const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); + const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); + + if ( + screenSize === ScreenSize.Mobile && + !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) + ) { + return null; + } + + return children; +} type MobileFriendlyPageNavProps = { path: string; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 159162dd..4de42081 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,19 +40,19 @@ import { getSpaceLobbyPath, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; -import { EstatePageNav } from './client/EstatePageNav'; +import { SidebarNav } from './client/SidebarNav'; import { PageRoot } from '../components/page'; import { ScreenSize } from '../hooks/useScreenSize'; -import { MobileFriendlyPageNav } from './MobileFriendly'; +import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; @@ -127,7 +127,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - + + + + } + > @@ -151,7 +157,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) }> + + + + } + > } @@ -172,7 +184,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) }> + + + + } + > } @@ -192,7 +210,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={SPACE_PATH} element={ - }> + + + + } + > diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index b7c50d93..983ca684 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,24 +1,20 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { - MidnightSkyTheme, + GameCultTheme, ThemeContextProvider, useActiveTheme, - useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { - // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the - // system scheme. - const theme = useSkyTheme() ?? MidnightSkyTheme; - useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - document.body.classList.add(...theme.classNames); - }, [theme]); + // The GameCult identity is single-theme: signed-out pages ignore the system scheme. + document.body.classList.add(...GameCultTheme.classNames); + }, []); return null; } diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 2b888f55..b0b94319 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -5,26 +5,20 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About - Source + v4.12.8 - - Based on Cinny + + Twitter Powered by Matrix diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 49c7b774..3943f42d 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -20,9 +20,7 @@ import { } from '../../hooks/useClientConfig'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; -import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; -import { LogoMark } from '../../components/logo/logoMark.css'; -import { SkyStateControl } from '../../features/scene/SkyStateControl'; +import CinnySVG from '../../../../public/res/svg/cinny.svg'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -136,14 +134,9 @@ export function AuthLayout() {
- Ratatoskr logo - Ratatoskr + Cinny Logo + Cinny -
diff --git a/src/app/pages/auth/TrainingNotice.tsx b/src/app/pages/auth/TrainingNotice.tsx deleted file mode 100644 index 6aeaf9d4..00000000 --- a/src/app/pages/auth/TrainingNotice.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Box, Checkbox, Text, color, config } from 'folds'; -import React from 'react'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { trainingNoticeText } from '../../utils/trainingNotice'; - -type TrainingNoticeProps = { - checked: boolean; - onToggle: () => void; -}; - -// Registration only: ticking it is the newcomer's acceptance of the current notice version. -export function TrainingNotice({ checked, onToggle }: TrainingNoticeProps) { - const { previewDemo } = useClientConfig(); - return ( - - - - {trainingNoticeText(previewDemo)} - - - ); -} diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts deleted file mode 100644 index 97febbbb..00000000 --- a/src/app/pages/auth/footerBand.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import zlib from 'zlib'; -import { compile, processVanillaFile } from '@vanilla-extract/integration'; -import { AuthFooter } from './styles.css'; - -// The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. -const emitted = async () => { - const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; - const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); - const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; - return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); -}; -const declarations = (css: string, selector: string) => - [...css.matchAll(/^([^{}\n]+) \{\n([^}]*)\}/gm)] - .filter((m) => m[1] === selector) - .map((m) => - m[2] - .split('\n') - .map((l) => l.trim().replace(/;$/, '')) - .filter(Boolean) - ); - -describe('the sign-in footer band, as emitted', () => { - const over = `:root[data-scene] .${AuthFooter}`; - - it('is one rule over the scene, so no later rule can override its mask', async () => { - expect(declarations(await emitted(), over)).toHaveLength(1); - }); - - it('puts the plate fill before the color-mix tint, so a browser without color-mix keeps the fill', async () => { - const [rule] = declarations(await emitted(), over); - const fills = rule.filter((d) => d.startsWith('background-color:')); - expect(fills).toHaveLength(2); - expect(fills[0]).toBe('background-color: var(--sky-bubble, #0d1a26)'); - expect(fills[1]).toContain('color-mix('); - }); - - it('blurs what is behind it by 12px, the radius of the chat bubbles', async () => { - const [rule] = declarations(await emitted(), over); - expect(rule.filter((d) => d.startsWith('backdrop-filter'))).toEqual([ - 'backdrop-filter: blur(12px)', - ]); - }); - - it('declares exactly these properties, so nothing else (a shadow, a stray override) rides along', async () => { - const [rule] = declarations(await emitted(), over); - // -webkit-backdrop-filter is the one prefixed property, for Safari 14; it must blur exactly as the standard one does. - const blur = (prop: string) => - rule - .find((d) => d.startsWith(`${prop}:`)) - ?.split(':')[1] - .trim(); - expect(blur('-webkit-backdrop-filter')).toBe(blur('backdrop-filter')); - expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([ - '-webkit-backdrop-filter', - 'backdrop-filter', - 'background-color', - 'background-color', - 'color', - 'mask-composite', - 'mask-image', - 'padding-bottom', - 'padding-top', - ]); - }); - - it('fades along both axes over a soft ramp, not a hard edge', async () => { - const [rule] = declarations(await emitted(), over); - const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; - expect(rule).toContain('mask-composite: intersect'); - ['bottom', 'right'].forEach((axis) => { - const ramp = mask.match( - new RegExp( - `linear-gradient\\(to ${axis}, transparent, #000 (\\d+)px, #000 calc\\(100% - (\\d+)px\\), transparent\\)` - ) - ); - expect(ramp).not.toBeNull(); - // Each end ramps from clear to opaque over at least 8px. - expect(Number(ramp?.[1])).toBeGreaterThanOrEqual(8); - expect(Number(ramp?.[2])).toBeGreaterThanOrEqual(8); - }); - }); - - it('has no border or radius, in the scene rule or in the base footer rule', async () => { - const css = await emitted(); - const [rule] = declarations(css, over); - const base = declarations(css, `.${AuthFooter}`); - expect(base).toHaveLength(1); - [rule, base[0]].forEach((r) => { - expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); - }); - // The base rule is only the footer's padding: nothing visible (a shadow, a fill) is hidden outside the scene rule. - expect(base[0].map((d) => d.slice(0, d.indexOf(':')))).toEqual(['padding']); - }); -}); diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 9d890bef..2f04a733 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -36,7 +36,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar export function Login() { const server = useAuthServer(); - const { hashRouter, previewDemo } = useClientConfig(); + const { hashRouter } = useClientConfig(); const { loginFlows } = useAuthFlows(); const [searchParams] = useSearchParams(); const loginSearchParams = useLoginSearchParams(searchParams); @@ -65,7 +65,7 @@ export function Login() { {parsedFlows.password && ( <> diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 3c8641d9..62f46dd2 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: username, }, password, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; @@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: mxIdUsername, }, password, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; const handleEmailLogin = (email: string, password: string) => { @@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog address: email, }, password, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 0541742d..761d5dc5 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) { startLogin(baseUrl, { type: 'm.login.token', token, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }, [baseUrl, token, startLogin]); diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index 99feed1a..9f173422 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -32,7 +32,6 @@ import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsy import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil'; import { FieldError } from '../FiledError'; -import { TrainingNotice } from '../TrainingNotice'; import { AutoDummyStageDialog, AutoTermsStageDialog, @@ -110,7 +109,7 @@ function RegisterUIAFlow({ auth: authDict, password, username, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }, [onRegister, formData] @@ -193,7 +192,6 @@ export function PasswordRegisterForm({ const [formData, setFormData] = useState(); const [ongoingFlow, setOngoingFlow] = useState(); - const [trainingAck, setTrainingAck] = useState(false); const [registerEmailState, registerEmail] = useRegisterEmail(mx); @@ -252,7 +250,7 @@ export function PasswordRegisterForm({ auth: { session: authData.session, }, - initial_device_display_name: 'Ratatoskr Web', + initial_device_display_name: 'Cinny Web', }); }; @@ -391,8 +389,7 @@ export function PasswordRegisterForm({ )} - setTrainingAck((ack) => !ack)} /> - + diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx new file mode 100644 index 00000000..04edf109 --- /dev/null +++ b/src/app/pages/client/direct/Direct.tsx @@ -0,0 +1,280 @@ +import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { + Avatar, + Box, + Button, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + config, + toRem, +} from 'folds'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import FocusTrap from 'focus-trap-react'; +import { useNavigate } from 'react-router-dom'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { factoryRoomIdByActivity } from '../../../utils/sort'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavEmptyCenter, + NavEmptyLayout, + NavItem, + NavItemContent, +} from '../../../components/nav'; +import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { VirtualTile } from '../../../components/virtualizer'; +import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; +import { useDirectRooms } from './useDirectRooms'; +import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { markAsRead } from '../../../utils/notifications'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; + +type DirectMenuProps = { + requestClose: () => void; +}; +const DirectMenu = forwardRef(({ requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const orphanRooms = useDirectRooms(); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +function DirectHeader() { + const [menuAnchor, setMenuAnchor] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + <> + + + + + Direct Messages + + + + + + + + + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + + ); +} + +function DirectEmpty() { + const navigate = useNavigate(); + + return ( + + } + title={ + + No Direct Messages + + } + content={ + + You do not have any direct messages yet. + + } + options={ + + } + /> + + ); +} + +const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); +export function Direct() { + const mx = useMatrixClient(); + useNavToActivePathMapper('direct'); + const scrollRef = useRef(null); + const directs = useDirectRooms(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const navigate = useNavigate(); + + const createDirectSelected = useDirectCreateSelected(); + + const selectedRoomId = useSelectedRoom(); + const noRoomToDisplay = directs.length === 0; + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const sortedDirects = useMemo(() => { + const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); + if (closedCategories.has(DEFAULT_CATEGORY_ID)) { + return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); + } + return items; + }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); + + const virtualizer = useVirtualizer({ + count: sortedDirects.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 38, + overscan: 10, + }); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + return ( + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getDirectCreatePath())}> + + + + + + + + Create Chat + + + + + + + + + + + Chats + + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedDirects[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; + + return ( + + + + ); + })} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index aaa8c929..d247bbc0 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,2 +1,3 @@ +export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx new file mode 100644 index 00000000..d87db963 --- /dev/null +++ b/src/app/pages/client/home/Home.tsx @@ -0,0 +1,362 @@ +import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Avatar, + Box, + Button, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + config, + toRem, +} from 'folds'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useAtom, useAtomValue } from 'jotai'; +import FocusTrap from 'focus-trap-react'; +import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavEmptyCenter, + NavEmptyLayout, + NavItem, + NavItemContent, + NavLink, +} from '../../../components/nav'; +import { + encodeSearchParamValueArray, + getExplorePath, + getHomeCreatePath, + getHomeRoomPath, + getHomeSearchPath, + withSearchParam, +} from '../../pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { + useHomeCreateSelected, + useHomeSearchSelected, +} from '../../../hooks/router/useHomeSelected'; +import { useHomeRooms } from './useHomeRooms'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { VirtualTile } from '../../../components/virtualizer'; +import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; +import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { markAsRead } from '../../../utils/notifications'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { UseStateProvider } from '../../../components/UseStateProvider'; +import { JoinAddressPrompt } from '../../../components/join-address-prompt'; +import { _RoomSearchParams } from '../../paths'; + +type HomeMenuProps = { + requestClose: () => void; +}; +const HomeMenu = forwardRef(({ requestClose }, ref) => { + const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +function HomeHeader() { + const [menuAnchor, setMenuAnchor] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + <> + + + + + Home + + + + + + + + + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + + ); +} + +function HomeEmpty() { + const navigate = useNavigate(); + + return ( + + } + title={ + + No Rooms + + } + content={ + + You do not have any rooms yet. + + } + options={ + <> + + + + } + /> + + ); +} + +const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); +export function Home() { + const mx = useMatrixClient(); + useNavToActivePathMapper('home'); + const scrollRef = useRef(null); + const rooms = useHomeRooms(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const navigate = useNavigate(); + + const selectedRoomId = useSelectedRoom(); + const createRoomSelected = useHomeCreateSelected(); + const searchSelected = useHomeSearchSelected(); + const noRoomToDisplay = rooms.length === 0; + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const sortedRooms = useMemo(() => { + const items = Array.from(rooms).sort( + closedCategories.has(DEFAULT_CATEGORY_ID) + ? factoryRoomIdByActivity(mx) + : factoryRoomIdByAtoZ(mx) + ); + if (closedCategories.has(DEFAULT_CATEGORY_ID)) { + return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); + } + return items; + }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); + + const virtualizer = useVirtualizer({ + count: sortedRooms.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 38, + overscan: 10, + }); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + return ( + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getHomeCreatePath())}> + + + + + + + + Create Room + + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + + Join with Address + + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + + + + + + + + + Message Search + + + + + + + + + + + Rooms + + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; + + return ( + + + + ); + })} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 5669f658..80764481 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,2 +1,3 @@ +export * from './Home'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index ca26bac3..20518e56 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -45,8 +45,12 @@ import { } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix'; -import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; +import { + addRoomIdToMDirect, + getMxIdLocalPart, + guessDmRoomUserId, + rateLimitedActions, +} from '../../../utils/matrix'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; @@ -156,20 +160,24 @@ function InviteCard({ hideAvatar, }: InviteCardProps) { const mx = useMatrixClient(); + const userId = mx.getSafeUserId(); + const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { - const result = await activateEstateRoom(mx, { - roomId: invite.roomId, - state: 'invited', - via: [], - }); - if (result.kind === 'refused') throw new Error(result.message); + const dmUserId = isDirectInvite(invite.room, userId) + ? guessDmRoomUserId(invite.room, userId) + : undefined; + + await mx.joinRoom(invite.roomId); + if (dmUserId) { + await addRoomIdToMDirect(mx, invite.roomId, dmUserId); + } onNavigate(invite.roomId, invite.isSpace); - }, [mx, invite, onNavigate]) + }, [mx, invite, userId, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx new file mode 100644 index 00000000..e6575cb4 --- /dev/null +++ b/src/app/pages/client/sidebar/CreateTab.tsx @@ -0,0 +1,134 @@ +import React, { MouseEventHandler, useState } from 'react'; +import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useNavigate } from 'react-router-dom'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { stopPropagation } from '../../../utils/keyboard'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SettingTile } from '../../../components/setting-tile'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { + encodeSearchParamValueArray, + getCreatePath, + getSpacePath, + withSearchParam, +} from '../../pathUtils'; +import { useCreateSelected } from '../../../hooks/router/useCreateSelected'; +import { JoinAddressPrompt } from '../../../components/join-address-prompt'; +import { _RoomSearchParams } from '../../paths'; + +export function CreateTab() { + const createSelected = useCreateSelected(); + + const navigate = useNavigate(); + const [menuCords, setMenuCords] = useState(); + const [joinAddress, setJoinAddress] = useState(false); + + const handleMenu: MouseEventHandler = (evt) => { + setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); + }; + + const handleCreateSpace = () => { + navigate(getCreatePath()); + setMenuCords(undefined); + }; + + const handleJoinWithAddress = () => { + setJoinAddress(true); + setMenuCords(undefined); + }; + + return ( + + + {(triggerRef) => ( + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + + }> + Create Space + + Build a space for your community. + + + + + }> + Join with Address + + Become a part of existing community. + + + + + + + } + > + + + + {joinAddress && ( + setJoinAddress(false)} + onOpen={(roomIdOrAlias, viaServers) => { + setJoinAddress(false); + const path = getSpacePath(roomIdOrAlias); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx new file mode 100644 index 00000000..dcd44e2f --- /dev/null +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -0,0 +1,138 @@ +import React, { MouseEventHandler, forwardRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtomValue } from 'jotai'; +import { useDirects } from '../../../state/hooks/roomList'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { getDirectPath, joinPathComponent } from '../../pathUtils'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, +} from '../../../components/sidebar'; +import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; +import { UnreadBadge } from '../../../components/unread-badge'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; +import { useDirectRooms } from '../direct/useDirectRooms'; +import { markAsRead } from '../../../utils/notifications'; +import { stopPropagation } from '../../../utils/keyboard'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; + +type DirectMenuProps = { + requestClose: () => void; +}; +const DirectMenu = forwardRef(({ requestClose }, ref) => { + const orphanRooms = useDirectRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +export function DirectTab() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + + const mDirects = useAtomValue(mDirectAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const directUnread = useRoomsUnread(directs, roomToUnreadAtom); + const [menuAnchor, setMenuAnchor] = useState(); + + const directSelected = useDirectSelected(); + + const handleDirectClick = () => { + const activePath = navToActivePath.get('direct'); + if (activePath && screenSize !== ScreenSize.Mobile) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getDirectPath()); + }; + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + return ( + + + {(triggerRef) => ( + + + + )} + + {directUnread && ( + 0}> + 0} count={directUnread.total} /> + + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + )} + + ); +} diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx new file mode 100644 index 00000000..a45b5830 --- /dev/null +++ b/src/app/pages/client/sidebar/ExploreTab.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Icon, Icons } from 'folds'; +import { useNavigate } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { useExploreSelected } from '../../../hooks/router/useExploreSelected'; +import { + getExploreFeaturedPath, + getExplorePath, + getExploreServerPath, + joinPathComponent, +} from '../../pathUtils'; +import { useClientConfig } from '../../../hooks/useClientConfig'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { getMxIdServer } from '../../../utils/matrix'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; + +export function ExploreTab() { + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const clientConfig = useClientConfig(); + const navigate = useNavigate(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + + const exploreSelected = useExploreSelected(); + + const handleExploreClick = () => { + if (screenSize === ScreenSize.Mobile) { + navigate(getExplorePath()); + return; + } + + const activePath = navToActivePath.get('explore'); + if (activePath) { + navigate(joinPathComponent(activePath)); + return; + } + + if (clientConfig.featuredCommunities?.openAsDefault) { + navigate(getExploreFeaturedPath()); + return; + } + const userId = mx.getUserId(); + const userServer = userId ? getMxIdServer(userId) : undefined; + if (userServer) { + navigate(getExploreServerPath(userServer)); + return; + } + navigate(getExplorePath()); + }; + + return ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx new file mode 100644 index 00000000..778b729e --- /dev/null +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -0,0 +1,140 @@ +import React, { MouseEventHandler, forwardRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; +import { useAtomValue } from 'jotai'; +import FocusTrap from 'focus-trap-react'; +import { useOrphanRooms } from '../../../state/hooks/roomList'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { getHomePath, joinPathComponent } from '../../pathUtils'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, +} from '../../../components/sidebar'; +import { useHomeSelected } from '../../../hooks/router/useHomeSelected'; +import { UnreadBadge } from '../../../components/unread-badge'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; +import { useHomeRooms } from '../home/useHomeRooms'; +import { markAsRead } from '../../../utils/notifications'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; + +type HomeMenuProps = { + requestClose: () => void; +}; +const HomeMenu = forwardRef(({ requestClose }, ref) => { + const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +export function HomeTab() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + + const mDirects = useAtomValue(mDirectAtom); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); + const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const homeSelected = useHomeSelected(); + const [menuAnchor, setMenuAnchor] = useState(); + + const handleHomeClick = () => { + const activePath = navToActivePath.get('home'); + if (activePath && screenSize !== ScreenSize.Mobile) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getHomePath()); + }; + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + + + {(triggerRef) => ( + + + + )} + + {homeUnread && ( + 0}> + 0} count={homeUnread.total} /> + + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + )} + + ); +} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx new file mode 100644 index 00000000..7ceb5c49 --- /dev/null +++ b/src/app/pages/client/sidebar/SearchTab.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Icon, Icons } from 'folds'; +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { searchModalAtom } from '../../../state/searchModal'; + +export function SearchTab() { + const [opened, setOpen] = useAtom(searchModalAtom); + + const open = () => setOpen(true); + + return ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx new file mode 100644 index 00000000..6fc528a1 --- /dev/null +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -0,0 +1,866 @@ +import React, { + MouseEventHandler, + ReactNode, + RefObject, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Icon, + IconButton, + Icons, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + config, + toRem, +} from 'folds'; +import { useAtom, useAtomValue } from 'jotai'; +import { Room } from 'matrix-js-sdk'; +import { + draggable, + dropTargetForElements, + monitorForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { + attachInstruction, + extractInstruction, + Instruction, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import FocusTrap from 'focus-trap-react'; +import { + useOrphanSpaces, + useRecursiveChildScopeFactory, + useSpaceChildren, +} from '../../../state/hooks/roomList'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, + SidebarStack, + SidebarStackSeparator, + SidebarFolder, + SidebarFolderDropTarget, +} from '../../../components/sidebar'; +import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; +import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; +import { UnreadBadge } from '../../../components/unread-badge'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; +import { RoomAvatar } from '../../../components/room-avatar'; +import { nameInitials, randomStr } from '../../../utils/common'; +import { + ISidebarFolder, + SidebarItems, + TSidebarItem, + makeCinnySpacesContent, + parseSidebar, + sidebarItemWithout, + useSidebarItems, +} from '../../../hooks/useSidebarItems'; +import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; +import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { markAsRead } from '../../../utils/notifications'; +import { copyToClipboard } from '../../../utils/dom'; +import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; +import { getRoomAvatarUrl } from '../../../utils/room'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { InviteUserPrompt } from '../../../components/invite-user-prompt'; + +type SpaceMenuProps = { + room: Room; + requestClose: () => void; + onUnpin?: (roomId: string) => void; +}; +const SpaceMenu = forwardRef( + ({ room, requestClose, onUnpin }, ref) => { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const roomToParents = useAtomValue(roomToParentsAtom); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openSpaceSettings = useOpenSpaceSettings(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const allChild = useSpaceChildren( + allRoomsAtom, + room.roomId, + useRecursiveChildScopeFactory(mx, roomToParents) + ); + const unread = useRoomsUnread(allChild, roomToUnreadAtom); + + const handleMarkAsRead = () => { + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + requestClose(); + }; + + const handleUnpin = () => { + onUnpin?.(room.roomId); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openSpaceSettings(room.roomId); + requestClose(); + }; + + return ( + + {invitePrompt && room && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + + } + radii="300" + disabled={!unread} + > + + Mark as Read + + + {onUnpin && ( + } + > + + Unpin + + + )} + + + + } + radii="300" + aria-pressed={invitePrompt} + disabled={!canInvite} + > + + Invite + + + } + radii="300" + > + + Copy Link + + + } + radii="300" + > + + Space Settings + + + + + ); + } +); + +type InstructionType = Instruction['type']; +type FolderDraggable = { + folder: ISidebarFolder; + spaceId?: string; + open?: boolean; +}; +type SidebarDraggable = string | FolderDraggable; + +const useDraggableItem = ( + item: SidebarDraggable, + targetRef: RefObject, + onDragging: (item?: SidebarDraggable) => void, + dragHandleRef?: RefObject +): boolean => { + const [dragging, setDragging] = useState(false); + + useEffect(() => { + const target = targetRef.current; + const dragHandle = dragHandleRef?.current ?? undefined; + + return !target + ? undefined + : draggable({ + element: target, + dragHandle, + getInitialData: () => ({ item }), + onDragStart: () => { + setDragging(true); + onDragging?.(item); + }, + onDrop: () => { + setDragging(false); + onDragging?.(undefined); + }, + }); + }, [targetRef, dragHandleRef, item, onDragging]); + + return dragging; +}; + +const useDropTarget = ( + item: SidebarDraggable, + targetRef: RefObject +): Instruction | undefined => { + const [dropState, setDropState] = useState(); + + useEffect(() => { + const target = targetRef.current; + if (!target) return undefined; + + return dropTargetForElements({ + element: target, + canDrop: ({ source }) => { + const dragItem = source.data.item as SidebarDraggable; + return dragItem !== item; + }, + getData: ({ input, element }) => { + const block: Instruction['type'][] = ['reparent']; + if (typeof item === 'object' && item.spaceId) block.push('make-child'); + + const insData = attachInstruction( + {}, + { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: 'standard', + block, + } + ); + + const instruction: Instruction | null = extractInstruction(insData); + setDropState(instruction ?? undefined); + + return { + item, + instructionType: instruction ? instruction.type : undefined, + }; + }, + onDragLeave: () => setDropState(undefined), + onDrop: () => setDropState(undefined), + }); + }, [item, targetRef]); + + return dropState; +}; + +function useDropTargetInstruction( + item: SidebarDraggable, + targetRef: RefObject, + instructionType: T +): T | undefined { + const [dropState, setDropState] = useState(); + + useEffect(() => { + const target = targetRef.current; + if (!target) return undefined; + + return dropTargetForElements({ + element: target, + canDrop: ({ source }) => { + const dragItem = source.data.item as SidebarDraggable; + return dragItem !== item; + }, + getData: () => { + setDropState(instructionType); + + return { + item, + instructionType, + }; + }, + onDragLeave: () => setDropState(undefined), + onDrop: () => setDropState(undefined), + }); + }, [item, targetRef, instructionType]); + + return dropState; +} + +const useDnDMonitor = ( + scrollRef: RefObject, + onDragging: (dragItem?: SidebarDraggable) => void, + onReorder: ( + draggable: SidebarDraggable, + container: SidebarDraggable, + instruction: InstructionType + ) => void +) => { + useEffect(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) { + throw Error('Scroll element ref not configured'); + } + + return combine( + monitorForElements({ + onDrop: ({ source, location }) => { + onDragging(undefined); + const { dropTargets } = location.current; + if (dropTargets.length === 0) return; + const item = source.data.item as SidebarDraggable; + const containerItem = dropTargets[0].data.item as SidebarDraggable; + const instructionType = dropTargets[0].data.instructionType as + | InstructionType + | undefined; + if (!instructionType) return; + onReorder(item, containerItem, instructionType); + }, + }), + autoScrollForElements({ + element: scrollElement, + }) + ); + }, [scrollRef, onDragging, onReorder]); +}; + +type SpaceTabProps = { + space: Room; + selected: boolean; + onClick: MouseEventHandler; + folder?: ISidebarFolder; + onDragging: (dragItem?: SidebarDraggable) => void; + disabled?: boolean; + onUnpin?: (roomId: string) => void; +}; +function SpaceTab({ + space, + selected, + onClick, + folder, + onDragging, + disabled, + onUnpin, +}: SpaceTabProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const targetRef = useRef(null); + + const spaceDraggable: SidebarDraggable = useMemo( + () => + folder + ? { + folder, + spaceId: space.roomId, + } + : space.roomId, + [folder, space] + ); + + useDraggableItem(spaceDraggable, targetRef, onDragging); + const dropState = useDropTarget(spaceDraggable, targetRef); + const dropType = dropState?.type; + + const [menuAnchor, setMenuAnchor] = useState(); + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + + {(unread) => ( + + + {(triggerRef) => ( + + ( + {nameInitials(space.name, 2)} + )} + /> + + )} + + {unread && ( + 0}> + 0} count={unread.total} /> + + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + onUnpin={onUnpin} + /> + + } + /> + )} + + )} + + ); +} + +type OpenedSpaceFolderProps = { + folder: ISidebarFolder; + onClose: MouseEventHandler; + children?: ReactNode; +}; +function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { + const aboveTargetRef = useRef(null); + const belowTargetRef = useRef(null); + + const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); + + const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); + const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); + + return ( + + + + + + + + {children} + + + ); +} + +type ClosedSpaceFolderProps = { + folder: ISidebarFolder; + selected: boolean; + onOpen: MouseEventHandler; + onDragging: (dragItem?: SidebarDraggable) => void; + disabled?: boolean; +}; +function ClosedSpaceFolder({ + folder, + selected, + onOpen, + onDragging, + disabled, +}: ClosedSpaceFolderProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const handlerRef = useRef(null); + + const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); + useDraggableItem(spaceDraggable, handlerRef, onDragging); + const dropState = useDropTarget(spaceDraggable, handlerRef); + const dropType = dropState?.type; + + const tooltipName = + folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; + + return ( + + {(unread) => ( + + + {(tooltipRef) => ( + + {folder.content.map((sId) => { + const space = mx.getRoom(sId); + if (!space) return null; + + return ( + + ( + + {nameInitials(space.name, 2)} + + )} + /> + + ); + })} + + )} + + {unread && ( + 0}> + 0} count={unread.total} /> + + )} + + )} + + ); +} + +type SpaceTabsProps = { + scrollRef: RefObject; +}; +export function SpaceTabs({ scrollRef }: SpaceTabsProps) { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); + const [draggingItem, setDraggingItem] = useState(); + + useDnDMonitor( + scrollRef, + setDraggingItem, + useCallback( + (item, containerItem, instructionType) => { + const newItems: SidebarItems = []; + + const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => { + if (typeof sI === 'string' && typeof dI === 'string') { + return sI === dI; + } + if (typeof sI === 'object' && typeof dI === 'object') { + return sI.id === dI.folder.id; + } + return false; + }; + const itemAsFolderContent = (i: SidebarDraggable): string[] => { + if (typeof i === 'string') { + return [i]; + } + if (i.spaceId) { + return [i.spaceId]; + } + return [...i.folder.content]; + }; + + sidebarItems.forEach((i) => { + const sameFolders = + typeof item === 'object' && + typeof containerItem === 'object' && + item.folder.id === containerItem.folder.id; + + // remove draggable space from current position or folder + if (!sameFolders && matchDest(i, item)) { + if (typeof item === 'object' && item.spaceId) { + const folderContent = item.folder.content.filter((s) => s !== item.spaceId); + if (folderContent.length === 0) { + // remove open state from local storage + setOpenedFolder({ type: 'DELETE', id: item.folder.id }); + return; + } + newItems.push({ + ...item.folder, + content: folderContent, + }); + } + return; + } + if (matchDest(i, containerItem)) { + // we can make child only if + // container item is space or closed folder + if (instructionType === 'make-child') { + const child: string[] = itemAsFolderContent(item); + if (typeof containerItem === 'string') { + const folder: ISidebarFolder = { + id: randomStr(), + content: [containerItem].concat(child), + }; + newItems.push(folder); + return; + } + newItems.push({ + ...containerItem.folder, + content: containerItem.folder.content.concat(child), + }); + return; + } + + // drop inside opened folder + // or reordering inside same folder + if (typeof containerItem === 'object' && containerItem.spaceId) { + const child = itemAsFolderContent(item); + const newContent: string[] = []; + containerItem.folder.content + .filter((sId) => !child.includes(sId)) + .forEach((sId) => { + if (sId === containerItem.spaceId) { + if (instructionType === 'reorder-below') { + newContent.push(sId, ...child); + } + if (instructionType === 'reorder-above') { + newContent.push(...child, sId); + } + return; + } + newContent.push(sId); + }); + const folder = { + ...containerItem.folder, + content: newContent, + }; + + newItems.push(folder); + return; + } + + // drop above or below space or closed/opened folder + if (typeof item === 'string') { + if (instructionType === 'reorder-below') newItems.push(i); + newItems.push(item); + if (instructionType === 'reorder-above') newItems.push(i); + } else if (item.spaceId) { + if (instructionType === 'reorder-above') { + newItems.push(item.spaceId); + } + if (sameFolders && typeof i === 'object') { + // remove from folder if placing around itself + const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) }; + if (newI.content.length > 0) newItems.push(newI); + } else { + newItems.push(i); + } + if (instructionType === 'reorder-below') { + newItems.push(item.spaceId); + } + } else { + if (instructionType === 'reorder-below') newItems.push(i); + newItems.push(item.folder); + if (instructionType === 'reorder-above') newItems.push(i); + } + return; + } + newItems.push(i); + }); + + const newSpacesContent = makeCinnySpacesContent(mx, newItems); + localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); + mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); + }, + [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] + ) + ); + + const selectedSpaceId = useSelectedSpace(); + + const handleSpaceClick: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const targetSpaceId = target.getAttribute('data-id'); + if (!targetSpaceId) return; + + const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); + if (screenSize === ScreenSize.Mobile) { + navigate(spacePath); + return; + } + + const activePath = navToActivePath.get(targetSpaceId); + if (activePath && activePath.pathname.startsWith(spacePath)) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + }; + + const handleFolderToggle: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const targetFolderId = target.getAttribute('data-id'); + if (!targetFolderId) return; + + setOpenedFolder({ + type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT', + id: targetFolderId, + }); + }; + + const handleUnpin = useCallback( + (roomId: string) => { + if (orphanSpaces.includes(roomId)) return; + const newItems = sidebarItemWithout(sidebarItems, roomId); + + const newSpacesContent = makeCinnySpacesContent(mx, newItems); + localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); + mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); + }, + [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] + ); + + if (sidebarItems.length === 0) return null; + return ( + <> + + + {sidebarItems.map((item) => { + if (typeof item === 'object') { + if (openedFolder.has(item.id)) { + return ( + + {item.content.map((sId) => { + const space = mx.getRoom(sId); + if (!space) return null; + return ( + + ); + })} + + ); + } + + return ( + + ); + } + + const space = mx.getRoom(item); + if (!space) return null; + + return ( + + ); + })} + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 3cf570d9..d44cfaa2 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -1,3 +1,8 @@ +export * from './HomeTab'; +export * from './DirectTab'; +export * from './SpaceTabs'; export * from './InboxTab'; +export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; +export * from './SearchTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx new file mode 100644 index 00000000..c822ff38 --- /dev/null +++ b/src/app/pages/client/space/Space.tsx @@ -0,0 +1,571 @@ +import React, { + MouseEventHandler, + forwardRef, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { + Avatar, + Box, + Button, + Icon, + IconButton, + Icons, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, + color, + config, + toRem, +} from 'folds'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { JoinRule, Room } from 'matrix-js-sdk'; +import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; +import FocusTrap from 'focus-trap-react'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { + NavCategory, + NavCategoryHeader, + NavItem, + NavItemContent, + NavLink, +} from '../../../components/nav'; +import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { + useSpaceLobbySelected, + useSpaceSearchSelected, +} from '../../../hooks/router/useSelectedSpace'; +import { useSpace } from '../../../hooks/useSpace'; +import { VirtualTile } from '../../../components/virtualizer'; +import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups'; +import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; +import { useRoomName } from '../../../hooks/useRoomMeta'; +import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { markAsRead } from '../../../utils/notifications'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { UseStateProvider } from '../../../components/UseStateProvider'; +import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; +import { copyToClipboard } from '../../../utils/dom'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { Membership, StateEvent } from '../../../../types/matrix/room'; +import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { BreakWord } from '../../../styles/Text.css'; +import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { useCallEmbed } from '../../../hooks/useCallEmbed'; + +type SpaceMenuProps = { + room: Room; + requestClose: () => void; +}; +const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const roomToParents = useAtomValue(roomToParentsAtom); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openSpaceSettings = useOpenSpaceSettings(); + const { navigateRoom } = useRoomNavigate(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const allChild = useSpaceChildren( + allRoomsAtom, + room.roomId, + useRecursiveChildScopeFactory(mx, roomToParents) + ); + const unread = useRoomsUnread(allChild, roomToUnreadAtom); + + const handleMarkAsRead = () => { + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openSpaceSettings(room.roomId); + requestClose(); + }; + + const handleOpenTimeline = () => { + navigateRoom(room.roomId); + requestClose(); + }; + + return ( + + + {invitePrompt && room && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + } + radii="300" + disabled={!unread} + > + + Mark as Read + + + + + + } + radii="300" + aria-pressed={invitePrompt} + disabled={!canInvite} + > + + Invite + + + } + radii="300" + > + + Copy Link + + + } + radii="300" + > + + Space Settings + + + {developerTools && ( + } + radii="300" + > + + Event Timeline + + + )} + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + > + + Leave Space + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); +}); + +function SpaceHeader() { + const space = useSpace(); + const spaceName = useRoomName(space); + const [menuAnchor, setMenuAnchor] = useState(); + + const joinRules = useStateEvent( + space, + StateEvent.RoomJoinRules + )?.getContent(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + <> + + + + + {spaceName} + + {joinRules?.join_rule !== JoinRule.Public && } + + + + + + + + + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + )} + + ); +} + +type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; +export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { + const mx = useMatrixClient(); + const { navigateSpace } = useRoomNavigate(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? getViaServers(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) navigateSpace(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); + }; + + return ( + + + Space Upgraded + This space has been replaced and is no longer active. + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} + + )} + + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + + ); +} + +export function Space() { + const mx = useMatrixClient(); + const space = useSpace(); + useNavToActivePathMapper(space.roomId); + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); + const scrollRef = useRef(null); + const mDirects = useAtomValue(mDirectAtom); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const allRooms = useAtomValue(allRoomsAtom); + const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + + const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); + const selectedRoomId = useSelectedRoom(); + const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); + const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + const callEmbed = useCallEmbed(); + + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const getRoom = useCallback( + (rId: string): Room | undefined => { + if (allJoinedRooms.has(rId)) { + return mx.getRoom(rId) ?? undefined; + } + return undefined; + }, + [mx, allJoinedRooms] + ); + + const hierarchy = useSpaceJoinedHierarchy( + space.roomId, + getRoom, + useCallback( + (parentId, roomId) => { + if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { + return false; + } + const showRoomAnyway = + roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; + return !showRoomAnyway; + }, + [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] + ), + useCallback( + (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), + [closedCategories, space.roomId] + ) + ); + + const { entries, toggleGroup } = useStewardGroups({ + mx, + spaceId: space.roomId, + items: hierarchy, + keepVisible: useCallback( + (rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId, + [roomToUnread, selectedRoomId, callEmbed] + ), + }); + + const virtualizer = useVirtualizer({ + count: entries.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 0, + overscan: 10, + }); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + const getToLink = (roomId: string) => + getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + + return ( + + + + + {tombstoneEvent && ( + + )} + + + + + + + + + + + Lobby + + + + + + + + + + + + + + + + Message Search + + + + + + + + + {virtualizer.getVirtualItems().map((vItem) => { + const entry = entries[vItem.index]; + if (!entry) return null; + if (entry.type === 'group') { + return ( + + + toggleGroup(entry.id)} + closed={!entry.open} + > + {entry.label} · {entry.count} + + + + ); + } + const { roomId } = entry.item; + const room = mx.getRoom(roomId); + if (!room) return null; + + if (room.isSpaceRoom()) { + const categoryId = makeNavCategoryId(space.roomId, roomId); + + return ( + +
+ + + {roomId === space.roomId ? 'Rooms' : room?.name} + + +
+
+ ); + } + + return ( + + + + ); + })} +
+
+
+
+ ); +} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 477d60ff..0e32d93b 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,3 +1,4 @@ export * from './SpaceProvider'; +export * from './Space'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts index 2a7c6016..77bcbd57 100644 --- a/src/app/plugins/markdown/inline/rules.ts +++ b/src/app/plugins/markdown/inline/rules.ts @@ -1,7 +1,8 @@ import { InlineMDRule } from './type'; -import { ESC_NEG_LB, URL_NEG_LB } from '../../../utils/regex'; const MIN_ANY = '(.+?)'; +const URL_NEG_LB = '(? { diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts index 84786a29..810c0d2e 100644 --- a/src/app/state/upload.ts +++ b/src/app/state/upload.ts @@ -1,14 +1,9 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback } from 'react'; import { useThrottle } from '../hooks/useThrottle'; -import { - ComponentLifecycle, - describeComponentLifecycle, - uploadContent, - TUploadContent, -} from '../utils/matrix'; +import { uploadContent, TUploadContent } from '../utils/matrix'; export enum UploadStatus { Idle = 'idle', @@ -110,34 +105,22 @@ export const useBindUploadAtom = ( const [upload, setUpload] = useAtom(uploadAtom); const { file } = upload; - // For the failure text: renders and unmount of the component that owns this upload. - const life = useRef({ attachedAt: 0, rendersAtAttach: 0, renders: [] }); - if (life.current.renders.length < 50) life.current.renders.push(Date.now()); - useEffect(() => { - const tracked = life.current; - tracked.unmountedAt = undefined; - return () => { - tracked.unmountedAt = Date.now(); - }; - }, []); - const handleProgress = useThrottle( useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]), { immediate: true, wait: 200 } ); - const startUpload = useCallback(() => { - life.current.attachedAt = Date.now(); - life.current.rendersAtAttach = life.current.renders.length; - return uploadContent(mx, file, { - hideFilename, - describeLifecycle: () => describeComponentLifecycle(life.current), - onPromise: (promise: Promise) => setUpload({ promise }), - onProgress: handleProgress, - onSuccess: (mxc) => setUpload({ mxc }), - onError: (error) => setUpload({ error }), - }); - }, [mx, file, hideFilename, setUpload, handleProgress]); + const startUpload = useCallback( + () => + uploadContent(mx, file, { + hideFilename, + onPromise: (promise: Promise) => setUpload({ promise }), + onProgress: handleProgress, + onSuccess: (mxc) => setUpload({ mxc }), + onError: (error) => setUpload({ error }), + }), + [mx, file, hideFilename, setUpload, handleProgress] + ); const cancelUpload = useCallback(async () => { if (upload.status === UploadStatus.Loading) { diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts deleted file mode 100644 index 1deaf37e..00000000 --- a/src/app/utils/mDirect.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix'; - -type Content = Record; - -/** A client whose account-data write takes a while and lands only when it completes. */ -const fakeClient = (initial?: Content) => { - let direct: Content | undefined = initial; - const writes: Content[] = []; - const mx = { - getAccountData: () => (direct ? { getContent: () => direct } : undefined), - setAccountData: async (_type: string, content: Content) => { - await new Promise((r) => { - setTimeout(r, 5); - }); - writes.push(structuredClone(content)); - direct = structuredClone(content); - }, - }; - return { mx: mx as never, writes, current: () => direct }; -}; - -describe('m.direct edits', () => { - it('moves a room between users and removes it, one write at a time', async () => { - const c = fakeClient({ '@a:test': ['!r:test'] }); - await Promise.all([ - addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), - removeRoomIdFromMDirect(c.mx, '!gone:test'), - ]); - expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] }); - await removeRoomIdFromMDirect(c.mx, '!r:test'); - expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); - }); - - it('serializes overlapping edits so each builds on the content the last one wrote', async () => { - const c = fakeClient(); - await Promise.all([ - addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'), - addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'), - addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'), - ]); - expect(c.current()).toEqual({ - '@friend:test': ['!dm2:test', '!dm3:test'], - '@other:test': ['!dm4:test'], - }); - expect(c.writes).toHaveLength(3); - }); - - it('keeps going after a failed write', async () => { - const c = fakeClient(); - const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise }; - const original = raw.setAccountData; - raw.setAccountData = async () => { - raw.setAccountData = original; - throw new Error('offline'); - }; - await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline'); - await addRoomIdToMDirect(c.mx, '!a:test', '@f:test'); - expect(c.current()).toEqual({ '@f:test': ['!a:test'] }); - }); -}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index f0ded881..4c86c4e2 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -143,270 +143,6 @@ export type ContentUploadOptions = { onProgress?: (progress: UploadProgress) => void; onSuccess: (mxc: string) => void; onError: (error: MatrixError) => void; - /** Read at failure time: what the owning component did since the upload attached. */ - describeLifecycle?: () => string; -}; - -let lastUserActivity = Date.now(); -export const noteUserActivity = () => { - lastUserActivity = Date.now(); -}; -if (typeof window !== 'undefined') { - ['pointerdown', 'keydown', 'touchstart'].forEach((type) => - window.addEventListener(type, noteUserActivity, { capture: true, passive: true }) - ); -} - -export type AbortCall = { api: string; frames: string }; - -/** What the progress handler saw of one upload, for the failure text. */ -export type UploadTrace = { - startedAt: number; - firstProgressAt?: number; - loaded?: number; - total?: number; - /** Summary of an earlier attempt of the same upload, when this one is a retry. */ - previous?: string; - /** abort() calls the page made while this attempt ran; absent when not probed. */ - aborts?: AbortCall[]; - /** What the owning component did since the upload attached. */ - lifecycle?: string; - /** Upload XHR events and end state, and whether our own signal was aborted. */ - xhr?: string; - signalAborted?: boolean; -}; - -const MAX_FRAMES = 6; -const MAX_ABORT_CALLS = 4; - -/** - * An Error stack as `file:line:col < file:line:col`, at most six frames. The error line and the - * probe's own frame are dropped; origins and function names are not kept. - */ -export const formatAbortStack = (stack: string): string => { - const frames = stack - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.startsWith('at ') || l.includes('@')) - .map((l) => { - const m = /([^/\s()@]+):(\d+):(\d+)\)?$/.exec(l); - return m ? `${m[1]}:${m[2]}:${m[3]}` : undefined; - }) - .filter((f): f is string => f !== undefined) - .slice(1, 1 + MAX_FRAMES); - return frames.length > 0 ? frames.join(' < ') : 'no stack'; -}; - -export type ComponentLifecycle = { - attachedAt: number; - rendersAtAttach: number; - renders: number[]; - unmountedAt?: number; -}; - -/** What the component that owns an upload did after the upload attached to it. */ -export const describeComponentLifecycle = (life: ComponentLifecycle): string => { - const since = life.renders.slice(life.rendersAtAttach); - const quick = since.filter((t) => t - life.attachedAt <= 300).length; - const unmount = - life.unmountedAt === undefined - ? 'not unmounted' - : `unmounted +${life.unmountedAt - life.attachedAt}ms`; - return `component: ${since.length} renders since attach (${quick} within 300ms), ${unmount}`; -}; - -export type XhrRecord = { - xhr: XMLHttpRequest; - method: string; - path: string; - startedAt: number; - events: string[]; -}; - -type Probe = { calls: AbortCall[]; xhrs: XhrRecord[] }; - -const probes = new Set(); -let restoreProbe: (() => void) | undefined; -const xhrTargets = new WeakMap(); - -const XHR_EVENTS = ['loadstart', 'error', 'abort', 'timeout', 'load', 'loadend']; -const MAX_XHR_EVENTS = 12; - -/* eslint-disable no-param-reassign */ -const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { - const original = proto.abort; - const wrapper = function abortProbe(this: unknown, ...args: any[]) { - const frames = formatAbortStack(new Error().stack ?? ''); - probes.forEach(({ calls }) => { - if (calls.length < MAX_ABORT_CALLS) calls.push({ api, frames }); - }); - return original.apply(this, args); - }; - proto.abort = wrapper; - return () => { - if (proto.abort === wrapper) proto.abort = original; - }; -}; - -// Upload XHRs only; the path is kept and the query (it holds the access token) is dropped. -const patchXhr = (proto: XMLHttpRequest) => { - const { open, send } = proto; - const openWrapper = function openProbe(this: XMLHttpRequest, ...args: any[]) { - try { - const url = new URL(String(args[1]), 'http://x'); - if (url.pathname.includes('/upload')) { - xhrTargets.set(this, { method: String(args[0]).toUpperCase(), path: url.pathname }); - } - } catch { - // not a URL we can read; the request is not recorded - } - return (open as any).apply(this, args); - }; - const sendWrapper = function sendProbe(this: XMLHttpRequest, ...args: any[]) { - const target = xhrTargets.get(this); - if (target && probes.size > 0) { - const startedAt = Date.now(); - const events: string[] = []; - const note = (name: string) => () => { - if (events.length < MAX_XHR_EVENTS) events.push(`${name} +${Date.now() - startedAt}ms`); - }; - XHR_EVENTS.forEach((ev) => this.addEventListener(ev, note(ev))); - XHR_EVENTS.forEach((ev) => this.upload?.addEventListener(ev, note(`upload.${ev}`))); - this.upload?.addEventListener( - 'progress', - (() => { - let first = true; - return () => { - if (first) note('upload.progress')(); - first = false; - }; - })() - ); - const record: XhrRecord = { xhr: this, ...target, startedAt, events }; - probes.forEach((probe) => probe.xhrs.push(record)); - } - return (send as any).apply(this, args); - }; - (proto as any).open = openWrapper; - (proto as any).send = sendWrapper; - return () => { - if ((proto as any).open === openWrapper) (proto as any).open = open; - if ((proto as any).send === sendWrapper) (proto as any).send = send; - }; -}; -/* eslint-enable no-param-reassign */ - -const describeXhr = (r: XhrRecord): string => - `${r.method} ${r.path} readyState ${r.xhr.readyState} status ${ - r.xhr.status - } events [${r.events.join(', ')}]`; - -/** - * Records, until stopped, every XMLHttpRequest.abort and AbortController.abort the page makes and - * the lifecycle events and end state of upload XHRs. The prototypes are wrapped while at least one - * probe runs and restored when the last one stops; the wrappers change no behaviour. - */ -export const startAbortProbe = () => { - const probe: Probe = { calls: [], xhrs: [] }; - if (probes.size === 0) { - const restores: (() => void)[] = []; - if (typeof XMLHttpRequest !== 'undefined') { - restores.push(patchAbort(XMLHttpRequest.prototype, 'XHR.abort')); - restores.push(patchXhr(XMLHttpRequest.prototype)); - } - if (typeof AbortController !== 'undefined') { - restores.push(patchAbort(AbortController.prototype, 'AbortController.abort')); - } - restoreProbe = () => restores.forEach((r) => r()); - } - probes.add(probe); - return { - calls: probe.calls, - xhrs: probe.xhrs, - describeXhrs: () => - probe.xhrs.length === 0 ? 'xhr: none seen' : `xhr: ${probe.xhrs.map(describeXhr).join('; ')}`, - stop: () => { - probes.delete(probe); - if (probes.size === 0) { - restoreProbe?.(); - restoreProbe = undefined; - } - }, - }; -}; - -const describeNetwork = (): string => { - const online = - typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'online'; - const c = typeof navigator !== 'undefined' ? (navigator as any).connection : undefined; - const net = c - ? `${c.effectiveType ?? '?'} rtt ${c.rtt ?? '?'}ms down ${c.downlink ?? '?'}Mbps saveData ${ - c.saveData ? 'on' : 'off' - }` - : 'n/a'; - const tab = typeof document !== 'undefined' ? document.visibilityState : 'n/a'; - return `${online}, net ${net}, tab ${tab}`; -}; - -/** - * Describe why an upload died. The SDK reports an XHR with status 0 as an AbortError - * DOMException with an empty message, so the text carries what the client can observe: file - * size, type and readability, connectivity, tab visibility, idle time, progress, time to fail. - */ -export const uploadFailure = async ( - e: any, - file: TUploadContent, - trace?: UploadTrace -): Promise => { - const now = Date.now(); - const name = typeof e?.name === 'string' && e.name ? e.name : 'Error'; - const message = typeof e?.message === 'string' ? e.message : ''; - let readable: string; - try { - await file.arrayBuffer(); - readable = 'file readable'; - } catch (readErr: any) { - readable = `file unreadable (${readErr?.name ?? 'unknown'})`; - } - const parts = [`${file.size} bytes`, file.type || 'unknown type', readable, describeNetwork()]; - parts.push(`idle ${now - lastUserActivity}ms`); - if (trace) { - parts.push( - trace.firstProgressAt === undefined - ? 'no progress' - : `first progress ${trace.firstProgressAt - trace.startedAt}ms ${trace.loaded ?? 0}/${ - trace.total ?? '?' - }B` - ); - parts.push(`failed after ${now - trace.startedAt}ms`); - if (trace.aborts) { - parts.push( - trace.aborts.length === 0 - ? 'abort calls: none' - : `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` - ); - } - if (trace.xhr) parts.push(trace.xhr); - if (trace.signalAborted !== undefined) parts.push(`signal.aborted ${trace.signalAborted}`); - if (trace.lifecycle) parts.push(trace.lifecycle); - if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); - } - const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`; - const err = new MatrixError({ error, errcode: name }); - (err as any).cause = e; - return err; -}; - -// An abort before any progress, within this long, is retried once: the request most likely died on -// a reused connection before a byte left, and the failed attempt discards that connection. Nothing -// here has shown that is the cause; the failure text records both attempts so it can be checked. -const RETRY_WINDOW_MS = 3000; - -const copyInMemory = async (file: TUploadContent): Promise => { - const bytes = await file.arrayBuffer(); - return typeof File !== 'undefined' && file instanceof File - ? new File([bytes], file.name, { type: file.type, lastModified: file.lastModified }) - : new Blob([bytes], { type: file.type }); }; export const uploadContent = async ( @@ -414,86 +150,24 @@ export const uploadContent = async ( file: TUploadContent, options: ContentUploadOptions ) => { - const { - name, - fileType, - hideFilename, - onProgress, - onPromise, - onSuccess, - onError, - describeLifecycle, - } = options; + const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; - let previous: string | undefined; - for (let attempt = 1; ; attempt += 1) { - // The browser may hold a picked file as a handle it cannot always re-read (e.g. content://), so - // each attempt uploads a copy held in memory; a file that cannot be read fails here, named. - let body: TUploadContent; - try { - // eslint-disable-next-line no-await-in-loop - body = await copyInMemory(file); - } catch (readErr: any) { - const errcode = readErr?.name || 'Error'; - onError( - new MatrixError({ - error: `Upload failed: file unreadable before upload (${errcode}) (${file.size} bytes, ${ - file.type || 'unknown type' - })`, - errcode, - }) - ); - return; - } - const probe = startAbortProbe(); - const trace: UploadTrace = { startedAt: Date.now(), previous, aborts: probe.calls }; - const abortController = new AbortController(); - try { - const uploadPromise = mx.uploadContent(body, { - name, - type: fileType, - includeFilename: !hideFilename, - abortController, - progressHandler: (progress) => { - if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); - trace.loaded = progress.loaded; - trace.total = progress.total; - onProgress?.(progress); - }, - }); - onPromise?.(uploadPromise); - try { - // eslint-disable-next-line no-await-in-loop - const data = await uploadPromise; - const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); - return; - } catch (e: any) { - const elapsed = Date.now() - trace.startedAt; - trace.xhr = probe.describeXhrs(); - trace.signalAborted = abortController.signal.aborted; - const retry = - attempt === 1 && - e?.name === 'AbortError' && - !abortController.signal.aborted && - trace.firstProgressAt === undefined && - elapsed < RETRY_WINDOW_MS; - if (!retry) { - trace.lifecycle = describeLifecycle?.(); - // eslint-disable-next-line no-await-in-loop - onError(await uploadFailure(e, file, trace)); - return; - } - previous = `AbortError after ${elapsed}ms, no progress, ${ - trace.aborts?.length - ? `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` - : 'abort calls: none' - }, ${trace.xhr}`; - } - } finally { - probe.stop(); - } + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + progressHandler: onProgress, + }); + onPromise?.(uploadPromise); + try { + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); + } catch (e: any) { + const error = typeof e?.message === 'string' ? e.message : undefined; + const errcode = typeof e?.name === 'string' ? e.message : undefined; + onError(new MatrixError({ error, errcode })); } }; @@ -551,59 +225,57 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -type MDirectContent = Record; - -const mDirectWrites = new WeakMap>(); - -/** - * The only writer of m.direct. m.direct is one account-data event that every - * edit replaces whole, so edits run one at a time, each reading the content the - * previous one wrote. `edit` returns the new content, or undefined for no write. - */ -export const updateMDirect = ( - mx: MatrixClient, - edit: (current: MDirectContent) => MDirectContent | undefined -): Promise => { - const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => { - const event = mx.getAccountData(AccountDataEvent.Direct as any); - const next = edit(event ? structuredClone(event.getContent()) : {}); - if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any); - }); - mDirectWrites.set( - mx, - run.catch(() => undefined) - ); - return run; -}; - -/** A room can be a DM for one person only: it leaves every other user's list. */ -export const addRoomIdToMDirect = ( +export const addRoomIdToMDirect = async ( mx: MatrixClient, roomId: string, userId: string -): Promise => - updateMDirect(mx, (current) => { - const next = structuredClone(current); - Object.keys(next).forEach((targetUserId) => { - if (targetUserId !== userId) { - next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); +): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + // remove it from the lists of any others users + // (it can only be a DM room for one person) + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + + if (targetUserId !== userId) { + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); } - }); - const roomIds = next[userId] ?? []; - if (!roomIds.includes(roomId)) roomIds.push(roomId); - next[userId] = roomIds; - return next; + } }); -export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => - updateMDirect(mx, (current) => { - const next: MDirectContent = {}; - Object.keys(current).forEach((userId) => { - next[userId] = current[userId].filter((id) => id !== roomId); - }); - return next; + const roomIds = userIdToRoomIds[userId] || []; + if (roomIds.indexOf(roomId) === -1) { + roomIds.push(roomId); + } + userIdToRoomIds[userId] = roomIds; + + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); +}; + +export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } }); + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); +}; + export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, diff --git a/src/app/utils/regex.ts b/src/app/utils/regex.ts index 0c8eced9..0b98b0e2 100644 --- a/src/app/utils/regex.ts +++ b/src/app/utils/regex.ts @@ -4,31 +4,14 @@ export const sanitizeForRegex = (unsafeText: string): string => unsafeText.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); -/** - * Whether this engine compiles lookbehind (Safari before 16.4 does not, and building such a RegExp throws, which at - * module load blanks the app). Patterns below that use lookbehind degrade when it is missing. - */ -export const LOOKBEHIND = ((): boolean => { - try { - return new RegExp('(?<=a)b').test('ab'); - } catch { - return false; - } -})(); - -// Without lookbehind a URL keeps any trailing punctuation. -export const HTTP_URL_PATTERN = LOOKBEHIND - ? `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -export const URL_NEG_LB = LOOKBEHIND ? '(? roomTrainingLine(parseRoomNotice(content)); - -describe('training notice', () => { - it('says exactly the ruled sentence and never mentions Discord', () => { - expect(TRAINING_NOTICE_TEXT).toBe( - "Everything said here may be used to train GameCult's AI stewards." - ); - expect(TRAINING_NOTICE_TEXT.toLowerCase()).not.toContain('discord'); - }); - - it('reads the notice from the org.gamecult.notice state event', () => { - expect(ROOM_NOTICE_EVENT_TYPE).toBe('org.gamecult.notice'); - }); - - it('shows the train line only where the room declares train', () => { - expect(parseRoomNotice({ version: 2, scope: 'train' })).toEqual({ version: 2, scope: 'train' }); - expect(lineFor({ version: 2, scope: 'train' })).toBe(TRAINING_NOTICE_TEXT); - expect(lineFor({ version: 1, scope: 'study' })).toBeUndefined(); - expect(lineFor({ version: 1, scope: 'show' })).toBeUndefined(); - }); - - it('shows nothing where the notice is absent', () => { - expect(lineFor(undefined)).toBeUndefined(); - expect(lineFor(null)).toBeUndefined(); - expect(lineFor({})).toBeUndefined(); - }); - - it('shows nothing where the notice is malformed in any way', () => { - expect(lineFor('train')).toBeUndefined(); - expect(lineFor({ scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: 1 })).toBeUndefined(); - expect(lineFor({ version: 0, scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: -1, scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: 1.5, scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: '1', scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: null, scope: 'train' })).toBeUndefined(); - }); - - it('rejects a version written with leading zeros', () => { - expect(lineFor({ version: '01', scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: '007', scope: 'train' })).toBeUndefined(); - }); - - it('bounds the version at 4294967295', () => { - expect(MAX_NOTICE_VERSION).toBe(4294967295); - expect(lineFor({ version: 4294967295, scope: 'train' })).toBe(TRAINING_NOTICE_TEXT); - expect(parseRoomNotice({ version: 4294967295, scope: 'train' })?.version).toBe(4294967295); - expect(lineFor({ version: 4294967296, scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: Number.MAX_SAFE_INTEGER, scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: 4294967295.5, scope: 'train' })).toBeUndefined(); - expect(lineFor({ version: '4294967295', scope: 'train' })).toBeUndefined(); - }); - - it('rejects an unknown or wrongly cased scope', () => { - expect(lineFor({ version: 1, scope: 'everything' })).toBeUndefined(); - expect(lineFor({ version: 1, scope: 'Train' })).toBeUndefined(); - expect(lineFor({ version: 1, scope: ['train'] })).toBeUndefined(); - }); -}); - -describe('training notice on the pretend server', () => { - it('states that nothing is trained instead of the real sentence', () => { - expect(trainingNoticeText(true)).toBe(PREVIEW_NOTICE_TEXT); - expect(PREVIEW_NOTICE_TEXT).not.toContain('may be used to train'); - expect(PREVIEW_NOTICE_TEXT.toLowerCase()).toContain('pretend'); - }); - - it('keeps the ruled sentence everywhere else', () => { - expect(trainingNoticeText(false)).toBe(TRAINING_NOTICE_TEXT); - expect(trainingNoticeText(undefined)).toBe(TRAINING_NOTICE_TEXT); - }); - - it('shows the same text in a train room and at registration', () => { - const notice = parseRoomNotice({ version: 1, scope: 'train' }); - expect(roomTrainingLine(notice, true)).toBe(trainingNoticeText(true)); - expect(roomTrainingLine(notice, false)).toBe(trainingNoticeText(false)); - expect(roomTrainingLine(parseRoomNotice({ version: 1, scope: 'show' }), true)).toBeUndefined(); - }); -}); diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts deleted file mode 100644 index c3d6e42a..00000000 --- a/src/app/utils/trainingNotice.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { StateEvent } from '../../types/matrix/room'; - -export const TRAINING_NOTICE_TEXT = - "Everything said here may be used to train GameCult's AI stewards."; - -// Source: Meta, 2026-11-09: server-wide, human-to-human DMs excepted, estate-room exception open. -// Neither exception is implemented here: the registration notice is unconditional, and the room -// line follows only the room's notice event, with no check for direct messages or estate rooms. -// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from -// it; it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and -// that wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in -// two places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) -// and the line in any room whose org.gamecult.notice state event has scope 'train' -// (roomTrainingLine, shown by RoomTrainingLine.tsx from RoomView.tsx). -// The preview build runs against a pretend server where nothing is trained, so the real sentence -// would be false there. -export const PREVIEW_NOTICE_TEXT = - 'Pretend server: nothing you type here is stored or used to train anything.'; - -/** The one sentence every first-typing surface shows: the real one, or the pretend server's. */ -export const trainingNoticeText = (previewDemo?: boolean): string => - previewDemo === true ? PREVIEW_NOTICE_TEXT : TRAINING_NOTICE_TEXT; - -/** - * Heimdall's ruling: a room's notice is a room state event, not a pinned message. Type below, - * state_key '', content `{version, scope}`; scope exactly one of show, study or train. Heimdall's - * one version rule: a positive integer, no leading zeros, no greater than 4294967295; anything - * else is malformed. Absent or malformed in any way means no train line. - */ -export const MAX_NOTICE_VERSION = 4294967295; -export const ROOM_NOTICE_EVENT_TYPE = StateEvent.GameCultNotice; - -export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' }; - -/** The notice a state event's content declares, or undefined: no scope is ever inferred. */ -export const parseRoomNotice = (content: unknown): RoomNotice | undefined => { - if (typeof content !== 'object' || content === null) return undefined; - const { version, scope } = content as { version?: unknown; scope?: unknown }; - if ( - typeof version === 'number' && - Number.isSafeInteger(version) && - version > 0 && - version <= MAX_NOTICE_VERSION && - (scope === 'show' || scope === 'study' || scope === 'train') - ) { - return { version, scope }; - } - return undefined; -}; - -/** The line a room shows: only a notice that says `train` in terms gets the train sentence. */ -export const roomTrainingLine = ( - notice: RoomNotice | undefined, - previewDemo?: boolean -): string | undefined => (notice?.scope === 'train' ? trainingNoticeText(previewDemo) : undefined); diff --git a/src/app/utils/uploadAbortProbe.test.ts b/src/app/utils/uploadAbortProbe.test.ts deleted file mode 100644 index 557908ef..00000000 --- a/src/app/utils/uploadAbortProbe.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - describeComponentLifecycle, - formatAbortStack, - startAbortProbe, - uploadContent, - uploadFailure, -} from './matrix'; - -const chromeStack = [ - 'Error', - ' at wrapper (https://chat.example/assets/index-abc.js:1:100)', - ' at https://chat.example/assets/index-abc.js:1:200', - ' at Object.cb (https://chat.example/assets/vendor-def.js:7:8)', - ' at https://chat.example/assets/a.js:2:3', - ' at https://chat.example/assets/b.js:4:5', - ' at https://chat.example/assets/c.js:6:7', - ' at https://chat.example/assets/d.js:8:9', - ' at https://chat.example/assets/e.js:10:11', -].join('\n'); - -describe('formatAbortStack', () => { - it('keeps at most six frames as file:line:col, skipping the probe wrapper and the error line', () => { - const out = formatAbortStack(chromeStack); - const frames = out.split(' < '); - expect(frames).toHaveLength(6); - expect(frames[0]).toBe('index-abc.js:1:200'); - expect(out).not.toContain('https://'); - expect(out).not.toContain('wrapper'); - }); - - it('copes with an empty stack', () => { - expect(formatAbortStack('')).toBe('no stack'); - }); -}); - -describe('abort probe', () => { - const original = AbortController.prototype.abort; - - afterEach(() => { - vi.unstubAllGlobals(); - AbortController.prototype.abort = original; - }); - - it('records an AbortController.abort made while it runs, still aborts, and restores afterwards', () => { - const probe = startAbortProbe(); - expect(AbortController.prototype.abort).not.toBe(original); - const c = new AbortController(); - c.abort(); - expect(c.signal.aborted).toBe(true); - probe.stop(); - expect(AbortController.prototype.abort).toBe(original); - expect(probe.calls).toHaveLength(1); - expect(probe.calls[0].api).toBe('AbortController.abort'); - }); - - it('records XMLHttpRequest.prototype.abort and restores it', () => { - const xhrAbort = vi.fn(); - class FakeXhr { - // eslint-disable-next-line class-methods-use-this - abort() { - xhrAbort(); - } - } - vi.stubGlobal('XMLHttpRequest', FakeXhr); - const probe = startAbortProbe(); - new FakeXhr().abort(); - probe.stop(); - expect(xhrAbort).toHaveBeenCalledTimes(1); - expect(probe.calls.map((c) => c.api)).toContain('XHR.abort'); - expect(FakeXhr.prototype.abort.toString()).not.toContain('collectors'); - const after = new FakeXhr(); - after.abort(); - expect(xhrAbort).toHaveBeenCalledTimes(2); - }); - - it('records nothing after stop', () => { - const probe = startAbortProbe(); - probe.stop(); - new AbortController().abort(); - expect(probe.calls).toHaveLength(0); - }); - - it('keeps the wrapper until the last overlapping probe stops, then restores the original', () => { - const a = startAbortProbe(); - const b = startAbortProbe(); - a.stop(); - new AbortController().abort(); - expect(b.calls).toHaveLength(1); - expect(a.calls).toHaveLength(0); - b.stop(); - expect(AbortController.prototype.abort).toBe(original); - }); - - it('restores the original when the upload attempt throws', async () => { - const mx = { uploadContent: () => Promise.reject(new Error('boom')) } as any; - await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() }); - expect(AbortController.prototype.abort).toBe(original); - }); - - it('restores the original when uploadContent itself throws synchronously', async () => { - const mx = { - uploadContent: () => { - throw new Error('sync'); - }, - } as any; - await expect( - uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() }) - ).rejects.toThrow('sync'); - expect(AbortController.prototype.abort).toBe(original); - }); -}); - -describe('failure text', () => { - const abort = () => new DOMException('', 'AbortError'); - - it('lists abort calls seen during the attempt', async () => { - const mx = { - uploadContent: () => { - new AbortController().abort(); - return Promise.reject(abort()); - }, - } as any; - const onError = vi.fn(); - await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError }); - // retried once: the final message carries the second attempt and attempt 1's summary - const msg = onError.mock.calls[0][0].message; - expect(msg).toContain('abort calls: AbortController.abort'); - expect(msg).toContain('retried (attempt 1: AbortError'); - }); - - it('says none when nothing in the page called abort', async () => { - const err = await uploadFailure(abort(), new Blob(['x']), { - startedAt: Date.now(), - aborts: [], - }); - expect(err.message).toContain('abort calls: none'); - }); - - it('reports the component lifecycle when given', async () => { - const mx = { uploadContent: () => Promise.reject(new Error('Timeout')) } as any; - const onError = vi.fn(); - await uploadContent(mx, new Blob(['x']), { - onSuccess: vi.fn(), - onError, - describeLifecycle: () => 'component remounted 0x, renders 3 (2 within 300ms)', - }); - expect(onError.mock.calls[0][0].message).toContain('renders 3 (2 within 300ms)'); - }); -}); - -describe('describeComponentLifecycle', () => { - it('counts renders after attach and those within 300ms, and reports an unmount', () => { - const text = describeComponentLifecycle({ - attachedAt: 1000, - rendersAtAttach: 1, - renders: [990, 1100, 1250, 1400], - unmountedAt: 1500, - }); - expect(text).toBe('component: 3 renders since attach (2 within 300ms), unmounted +500ms'); - }); - - it('says not unmounted when it was not', () => { - const text = describeComponentLifecycle({ - attachedAt: 1000, - rendersAtAttach: 1, - renders: [990], - }); - expect(text).toBe('component: 0 renders since attach (0 within 300ms), not unmounted'); - }); -}); diff --git a/src/app/utils/uploadFailure.test.ts b/src/app/utils/uploadFailure.test.ts deleted file mode 100644 index ec1e8115..00000000 --- a/src/app/utils/uploadFailure.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { noteUserActivity, uploadFailure } from './matrix'; - -const abort = () => new DOMException('', 'AbortError'); - -describe('uploadFailure', () => { - it('keeps the error name as errcode, never the message', async () => { - const err = await uploadFailure(abort(), new Blob(['abc'], { type: 'image/png' })); - expect(err.errcode).toBe('AbortError'); - }); - - it('never reduces an empty-message error to "Unknown message"', async () => { - const err = await uploadFailure(abort(), new Blob(['abc'])); - expect(err.message).not.toContain('Unknown message'); - expect(err.message).toContain('AbortError'); - }); - - it('reports size and type of the file', async () => { - const err = await uploadFailure(abort(), new Blob(['abcd'], { type: 'image/png' })); - expect(err.message).toContain('4 bytes'); - expect(err.message).toContain('image/png'); - }); - - it('keeps the original error as cause', async () => { - const e = abort(); - const err = await uploadFailure(e, new Blob(['x'])); - expect((err as any).cause).toBe(e); - }); - - it('says whether the browser could still read the file', async () => { - const ok = await uploadFailure(abort(), new Blob(['x'])); - expect(ok.message).toContain('file readable'); - const bad = new Blob(['x']); - (bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError')); - const err = await uploadFailure(abort(), bad); - expect(err.message).toContain('file unreadable (NotReadableError)'); - }); - - it('keeps a real message when the error has one', async () => { - const err = await uploadFailure(new Error('Timeout'), new Blob(['x'])); - expect(err.message).toContain('Timeout'); - expect(err.errcode).toBe('Error'); - }); -}); - -describe('uploadFailure client trace', () => { - const blob = new Blob(['abcd'], { type: 'image/jpeg' }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it('reports connectivity, visibility and idle time at the moment of failure', async () => { - vi.useFakeTimers(); - vi.setSystemTime(10_000); - vi.stubGlobal('navigator', { - onLine: false, - connection: { effectiveType: '3g', rtt: 300, downlink: 1.5, saveData: true }, - }); - vi.stubGlobal('document', { visibilityState: 'hidden' }); - noteUserActivity(); - vi.setSystemTime(13_000); - const err = await uploadFailure(abort(), blob, { startedAt: 12_000 }); - expect(err.message).toContain('offline'); - expect(err.message).toContain('3g rtt 300ms down 1.5Mbps saveData on'); - expect(err.message).toContain('tab hidden'); - expect(err.message).toContain('idle 3000ms'); - expect(err.message).toContain('failed after 1000ms'); - }); - - it('says so when no progress event ever arrived', async () => { - vi.useFakeTimers(); - vi.setSystemTime(5_000); - const err = await uploadFailure(abort(), blob, { startedAt: 4_000 }); - expect(err.message).toContain('no progress'); - }); - - it('reports time to first progress and bytes loaded when some arrived', async () => { - vi.useFakeTimers(); - vi.setSystemTime(9_000); - const err = await uploadFailure(abort(), blob, { - startedAt: 5_000, - firstProgressAt: 5_120, - loaded: 65536, - total: 700000, - }); - expect(err.message).toContain('first progress 120ms'); - expect(err.message).toContain('65536/700000B'); - expect(err.message).toContain('failed after 4000ms'); - }); - - it('copes with a browser that has no connection info or document', async () => { - vi.stubGlobal('navigator', { onLine: true }); - vi.stubGlobal('document', undefined); - const err = await uploadFailure(abort(), blob, { startedAt: Date.now() }); - expect(err.message).toContain('online'); - expect(err.message).toContain('net n/a'); - }); -}); diff --git a/src/app/utils/uploadRetry.test.ts b/src/app/utils/uploadRetry.test.ts deleted file mode 100644 index 3e9957c2..00000000 --- a/src/app/utils/uploadRetry.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { uploadContent } from './matrix'; - -const abort = () => new DOMException('', 'AbortError'); -const blob = new Blob(['abcd'], { type: 'image/jpeg' }); - -type Step = (opts: any) => Promise; -const fakeMx = (steps: Step[]) => { - const calls: any[] = []; - const mx = { - uploadContent: vi.fn((_file: unknown, opts: any) => { - calls.push(opts); - return steps[calls.length - 1](opts); - }), - }; - return { mx: mx as any, calls }; -}; -const run = async (mx: any) => { - const onSuccess = vi.fn(); - const onError = vi.fn(); - const onPromise = vi.fn(); - await uploadContent(mx, blob, { onSuccess, onError, onPromise }); - return { onSuccess, onError, onPromise }; -}; - -describe('uploadContent retry', () => { - afterEach(() => vi.useRealTimers()); - - it('retries once when the first attempt aborts at once with no progress', async () => { - const { mx } = fakeMx([ - () => Promise.reject(abort()), - async () => ({ content_uri: 'mxc://a/b' }), - ]); - const r = await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(2); - expect(r.onSuccess).toHaveBeenCalledWith('mxc://a/b'); - expect(r.onError).not.toHaveBeenCalled(); - expect(r.onPromise).toHaveBeenCalledTimes(2); - }); - - it('retries at most once and reports both attempts when the retry fails too', async () => { - const { mx } = fakeMx([() => Promise.reject(abort()), () => Promise.reject(abort())]); - const r = await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(2); - const msg = r.onError.mock.calls[0][0].message; - expect(msg).toContain('retried'); - expect(msg).toContain('attempt 1: AbortError'); - expect(msg).toContain('no progress'); - }); - - it('never retries after a progress event', async () => { - const { mx } = fakeMx([ - (opts) => { - opts.progressHandler({ loaded: 10, total: 100 }); - return Promise.reject(abort()); - }, - async () => ({ content_uri: 'mxc://a/b' }), - ]); - const r = await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(1); - expect(r.onError).toHaveBeenCalledTimes(1); - expect(r.onError.mock.calls[0][0].message).not.toContain('retried'); - }); - - it('never retries after the user cancelled', async () => { - const { mx } = fakeMx([ - (opts) => { - opts.abortController.abort(); - return Promise.reject(abort()); - }, - async () => ({ content_uri: 'mxc://a/b' }), - ]); - const r = await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(1); - expect(r.onError).toHaveBeenCalledTimes(1); - }); - - it('never retries an error that is not an abort', async () => { - const { mx } = fakeMx([() => Promise.reject(new Error('Timeout')), async () => ({})]); - const r = await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(1); - expect(r.onError).toHaveBeenCalledTimes(1); - }); - - it('never retries an abort that took three seconds or more', async () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const { mx } = fakeMx([ - () => { - vi.setSystemTime(3000); - return Promise.reject(abort()); - }, - async () => ({ content_uri: 'mxc://a/b' }), - ]); - const r = await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(1); - expect(r.onError).toHaveBeenCalledTimes(1); - }); - - it('retries the same abort at 2999ms', async () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const { mx } = fakeMx([ - () => { - vi.setSystemTime(2999); - return Promise.reject(abort()); - }, - async () => ({ content_uri: 'mxc://a/b' }), - ]); - await run(mx); - expect(mx.uploadContent).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/app/utils/uploadXhrProbe.test.ts b/src/app/utils/uploadXhrProbe.test.ts deleted file mode 100644 index e72afa3d..00000000 --- a/src/app/utils/uploadXhrProbe.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* eslint-disable class-methods-use-this, @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, no-unused-vars */ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { startAbortProbe, uploadContent } from './matrix'; - -class FakeXhr extends EventTarget { - upload = new EventTarget(); - - readyState = 0; - - status = 0; - - open(_method: string, _url: string) { - this.readyState = 1; - } - - send() { - this.readyState = 2; - } - - abort() {} -} - -const origOpen = FakeXhr.prototype.open; -const origSend = FakeXhr.prototype.send; -const uploadUrl = - 'https://matrix.example/_matrix/media/v3/upload?filename=a.jpg&access_token=SECRET'; - -describe('XHR probe', () => { - afterEach(() => { - vi.unstubAllGlobals(); - FakeXhr.prototype.open = origOpen; - FakeXhr.prototype.send = origSend; - }); - - it('records which events an upload XHR fired and how it ended, without the query string', () => { - vi.stubGlobal('XMLHttpRequest', FakeXhr); - const probe = startAbortProbe(); - const x = new FakeXhr(); - x.open('POST', uploadUrl); - x.send(); - x.upload.dispatchEvent(new Event('loadstart')); - x.upload.dispatchEvent(new Event('error')); - x.dispatchEvent(new Event('error')); - x.readyState = 4; - x.status = 0; - probe.stop(); - expect(probe.xhrs).toHaveLength(1); - const text = probe.describeXhrs(); - expect(text).toContain('POST /_matrix/media/v3/upload'); - expect(text).toContain('readyState 4 status 0'); - expect(text).toContain('upload.loadstart'); - expect(text).toContain('upload.error'); - expect(text).toContain('error'); - expect(text).not.toContain('SECRET'); - expect(text).not.toContain('access_token'); - }); - - it('ignores XHRs that are not uploads', () => { - vi.stubGlobal('XMLHttpRequest', FakeXhr); - const probe = startAbortProbe(); - const x = new FakeXhr(); - x.open('GET', 'https://matrix.example/_matrix/client/v3/sync'); - x.send(); - probe.stop(); - expect(probe.xhrs).toHaveLength(0); - expect(probe.describeXhrs()).toBe('xhr: none seen'); - }); - - it('still calls the real open and send and restores both afterwards', () => { - const open = vi.fn(); - const send = vi.fn(); - FakeXhr.prototype.open = open; - FakeXhr.prototype.send = send; - vi.stubGlobal('XMLHttpRequest', FakeXhr); - const probe = startAbortProbe(); - const x = new FakeXhr(); - x.open('POST', uploadUrl); - x.send(); - expect(open).toHaveBeenCalledWith('POST', uploadUrl); - expect(send).toHaveBeenCalledTimes(1); - probe.stop(); - expect(FakeXhr.prototype.open).toBe(open); - expect(FakeXhr.prototype.send).toBe(send); - }); - - it('records nothing for requests made after stop', () => { - vi.stubGlobal('XMLHttpRequest', FakeXhr); - const probe = startAbortProbe(); - probe.stop(); - const x = new FakeXhr(); - x.open('POST', uploadUrl); - x.send(); - expect(probe.xhrs).toHaveLength(0); - }); -}); - -describe('uploadContent in-memory copy and failure line', () => { - const abort = () => new DOMException('', 'AbortError'); - - it('uploads an in-memory copy with the same bytes, name and type, not the original', async () => { - const file = new File(['abcdef'], 'a.jpg', { type: 'image/jpeg' }); - let sent: any; - const mx = { - uploadContent: vi.fn(async (f: any) => { - sent = f; - return { content_uri: 'mxc://a/b' }; - }), - } as any; - await uploadContent(mx, file, { onSuccess: vi.fn(), onError: vi.fn() }); - expect(sent).not.toBe(file); - expect(sent.type).toBe('image/jpeg'); - expect(sent.name).toBe('a.jpg'); - expect(await sent.text()).toBe('abcdef'); - }); - - it('copies again before the retry', async () => { - const seen: any[] = []; - const mx = { - uploadContent: vi.fn((f: any) => { - seen.push(f); - return seen.length === 1 - ? Promise.reject(abort()) - : Promise.resolve({ content_uri: 'mxc://a/b' }); - }), - } as any; - await uploadContent(mx, new Blob(['xy']), { onSuccess: vi.fn(), onError: vi.fn() }); - expect(seen).toHaveLength(2); - expect(seen[0]).not.toBe(seen[1]); - }); - - it('reports an unreadable file and never starts the upload', async () => { - const bad = new Blob(['x']); - (bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError')); - const mx = { uploadContent: vi.fn() } as any; - const onError = vi.fn(); - await uploadContent(mx, bad, { onSuccess: vi.fn(), onError }); - expect(mx.uploadContent).not.toHaveBeenCalled(); - expect(onError.mock.calls[0][0].message).toContain( - 'unreadable before upload (NotReadableError)' - ); - }); - - it('puts the XHR end state and signal.aborted in the failure line', async () => { - vi.stubGlobal('XMLHttpRequest', FakeXhr); - const mx = { - uploadContent: () => { - const x = new FakeXhr(); - x.open('POST', uploadUrl); - x.send(); - x.upload.dispatchEvent(new Event('error')); - x.readyState = 4; - return Promise.reject(new Error('Timeout')); - }, - } as any; - const onError = vi.fn(); - await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError }); - const msg = onError.mock.calls[0][0].message; - expect(msg).toContain('readyState 4 status 0'); - expect(msg).toContain('upload.error'); - expect(msg).toContain('signal.aborted false'); - vi.unstubAllGlobals(); - }); -}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871a7625..498d4f75 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -3,7 +3,6 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; -import { startResumeSync } from './resumeSync'; type Session = { baseUrl: string; @@ -41,14 +40,10 @@ export const initClient = async (session: Session): Promise => { return mx; }; -const startSync = (mx: MatrixClient) => - mx.startClient({ +export const startClient = async (mx: MatrixClient) => { + await mx.startClient({ lazyLoadMembers: true, }); - -export const startClient = async (mx: MatrixClient) => { - await startSync(mx); - startResumeSync(mx, { restart: startSync }); }; export const clearCacheAndReload = async (mx: MatrixClient) => { diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts deleted file mode 100644 index eada97af..00000000 --- a/src/client/resumeSync.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; - -class Target extends EventTarget { - visibilityState: 'visible' | 'hidden' = 'visible'; -} - -const setup = (state: string | null = 'SYNCING') => { - const doc = new Target(); - const win = new Target(); - const mx = { - state, - getSyncState: () => mx.state, - retryImmediately: vi.fn(() => true), - stopClient: vi.fn(), - }; - const restart = vi.fn(async () => {}); - const stop = startResumeSync(mx, { - restart, - doc: doc as never, - win: win as never, - }); - const hide = () => { - doc.visibilityState = 'hidden'; - doc.dispatchEvent(new Event('visibilitychange')); - }; - const show = () => { - doc.visibilityState = 'visible'; - doc.dispatchEvent(new Event('visibilitychange')); - }; - return { doc, win, mx, restart, stop, hide, show }; -}; - -const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); - -describe('startResumeSync', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('nudges the poll when the tab returns after a real absence', async () => { - const t = setup(); - t.hide(); - await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS); - t.show(); - expect(t.mx.retryImmediately).not.toHaveBeenCalled(); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - }); - - it('ignores a hide shorter than the minimum', async () => { - const t = setup(); - t.hide(); - await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); - t.show(); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.retryImmediately).not.toHaveBeenCalled(); - }); - - it('handles pageshow only when restored from bfcache, and online always', async () => { - const t = setup(); - t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: false })); - await settle(); - expect(t.mx.retryImmediately).not.toHaveBeenCalled(); - t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); - t.win.dispatchEvent(new Event('online')); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); - }); - - it('debounces a burst of triggers into one nudge', async () => { - const t = setup(); - t.win.dispatchEvent(new Event('online')); - t.win.dispatchEvent(new Event('online')); - t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - }); - - it('does not restart when sync is healthy after the check window', async () => { - for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { - const t = setup(state); - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.restart).not.toHaveBeenCalled(); - t.stop(); - } - }); - - it('does one stop/start when sync is still unhealthy, never overlapping', async () => { - const t = setup('RECONNECTING'); - let release!: () => void; - t.restart.mockImplementation( - () => - new Promise((r) => { - release = r; - }) - ); - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.stopClient).toHaveBeenCalledTimes(1); - expect(t.restart).toHaveBeenCalledTimes(1); - // a trigger while the restart is in flight does nothing - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - expect(t.restart).toHaveBeenCalledTimes(1); - release(); - await vi.advanceTimersByTimeAsync(0); - t.win.dispatchEvent(new Event('online')); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); - }); - - it('removes listeners and cancels pending work on dispose', async () => { - const t = setup('ERROR'); - t.win.dispatchEvent(new Event('online')); - await settle(); - t.stop(); - await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); - expect(t.restart).not.toHaveBeenCalled(); - t.win.dispatchEvent(new Event('online')); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts deleted file mode 100644 index 5f81547b..00000000 --- a/src/client/resumeSync.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ -export type SyncHost = { - getSyncState(): string | null; - retryImmediately(): boolean; - stopClient(): void; -}; - -// matrix-js-sdk SyncState values for a live sync loop. -const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); - -export const RESUME_DEBOUNCE_MS = 250; -export const MIN_HIDDEN_MS = 5000; -export const HEALTH_CHECK_MS = 3000; - -type ResumeSyncOptions = { - /** Event sources and visibility; default to the browser globals. */ - doc?: Pick; - win?: Pick; - /** Restart the client after stopClient; must resolve once startClient has been issued. */ - restart: (mx: Host) => Promise; -}; - -/** - * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. - * On return (visible after a real absence, pageshow from bfcache, online) nudge the - * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one - * stop/start. Returns a disposer that removes every listener and timer. - */ -export const startResumeSync = ( - mx: Host, - { restart, doc = document, win = window }: ResumeSyncOptions -): (() => void) => { - let hiddenAt: number | undefined; - let debounce: ReturnType | undefined; - let healthCheck: ReturnType | undefined; - let restarting = false; - let disposed = false; - - const check = async () => { - healthCheck = undefined; - if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; - restarting = true; - try { - mx.stopClient(); - await restart(mx); - } finally { - restarting = false; - } - }; - - const resume = () => { - if (debounce !== undefined) clearTimeout(debounce); - debounce = setTimeout(() => { - debounce = undefined; - if (disposed || restarting || healthCheck !== undefined) return; - mx.retryImmediately(); - healthCheck = setTimeout(check, HEALTH_CHECK_MS); - }, RESUME_DEBOUNCE_MS); - }; - - const onVisibility = () => { - if (doc.visibilityState === 'hidden') { - hiddenAt = Date.now(); - return; - } - const away = hiddenAt === undefined ? Infinity : Date.now() - hiddenAt; - hiddenAt = undefined; - if (away >= MIN_HIDDEN_MS) resume(); - }; - const onPageShow = (e: Event) => { - if ((e as PageTransitionEvent).persisted) resume(); - }; - - doc.addEventListener('visibilitychange', onVisibility); - win.addEventListener('pageshow', onPageShow); - win.addEventListener('online', resume); - - return () => { - disposed = true; - if (debounce !== undefined) clearTimeout(debounce); - if (healthCheck !== undefined) clearTimeout(healthCheck); - doc.removeEventListener('visibilitychange', onVisibility); - win.removeEventListener('pageshow', onPageShow); - win.removeEventListener('online', resume); - }; -}; diff --git a/src/colors.css.ts b/src/colors.css.ts index b4569b8b..9c03c2cb 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -1,6 +1,5 @@ import { createTheme } from '@vanilla-extract/css'; import { color } from 'folds'; -import { baked } from './sky/baked'; export const silverTheme = createTheme(color, { Background: { @@ -241,22 +240,91 @@ export const butterTheme = createTheme(color, { // GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a, // orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so // Success/Warning/Critical are invented here and kept distinct from the orange accent. -// Midnight Sky and Noonday Sky: every colour is albedo x irradiance from src/sky/sun.js, -// baked by scripts/bake-sky.mjs. -export const midnightSkyTheme = createTheme(color, { - ...baked.midnight.tokens, +export const gamecultTheme = createTheme(color, { + Background: { + Container: '#07111a', + ContainerHover: '#0d1a26', + ContainerActive: '#122130', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + Surface: { + Container: '#09141f', + ContainerHover: '#0f1c29', + ContainerActive: '#152433', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + SurfaceVariant: { + Container: '#0d1a26', + ContainerHover: '#122130', + ContainerActive: '#16273a', + ContainerLine: '#1b2a38', + OnContainer: '#b7c7d9', + }, + Primary: { + Main: '#ff8a2a', + MainHover: '#ff9640', + MainActive: '#ffa155', + MainLine: '#ffab66', + OnMain: '#2a1300', + Container: '#2b1a0e', + ContainerHover: '#35200f', + ContainerActive: '#402611', + ContainerLine: '#4d2d13', + OnContainer: '#ffd3ab', + }, + Secondary: { + Main: '#eef5ff', + MainHover: '#dbe7f6', + MainActive: '#cddced', + MainLine: '#bfd0e4', + OnMain: '#07111a', + Container: '#16212c', + ContainerHover: '#1c2a38', + ContainerActive: '#233342', + ContainerLine: '#2a3b4c', + OnContainer: '#eef5ff', + }, + Success: { + Main: '#5fd0a4', + MainHover: '#72d8b0', + MainActive: '#84deba', + MainLine: '#93e3c2', + OnMain: '#042014', + Container: '#0c2a21', + ContainerHover: '#10342a', + ContainerActive: '#143e32', + ContainerLine: '#19493a', + OnContainer: '#c4f1de', + }, + Warning: { + Main: '#f2d04b', + MainHover: '#f5d963', + MainActive: '#f7e078', + MainLine: '#f9e68b', + OnMain: '#2a2100', + Container: '#2a2410', + ContainerHover: '#352d13', + ContainerActive: '#403616', + ContainerLine: '#4d411a', + OnContainer: '#f8ebb0', + }, + Critical: { + Main: '#ff7a90', + MainHover: '#ff8fa1', + MainActive: '#ffa0af', + MainLine: '#ffb0bc', + OnMain: '#3a0710', + Container: '#2e1118', + ContainerHover: '#391520', + ContainerActive: '#441a27', + ContainerLine: '#51202e', + OnContainer: '#ffd0d8', + }, Other: { - FocusRing: 'rgba(132, 200, 181, 0.6)', + FocusRing: 'rgba(89, 183, 255, 0.55)', Shadow: 'rgba(0, 0, 0, 0.6)', - Overlay: 'rgba(3, 4, 10, 0.8)', - }, -}); - -export const noondaySkyTheme = createTheme(color, { - ...baked.noonday.tokens, - Other: { - FocusRing: 'rgba(43, 80, 40, 0.6)', - Shadow: 'rgba(40, 30, 20, 0.25)', - Overlay: 'rgba(20, 24, 30, 0.5)', + Overlay: 'rgba(3, 7, 13, 0.8)', }, }); diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts index 57fbbf05..4aaf3e76 100644 --- a/src/gamecult.css.ts +++ b/src/gamecult.css.ts @@ -18,8 +18,3 @@ globalStyle(scope, { [config.lineHeight.D400]: '1.7', }, }); - -// Nunito for the GameCult look (the three-doors mock-up); the stock themes keep Inter. -globalStyle(scope, { - fontFamily: "'Nunito Variable', var(--font-secondary)", -}); diff --git a/src/index.css b/src/index.css index 99b355c0..f751b94d 100644 --- a/src/index.css +++ b/src/index.css @@ -136,11 +136,44 @@ li p::before { display: inline-block; } -/* GameCult identity (type only; colour is sky.css.ts): Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */ +/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */ .gamecult-theme { + --tc-link: #59b7ff; + + --mx-uc-1: hsl(208, 100%, 72%); + --mx-uc-2: hsl(280, 100%, 78%); + --mx-uc-3: hsl(163, 70%, 65%); + --mx-uc-4: hsl(343, 100%, 76%); + --mx-uc-5: hsl(28, 100%, 66%); + --mx-uc-6: hsl(181, 70%, 62%); + --mx-uc-7: hsl(243, 100%, 80%); + --mx-uc-8: hsl(94, 70%, 70%); + --font-secondary: 'Ubuntu', var(--font-emoji), sans-serif; --font-title: 'Montserrat', 'Ubuntu', sans-serif; --font-mono: 'IBM Plex Mono', monospace; + --gamecult-rule: rgba(148, 163, 184, 0.22); + --gamecult-wash: 0.5; + + background-color: #07111a; + background-image: radial-gradient( + circle at 78% 14%, + rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))), + transparent 18% + ), + radial-gradient( + circle at 18% 10%, + rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))), + transparent 24% + ), + radial-gradient( + circle at 52% 0%, + rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))), + transparent 28% + ), + linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%); + background-attachment: fixed; + color-scheme: dark; font-weight: 300; } diff --git a/src/index.tsx b/src/index.tsx index 6c7a3bc2..672ac5c0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,8 @@ /* eslint-disable import/first */ -import './polyfills'; -import './preview-demo/seed'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; -import '@fontsource-variable/nunito/index.css'; import '@fontsource/ubuntu/300.css'; import '@fontsource/ubuntu/400.css'; import '@fontsource/ubuntu/500.css'; @@ -54,16 +51,6 @@ if ('serviceWorker' in navigator) { }); } -// The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev -// workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it. -const installPreviewDemo = async () => { - if (import.meta.env.VITE_PREVIEW_DEMO !== '1') return; - const config = await fetch(`${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`) - .then((r) => r.json()) - .catch(() => ({})); - if (config.previewDemo === true) (await import('./preview-demo')).installDemo(); -}; - const mountApp = () => { const rootContainer = document.getElementById('root'); @@ -76,4 +63,4 @@ const mountApp = () => { root.render(); }; -installPreviewDemo().finally(mountApp); +mountApp(); diff --git a/src/polyfills.ts b/src/polyfills.ts deleted file mode 100644 index 2f018c4b..00000000 --- a/src/polyfills.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable no-extend-native, func-names */ -// Safari 14 (iPadOS 14) lacks these; the bundle (ours and matrix-js-sdk) calls them at runtime. Safari 15.4 has all. -// Loaded first by src/index.tsx. Each is installed only when missing. - -function at(this: ArrayLike, n: number): T | undefined { - const len = this.length; - const i = Math.trunc(n) || 0; - const k = i < 0 ? len + i : i; - return k < 0 || k >= len ? undefined : this[k]; -} -if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', { value: at, writable: true, configurable: true }); -if (!String.prototype.at) - Object.defineProperty(String.prototype, 'at', { value: at, writable: true, configurable: true }); - -if (!(Array.prototype as unknown as Record).findLast) - Object.defineProperty(Array.prototype, 'findLast', { - value: function (this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) { - for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return this[i]; - return undefined; - }, - writable: true, - configurable: true, - }); -if (!(Array.prototype as unknown as Record).findLastIndex) - Object.defineProperty(Array.prototype, 'findLastIndex', { - value: function (this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) { - for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return i; - return -1; - }, - writable: true, - configurable: true, - }); - -if (!(Object as unknown as Record).hasOwn) - Object.defineProperty(Object, 'hasOwn', { - value: (o: object, k: PropertyKey) => Object.prototype.hasOwnProperty.call(o, k), - writable: true, - configurable: true, - }); - -// Structured clone for the data the client clones: plain objects, arrays, Map, Set, Date, RegExp, typed arrays, with cycles. -if (typeof (globalThis as { structuredClone?: unknown }).structuredClone !== 'function') { - const clone = (v: unknown, seen: Map): unknown => { - if (v === null || typeof v !== 'object') return v; - if (seen.has(v)) return seen.get(v); - if (v instanceof Date) return new Date(v.getTime()); - if (v instanceof RegExp) return new RegExp(v.source, v.flags); - if (ArrayBuffer.isView(v)) return (v as unknown as { slice: () => unknown }).slice(); - if (v instanceof ArrayBuffer) return v.slice(0); - if (v instanceof Map) { - const m = new Map(); - seen.set(v, m); - v.forEach((val, key) => m.set(clone(key, seen), clone(val, seen))); - return m; - } - if (v instanceof Set) { - const s = new Set(); - seen.set(v, s); - v.forEach((val) => s.add(clone(val, seen))); - return s; - } - const out: Record | unknown[] = Array.isArray(v) ? [] : {}; - seen.set(v, out); - Object.keys(v).forEach((k) => { - (out as Record)[k] = clone((v as Record)[k], seen); - }); - return out; - }; - (globalThis as { structuredClone?: unknown }).structuredClone = (v: unknown) => clone(v, new Map()); -} - -export {}; diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts deleted file mode 100644 index c96a4ccf..00000000 --- a/src/preview-demo/fixtures.ts +++ /dev/null @@ -1,311 +0,0 @@ -// Fixtures for the preview-only pretend homeserver. Public-safe by construction: the only -// names are GameCult's Minds and a fictional visitor; every room, line and id is invented here. -export const DEMO_SERVER = 'demo.invalid'; -export const DEMO_ORIGIN = `https://${DEMO_SERVER}`; -export const ME = `@demo:${DEMO_SERVER}`; -export const DEMO_DEVICE = 'DEMOPREVIEW'; -export const DEMO_TOKEN = 'demo-preview-token'; - -export type DemoEvent = { - type: string; - state_key?: string; - sender: string; - content: Record; - event_id: string; - origin_server_ts: number; - unsigned?: Record; -}; - -export type DemoUser = { id: string; name: string }; -export const USERS: DemoUser[] = [ - { id: ME, name: 'Demo' }, - { id: `@eureka:${DEMO_SERVER}`, name: 'Eureka' }, - { id: `@void:${DEMO_SERVER}`, name: 'Void' }, - { id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' }, - { id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' }, - { id: `@heimdall:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, -]; -const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; - -// `html` is the formatted body a Mind's Markdown renders to; `body` is its plain-text twin. -export type DemoLine = { - from: string; - body: string; - html?: string; - pose?: string; - minutesAgo: number; -}; -export type DemoRoom = { - id: string; - alias?: string; - name: string; - space?: boolean; - direct?: boolean; - topic?: string; - members: string[]; - notice?: 'show' | 'study' | 'train'; - parent?: string; - steward?: { mind: string; repo: string }; - joined: boolean; - lines: DemoLine[]; -}; - -export const ROOT = `!root:${DEMO_SERVER}`; -export const PROJECTS = `!projects:${DEMO_SERVER}`; -export const GENERAL = `!general:${DEMO_SERVER}`; -export const COMMONS = `!commons:${DEMO_SERVER}`; - -const everyone = USERS.map((u) => u.id); -export const MIND_IDS = [user('eureka'), user('void'), user('nibu')]; -const minds = MIND_IDS; - -export const ROOMS: DemoRoom[] = [ - { - id: ROOT, - alias: `#gamecult:${DEMO_SERVER}`, - name: 'GameCult', - space: true, - members: [ME], - joined: true, - lines: [], - }, - { - id: GENERAL, - alias: `#general:${DEMO_SERVER}`, - name: 'General', - topic: 'A pretend room on a pretend server.', - members: everyone, - notice: 'train', - parent: ROOT, - joined: true, - lines: [ - { from: user('visitor'), body: 'Is anyone around? I just found this place.', minutesAgo: 42 }, - { - from: user('eureka'), - body: 'Welcome. This is a preview with no real server behind it.', - pose: 'happy', - minutesAgo: 40, - }, - { - from: user('void'), - body: 'The portrait beside a message is the pose its sender chose. Turn the setting on if you cannot see it.', - pose: 'reading', - minutesAgo: 38, - }, - { - from: user('nibu'), - body: 'Check the sky behind the login screen while you are here.', - pose: 'triumph', - minutesAgo: 35, - }, - { from: user('visitor'), body: 'Can I type here?', minutesAgo: 30 }, - { - from: user('eureka'), - body: 'Yes. Say something and a Mind will answer. The composer underlines misspelled words, so try "recieve".', - pose: 'amused', - minutesAgo: 29, - }, - { - from: user('void'), - body: 'Nothing you type leaves this page.', - pose: 'deadpan', - minutesAgo: 25, - }, - // A long display name: the name pill has to show all of it on a phone. - { from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 }, - ], - }, - { - id: PROJECTS, - alias: `#projects:${DEMO_SERVER}`, - name: 'Projects', - space: true, - members: [ME], - parent: ROOT, - joined: true, - lines: [], - }, - { - id: `!ratatoskr:${DEMO_SERVER}`, - alias: `#ratatoskr:${DEMO_SERVER}`, - name: 'Ratatoskr', - topic: 'The chat client, built by the people using it.', - members: [ME, ...minds], - notice: 'study', - parent: PROJECTS, - steward: { mind: 'nibu', repo: 'Ratatoskr' }, - joined: true, - lines: [ - { - from: user('nibu'), - body: 'The ridge scene is on at the low tier in this preview.', - pose: 'resolute', - minutesAgo: 90, - }, - { - from: user('void'), - body: 'Backgrounded tabs now nudge sync when you come back, so the list is not stale.', - pose: 'point', - minutesAgo: 80, - }, - { - from: user('eureka'), - body: 'Messages from Minds carry a pose label; this one is thinking.', - pose: 'thinking', - minutesAgo: 70, - }, - { - from: user('nibu'), - body: 'This label is not in the pose catalogue, so the portrait falls back to neutral.', - pose: 'no-such-pose', - minutesAgo: 60, - }, - { - from: user('void'), - pose: 'reading', - minutesAgo: 22, - body: [ - 'Review of the sync change. Three things before it merges.', - '', - 'The resume path is right. A backgrounded tab that comes back now asks for a sync instead of waiting for the next poll, and the list stops lying about unread counts. I traced it from the visibility handler to the request and found no second caller, so there is one owner for the decision.', - '', - 'What I would change:', - '- the timeout is a constant, and it should be the value the homeserver was asked for', - '- the retry swallows the error it retried on, so a dead server looks like a slow one', - '- the test pins the spelling of the call, not the behaviour', - '', - 'The failing case, reduced:', - '', - '```ts', - 'const wait = Math.min(timeout, 30_000); // clamps what the caller asked for', - 'await client.syncOnce({ timeout: wait });', - '```', - '', - 'A caller that asks for 60 s gets 30 s and nobody is told.', - ].join('\n'), - html: - '

Review of the sync change. Three things before it merges.

' + - '

The resume path is right. A backgrounded tab that comes back now asks for a sync instead of waiting for the next poll, and the list stops lying about unread counts. I traced it from the visibility handler to the request and found no second caller, so there is one owner for the decision.

' + - '

What I would change:

' + - '
  • the timeout is a constant, and it should be the value the homeserver was asked for
  • ' + - '
  • the retry swallows the error it retried on, so a dead server looks like a slow one
  • ' + - '
  • the test pins the spelling of the call, not the behaviour
' + - '

The failing case, reduced:

' + - '
const wait = Math.min(timeout, 30_000); // clamps what the caller asked for\nawait client.syncOnce({ timeout: wait });
' + - '

A caller that asks for 60 s gets 30 s and nobody is told.

', - }, - { - from: user('eureka'), - pose: 'thinking', - minutesAgo: 12, - body: [ - 'Plan for the portrait fix, in order.', - '', - '1. Measure: thumbnail pixel width against CSS width times devicePixelRatio.', - '2. Regenerate from the cast art at about three times the displayed size.', - '3. Check the result on a phone, not in a desktop window.', - '', - 'Not in scope: the wide layout, the pose catalogue, the scene.', - '', - 'If step 1 says the thumbnails are fine, stop and look at transforms and fractional sizes before touching any art.', - ].join('\n'), - html: - '

Plan for the portrait fix, in order.

' + - '
  1. Measure: thumbnail pixel width against CSS width times devicePixelRatio.
  2. ' + - '
  3. Regenerate from the cast art at about three times the displayed size.
  4. ' + - '
  5. Check the result on a phone, not in a desktop window.
' + - '

Not in scope: the wide layout, the pose catalogue, the scene.

' + - '

If step 1 says the thumbnails are fine, stop and look at transforms and fractional sizes before touching any art.

', - }, - { - from: user('nibu'), - pose: 'console', - minutesAgo: 6, - body: [ - 'Handoff notes, long form.', - '', - 'The bubble is the unit of reading. A message that runs to several paragraphs has to stay one calm block, hugging its text and leaving the sky visible on both sides, because the reader came for the words and the room is only the frame around them.', - '', - 'Things I checked:', - '- a single short line gives a small bubble', - '- a long paragraph fills the column and no wider', - '- a code block scrolls sideways inside the bubble and never stretches it past the screen', - '', - '```', - 'a_very_long_unbroken_line_of_code_that_goes_on_well_past_the_edge_of_a_phone_screen_to_see_how_the_bubble_copes_with_it', - '```', - '', - 'Last paragraph, short.', - ].join('\n'), - html: - '

Handoff notes, long form.

' + - '

The bubble is the unit of reading. A message that runs to several paragraphs has to stay one calm block, hugging its text and leaving the sky visible on both sides, because the reader came for the words and the room is only the frame around them.

' + - '

Things I checked:

' + - '
  • a single short line gives a small bubble
  • a long paragraph fills the column and no wider
  • ' + - '
  • a code block scrolls sideways inside the bubble and never stretches it past the screen
' + - '
a_very_long_unbroken_line_of_code_that_goes_on_well_past_the_edge_of_a_phone_screen_to_see_how_the_bubble_copes_with_it
' + - '

Last paragraph, short.

', - }, - ], - }, - { - id: `!huginn:${DEMO_SERVER}`, - alias: `#huginn:${DEMO_SERVER}`, - name: 'Huginn', - topic: 'Memory for the Minds.', - members: [ME, user('eureka'), user('void')], - notice: 'show', - parent: PROJECTS, - steward: { mind: 'eureka', repo: 'Huginn' }, - joined: true, - lines: [ - { - from: user('eureka'), - body: 'This room shows no training line, because its notice says show.', - pose: 'neutral', - minutesAgo: 120, - }, - ], - }, - { - id: `!dm-eureka:${DEMO_SERVER}`, - name: 'Eureka', - direct: true, - members: [ME, user('eureka')], - joined: true, - lines: [ - { - from: user('eureka'), - body: 'A direct message. I answer here too.', - pose: 'happy', - minutesAgo: 15, - }, - { - from: user('eureka'), - body: 'Ask me anything; the answers are canned.', - pose: 'thinking', - minutesAgo: 14, - }, - ], - }, - { - id: COMMONS, - alias: `#commons:${DEMO_SERVER}`, - name: 'Commons', - topic: 'Open to anyone. Join to look inside.', - members: [user('visitor'), user('void')], - parent: ROOT, - joined: false, - lines: [ - { from: user('void'), body: 'You found the unjoined room.', pose: 'glare', minutesAgo: 200 }, - ], - }, -]; - -// A Mind's canned answer to whatever the visitor types: label and line. -export const REPLIES: { pose: string; body: string }[] = [ - { pose: 'thinking', body: 'Hm. Let me think about that.' }, - { pose: 'amused', body: 'That is a fair thing to say to a pretend server.' }, - { pose: 'happy', body: 'Noted. Nothing was sent anywhere.' }, - { pose: 'surprised', body: 'I did not expect that.' }, -]; diff --git a/src/preview-demo/index.ts b/src/preview-demo/index.ts deleted file mode 100644 index f66dde43..00000000 --- a/src/preview-demo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { installDemo } from './install'; diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts deleted file mode 100644 index a7b66de9..00000000 --- a/src/preview-demo/install.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* eslint-disable no-param-reassign */ -// Patching the window's network entry points is this module's whole job. -// Routes the page's network through the pretend homeserver and refuses everything else. Installed -// only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx). -// Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any -// other origin is refused, so no real homeserver can be contacted while this is on. -import { DEMO_ORIGIN } from './fixtures'; -import { createDemoServer, DemoServer } from './server'; - -export type Route = 'own' | 'demo' | 'refuse'; - -export const routeOf = (url: string, pageOrigin: string): Route => { - let u: URL; - try { - u = new URL(url, pageOrigin); - } catch { - return 'refuse'; - } - if (u.protocol === 'data:' || u.protocol === 'blob:') return 'own'; - if (u.origin === DEMO_ORIGIN) return 'demo'; - const ws = u.protocol === 'ws:' || u.protocol === 'wss:'; - const sameHost = u.host === new URL(pageOrigin).host; - return sameHost && (u.origin === pageOrigin || ws) ? 'own' : 'refuse'; -}; - -const refusal = (what: string, url: string) => - new Error(`preview: pretend server only; ${what} to ${url} refused`); - -const BANNER_TEXT = 'preview: pretend server. Any password works.'; -const FOREIGN_SESSION_TEXT = - 'preview: pretend server. A login for another server is stored here and cannot be used.'; - -// In flow, above the app: body becomes a column and the app root takes the rest, so the banner -// never covers the room header or the connection strip. -const showBanner = (text: string) => { - const el = document.createElement('div'); - el.textContent = text; - el.setAttribute('role', 'status'); - el.style.cssText = - 'flex:none;padding:2px 8px;font:11px/1.4 system-ui,sans-serif;color:#1b1b1b;background:#ffc46b;text-align:center'; - const root = document.getElementById('root'); - document.body.style.cssText += ';display:flex;flex-direction:column'; - if (root) root.style.cssText += ';flex:1;min-height:0;height:auto'; - document.body.insertBefore(el, root); -}; - -const parseBody = (body: unknown): unknown => { - if (typeof body !== 'string') return undefined; - try { - return JSON.parse(body); - } catch { - return undefined; - } -}; - -export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; - -// Own-origin requests go to the real fetch, the demo origin to the pretend server, the rest throw. -export const guardedFetch = - (server: DemoServer, origin: string, realFetch: FetchFn): FetchFn => - async (input, init) => { - const req = input instanceof Request ? input : undefined; - const url = req ? req.url : String(input); - const route = routeOf(url, origin); - if (route === 'own') return realFetch(input, init); - if (route === 'refuse') throw new TypeError(refusal('fetch', url).message); - const method = init?.method ?? req?.method ?? 'GET'; - const raw = init?.body ?? (req ? await req.clone().text() : undefined); - const out = await server.handle({ - method, - url, - body: parseBody(raw), - signal: init?.signal ?? req?.signal ?? undefined, - }); - return new Response(JSON.stringify(out.body), { - status: out.status, - headers: { 'Content-Type': 'application/json' }, - }); - }; - -export const installDemo = (win: Window & typeof globalThis = window) => { - const server = createDemoServer(); - const { origin } = win.location; - const realFetch = win.fetch.bind(win); - - win.fetch = guardedFetch(server, origin, realFetch); - - const { WebSocket: RealSocket } = win; - class GuardedSocket extends RealSocket { - constructor(url: string | URL, protocols?: string | string[]) { - if (routeOf(String(url), origin) !== 'own') throw refusal('WebSocket', String(url)); - super(url, protocols); - } - } - win.WebSocket = GuardedSocket; - - const { open: realOpen } = win.XMLHttpRequest.prototype; - win.XMLHttpRequest.prototype.open = function open( - this: XMLHttpRequest, - method: string, - url: string | URL, - ...rest: unknown[] - ) { - if (routeOf(String(url), origin) !== 'own') throw refusal('XMLHttpRequest', String(url)); - return (realOpen as (...a: unknown[]) => void).call(this, method, url, ...rest); - } as typeof realOpen; - - const realBeacon = win.navigator.sendBeacon?.bind(win.navigator); - if (realBeacon) { - win.navigator.sendBeacon = (url: string | URL, data?: BodyInit | null) => - routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false; - } - - const stored = win.localStorage.getItem('cinny_hs_base_url'); - showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT); - return server; -}; diff --git a/src/preview-demo/seed.ts b/src/preview-demo/seed.ts deleted file mode 100644 index 29af3dcc..00000000 --- a/src/preview-demo/seed.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Imported first by src/index.tsx: the settings module reads localStorage when it is first -// imported, so a demo build's first-visit defaults must be stored before anything else loads. -// Pose portraits default off; the demo exists to show them. Does nothing without the build flag. -export const seedDemoSettings = (storage: Storage) => { - if (storage.getItem('settings') === null) { - storage.setItem('settings', JSON.stringify({ poseBubbles: true })); - } -}; - -if (import.meta.env.VITE_PREVIEW_DEMO === '1') seedDemoSettings(localStorage); diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts deleted file mode 100644 index d277b97b..00000000 --- a/src/preview-demo/server.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -/* eslint-disable max-classes-per-file, no-promise-executor-return, no-useless-constructor, no-empty-function, no-void, @typescript-eslint/no-non-null-assertion */ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk'; -import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures'; -import { guardedFetch, installDemo, routeOf } from './install'; -import { createDemoServer, DemoServer } from './server'; -import { seedDemoSettings } from './seed'; -import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses'; -import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice'; - -const PAGE = 'https://page.test'; -const never = vi.fn(async () => new Response('real network')); - -const clientFor = (server: DemoServer): MatrixClient => - createClient({ - baseUrl: DEMO_ORIGIN, - fetchFn: guardedFetch(server, PAGE, never as never) as never, - }); - -const waitFor = (read: () => T | undefined, ms = 5000): Promise => - new Promise((resolve, reject) => { - const t0 = Date.now(); - const tick = () => { - const v = read(); - if (v !== undefined) resolve(v); - else if (Date.now() - t0 > ms) reject(new Error('timed out')); - else setTimeout(tick, 20); - }; - tick(); - }); - -const live: { server: DemoServer; mx: MatrixClient }[] = []; -afterEach(() => { - live.splice(0).forEach(({ server, mx }) => { - mx.stopClient(); - server.close(); - }); - never.mockClear(); -}); - -const syncedClient = async () => { - const server = createDemoServer(); - const mx = clientFor(server); - live.push({ server, mx }); - const login = await mx.loginWithPassword('demo', 'anything'); - mx.http.opts.accessToken = login.access_token; - await mx.startClient({ initialSyncLimit: 20 }); - await waitFor(() => (mx.getSyncState() === SyncState.Syncing ? true : undefined)); - return { server, mx, login }; -}; - -describe('routeOf', () => { - it('lets own-origin requests through, answers the demo origin, refuses the rest', () => { - expect(routeOf('/config.json', PAGE)).toBe('own'); - expect(routeOf(`${PAGE}/assets/a.js`, PAGE)).toBe('own'); - expect(routeOf('data:text/plain,hi', PAGE)).toBe('own'); - expect(routeOf(`${DEMO_ORIGIN}/_matrix/client/versions`, PAGE)).toBe('demo'); - expect(routeOf('https://matrix.org/_matrix/client/versions', PAGE)).toBe('refuse'); - expect(routeOf('https://page.test.evil.example/x', PAGE)).toBe('refuse'); - expect(routeOf('https://demo.invalid.evil.example/x', PAGE)).toBe('refuse'); - expect(routeOf('http://page.test/x', PAGE)).toBe('refuse'); - expect(routeOf('wss://matrix.org/socket', PAGE)).toBe('refuse'); - expect(routeOf('wss://page.test/socket', PAGE)).toBe('own'); - }); -}); - -describe('the pretend homeserver through matrix-js-sdk', () => { - it('logs in as demo with any password', async () => { - const server = createDemoServer(); - live.push({ server, mx: clientFor(server) }); - const res = await live[0].mx.loginWithPassword('demo', 'whatever'); - expect(res.user_id).toBe(ME); - expect(res.access_token).toBeTruthy(); - }); - - it('syncs the joined rooms, space tree, direct message and members', async () => { - const { mx } = await syncedClient(); - const joined = ROOMS.filter((r) => r.joined); - expect( - mx - .getRooms() - .map((r) => r.roomId) - .sort() - ).toEqual(joined.map((r) => r.id).sort()); - expect(mx.getRoom(GENERAL)?.name).toBe('General'); - expect(mx.getRoom(COMMONS)).toBeNull(); - const direct = mx.getAccountData('m.direct')?.getContent() as Record; - expect(Object.values(direct).flat()).toContain(ROOMS.find((r) => r.direct)?.id); - expect(mx.getRoom(GENERAL)?.getJoinedMemberCount()).toBeGreaterThan(3); - }); - - it('carries pose labels that the pose catalogue resolves, for known Minds only', async () => { - const { mx } = await syncedClient(); - const events = mx - .getRoom(GENERAL)! - .getLiveTimeline() - .getEvents() - .filter((e) => e.getType() === 'm.room.message'); - const posed = events.filter((e) => e.getContent()[POSE_KEY]); - expect(posed.length).toBeGreaterThan(3); - posed.forEach((e) => { - const local = e.getSender()!.slice(1).split(':')[0]; - const mind = mindOfSender(local, undefined); - expect(mind, e.getSender()!).toBeDefined(); - expect(resolvePose(mind!, e.getContent()[POSE_KEY])).toBe(e.getContent()[POSE_KEY]); - }); - expect( - events.some((e) => mindOfSender(e.getSender()!.slice(1).split(':')[0]) === undefined) - ).toBe(true); - }); - - it('shows the training line only in the rooms whose notice says train', async () => { - const { mx } = await syncedClient(); - const lines = ROOMS.filter((r) => r.joined && !r.space && !r.direct).map((r) => { - const ev = mx.getRoom(r.id)!.currentState.getStateEvents('org.gamecult.notice', ''); - return [r.name, roomTrainingLine(parseRoomNotice(ev?.getContent())) !== undefined]; - }); - expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false }); - }); - - it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => { - const { mx } = await syncedClient(); - const room = mx.getRoom(GENERAL)!; - const before = room.getLiveTimeline().getEvents().length; - const sent = await mx.sendTextMessage(GENERAL, 'hello pretend world'); - const mine = await waitFor(() => - room - .getLiveTimeline() - .getEvents() - .find((e) => e.getId() === sent.event_id && !e.status) - ); - expect(mine.getSender()).toBe(ME); - const reply = await waitFor( - () => - room - .getLiveTimeline() - .getEvents() - .find( - (e) => - e.getSender() !== ME && - e.getContent().body?.length && - room.getLiveTimeline().getEvents().indexOf(e) >= before + 1 - ), - 6000 - ); - expect(reply.getContent()[POSE_KEY]).toBeTruthy(); - const ours = room - .getLiveTimeline() - .getEvents() - .filter((e) => e.getContent().body === 'hello pretend world'); - expect(ours).toHaveLength(1); - }); - - it('joins the unjoined public room and then syncs it', async () => { - const { mx } = await syncedClient(); - const joinedRoom = new Promise((resolve) => - mx.on(ClientEvent.Room, (r) => resolve(r.roomId)) - ); - await mx.joinRoom(COMMONS); - expect(await joinedRoom).toBe(COMMONS); - }); - - it('resolves the root space alias and serves the hierarchy with steward metadata', async () => { - const { mx } = await syncedClient(); - const alias = await mx.getRoomIdForAlias('#gamecult:demo.invalid'); - expect(alias.room_id).toBeTruthy(); - const h = await mx.getRoomHierarchy(alias.room_id); - const stewards = h.rooms - .flatMap((r) => r.children_state) - .map((c) => (c.content as Record)['org.gamecult.steward']); - expect(stewards.filter(Boolean).length).toBe(2); - }); - - it('answers a long poll with nothing new after its timeout', async () => { - const server = createDemoServer(); - live.push({ server, mx: clientFor(server) }); - const first = await server.handle({ - method: 'GET', - url: `${DEMO_ORIGIN}/_matrix/client/v3/sync`, - }); - const token = (first.body as { next_batch: string }).next_batch; - const t0 = Date.now(); - const idle = await server.handle({ - method: 'GET', - url: `${DEMO_ORIGIN}/_matrix/client/v3/sync?since=${token}&timeout=150`, - }); - expect(Date.now() - t0).toBeGreaterThanOrEqual(140); - expect((idle.body as { rooms: { join: object } }).rooms.join).toEqual({}); - }); - - it('says so, rather than inventing an answer, for an endpoint it does not implement', async () => { - const server = createDemoServer(); - live.push({ server, mx: clientFor(server) }); - const res = await server.handle({ - method: 'GET', - url: `${DEMO_ORIGIN}/_matrix/client/v3/something/new`, - }); - expect(res.status).toBe(404); - expect((res.body as { errcode: string }).errcode).toBe('M_UNRECOGNIZED'); - }); -}); - -describe('the guard', () => { - it('refuses a real homeserver and never reaches the real fetch for it', async () => { - const server = createDemoServer(); - live.push({ server, mx: clientFor(server) }); - const f = guardedFetch(server, PAGE, never as never); - await expect(f('https://matrix.org/_matrix/client/versions')).rejects.toThrow( - /pretend server only/ - ); - await expect(f(new Request('https://converser.eu/_matrix/client/v3/login'))).rejects.toThrow( - /refused/ - ); - expect(never).not.toHaveBeenCalled(); - await f(`${PAGE}/config.json`); - expect(never).toHaveBeenCalledTimes(1); - const versions = await f(`${DEMO_ORIGIN}/_matrix/client/versions`); - expect((await versions.json()).versions.length).toBeGreaterThan(0); - }); - - it('installDemo refuses WebSocket, XMLHttpRequest and beacons to foreign origins', () => { - const store = new Map(); - const open = vi.fn(); - class Sock { - constructor(public url: string) {} - } - const win = { - location: { origin: PAGE }, - fetch: never, - WebSocket: Sock, - XMLHttpRequest: { prototype: { open } }, - navigator: { sendBeacon: vi.fn(() => true) }, - localStorage: { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, v), - }, - } as unknown as Window & typeof globalThis; - vi.stubGlobal('document', { - createElement: () => ({ style: {}, setAttribute: vi.fn() }), - getElementById: () => null, - body: { style: {}, insertBefore: vi.fn() }, - addEventListener: vi.fn(), - }); - const server = installDemo(win); - live.push({ server, mx: clientFor(server) }); - expect(() => new win.WebSocket('wss://matrix.org/x')).toThrow(/refused/); - expect(() => new win.WebSocket(`wss://page.test/x`)).not.toThrow(); - expect(() => win.XMLHttpRequest.prototype.open('GET', 'https://matrix.org/x')).toThrow( - /refused/ - ); - win.XMLHttpRequest.prototype.open('GET', '/same-origin'); - expect(open).toHaveBeenCalledTimes(1); - expect(win.navigator.sendBeacon('https://matrix.org/x')).toBe(false); - vi.unstubAllGlobals(); - }); - - it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => { - const fresh = new Map(); - seedDemoSettings({ - getItem: (k: string) => fresh.get(k) ?? null, - setItem: (k: string, v: string) => void fresh.set(k, v), - } as Storage); - expect(JSON.parse(fresh.get('settings')!)).toEqual({ poseBubbles: true }); - const chosen = new Map([['settings', '{"poseBubbles":false}']]); - seedDemoSettings({ - getItem: (k: string) => chosen.get(k) ?? null, - setItem: (k: string, v: string) => void chosen.set(k, v), - } as Storage); - expect(chosen.get('settings')).toBe('{"poseBubbles":false}'); - }); -}); diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts deleted file mode 100644 index 33a51b16..00000000 --- a/src/preview-demo/server.ts +++ /dev/null @@ -1,426 +0,0 @@ -// The preview-only pretend homeserver: a pure request handler over in-memory fixtures. It speaks -// just enough of the Matrix client-server API for matrix-js-sdk to log in, sync, show rooms, -// send messages and get a Mind's canned answer. It touches no network and no storage; the -// fetch guard that routes requests here lives in install.ts. -import { - COMMONS, - DEMO_DEVICE, - DEMO_ORIGIN, - DEMO_SERVER, - DEMO_TOKEN, - DemoEvent, - DemoRoom, - ME, - MIND_IDS, - REPLIES, - ROOMS, - USERS, -} from './fixtures'; - -export type DemoResponse = { status: number; body: unknown }; -export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal }; - -const POSE_KEY = 'org.gamecult.pose'; -const NOTICE_TYPE = 'org.gamecult.notice'; -const MAX_LONG_POLL_MS = 15000; - -const ok = (body: unknown = {}): DemoResponse => ({ status: 200, body }); -const err = (status: number, errcode: string, error: string): DemoResponse => ({ - status, - body: { errcode, error }, -}); - -type Stamped = { version: number; roomId: string; event: DemoEvent }; - -export type DemoServer = { - handle: (req: DemoRequest) => Promise; - /** Stops the pending Mind replies and long polls. */ - close: () => void; -}; - -export const createDemoServer = (now: () => number = Date.now): DemoServer => { - let version = 1; - let eventCounter = 0; - const joined = new Set(ROOMS.filter((r) => r.joined).map((r) => r.id)); - const timeline: Stamped[] = []; - const stateOf = new Map(); - const accountData = new Map(); - const waiters = new Set<() => void>(); - const timers = new Set>(); - let replyCounter = 0; - let accountDataVersion = 0; - let syncedJoined = new Set(joined); - let polled = false; - let closed = false; - - const mk = ( - type: string, - sender: string, - content: Record, - ts: number, - stateKey?: string - ): DemoEvent => { - eventCounter += 1; - return { - type, - sender, - content, - origin_server_ts: ts, - event_id: `$demo${eventCounter}`, - ...(stateKey === undefined ? {} : { state_key: stateKey }), - }; - }; - const nameOf = (id: string) => USERS.find((u) => u.id === id)?.name ?? id; - - const buildState = (room: DemoRoom): DemoEvent[] => { - const t0 = now() - 1000 * 60 * 60 * 24; - const creator = room.members[0] ?? ME; - const events: DemoEvent[] = [ - mk( - 'm.room.create', - creator, - room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' }, - t0, - '' - ), - mk('m.room.name', creator, { name: room.name }, t0, ''), - mk( - 'm.room.join_rules', - creator, - { join_rule: room.id === COMMONS ? 'public' : 'invite' }, - t0, - '' - ), - mk('m.room.history_visibility', creator, { history_visibility: 'shared' }, t0, ''), - mk( - 'm.room.power_levels', - creator, - { users: { [ME]: 100 }, users_default: 0, events_default: 0, state_default: 50 }, - t0, - '' - ), - ...room.members.map((m) => - mk('m.room.member', m, { membership: 'join', displayname: nameOf(m) }, t0, m) - ), - ]; - if (room.topic) events.push(mk('m.room.topic', creator, { topic: room.topic }, t0, '')); - if (room.alias) - events.push(mk('m.room.canonical_alias', creator, { alias: room.alias }, t0, '')); - if (room.notice) - events.push(mk(NOTICE_TYPE, creator, { version: 1, scope: room.notice }, t0, '')); - if (room.parent) { - events.push( - mk('m.space.parent', creator, { via: [DEMO_SERVER], canonical: true }, t0, room.parent) - ); - } - ROOMS.filter((c) => c.parent === room.id).forEach((c) => { - events.push( - mk( - 'm.space.child', - creator, - { - via: [DEMO_SERVER], - ...(c.steward - ? { - 'org.gamecult.steward': { ...c.steward, source: 'demo', steward: c.steward.mind }, - } - : {}), - }, - t0, - c.id - ) - ); - }); - return events; - }; - - ROOMS.forEach((room) => { - stateOf.set(room.id, buildState(room)); - room.lines.forEach((l) => { - timeline.push({ - version: 1, - roomId: room.id, - event: mk( - 'm.room.message', - l.from, - { - msgtype: 'm.text', - body: l.body, - ...(l.html ? { format: 'org.matrix.custom.html', formatted_body: l.html } : {}), - ...(l.pose ? { [POSE_KEY]: l.pose } : {}), - }, - now() - l.minutesAgo * 60 * 1000 - ), - }); - }); - }); - timeline.sort((a, b) => a.event.origin_server_ts - b.event.origin_server_ts); - accountData.set('m.direct', { - [`@eureka:${DEMO_SERVER}`]: ROOMS.filter((r) => r.direct).map((r) => r.id), - }); - - const notify = () => { - waiters.forEach((w) => w()); - waiters.clear(); - }; - const push = (roomId: string, event: DemoEvent) => { - version += 1; - timeline.push({ version, roomId, event }); - notify(); - }; - const later = (ms: number, fn: () => void) => { - const t = setTimeout(() => { - timers.delete(t); - if (!closed) fn(); - }, ms); - timers.add(t); - }; - - // A Mind in the room answers whatever the visitor sent, with a pose from a short cycle. - const scheduleReply = (room: DemoRoom) => { - const mind = room.members.find((m) => MIND_IDS.includes(m)); - if (!mind) return; - const reply = REPLIES[replyCounter % REPLIES.length]; - replyCounter += 1; - later(1200, () => - push( - room.id, - mk( - 'm.room.message', - mind, - { msgtype: 'm.text', body: reply.body, [POSE_KEY]: reply.pose }, - now() - ) - ) - ); - }; - - const roomJson = (room: DemoRoom, since: number) => { - const full = since === 0; - const events = timeline - .filter((t) => t.roomId === room.id && (full || t.version > since)) - .map((t) => t.event); - return { - state: { events: full ? stateOf.get(room.id) : [] }, - timeline: { events, limited: false, prev_batch: 'p0' }, - ephemeral: { events: [] }, - account_data: { events: [] }, - unread_notifications: { highlight_count: 0, notification_count: 0 }, - summary: { - 'm.joined_member_count': room.members.length, - 'm.invited_member_count': 0, - 'm.heroes': [], - }, - }; - }; - - const syncBody = (since: number, joinedBefore: Set) => { - const join: Record = {}; - ROOMS.filter((r) => joined.has(r.id)).forEach((r) => { - const newlyJoined = !joinedBefore.has(r.id); - const j = roomJson(r, newlyJoined ? 0 : since); - if (since === 0 || newlyJoined || (j.timeline.events as unknown[]).length) join[r.id] = j; - }); - const ad: { type: string; content: unknown }[] = []; - if (since === 0 || accountDataVersion > since) { - accountData.forEach((content, type) => ad.push({ type, content })); - } - return { - next_batch: `d${version}`, - rooms: { join, invite: {}, leave: {} }, - account_data: { events: ad }, - presence: { events: [] }, - to_device: { events: [] }, - device_one_time_keys_count: { signed_curve25519: 50 }, - }; - }; - - const sync = async ( - since: number, - timeoutMs: number, - signal?: AbortSignal - ): Promise => { - if (since === 0) { - syncedJoined = new Set(joined); - return ok(syncBody(0, new Set())); - } - const changed = () => version > since || accountDataVersion > since; - if (!changed()) { - await new Promise((resolve) => { - const done = () => { - waiters.delete(done); - resolve(); - }; - waiters.add(done); - // The first poll answers quickly so the client leaves its Connecting state. - later(polled ? Math.min(timeoutMs, MAX_LONG_POLL_MS) : 200, done); - polled = true; - signal?.addEventListener('abort', done); - }); - } - const before = syncedJoined; - syncedJoined = new Set(joined); - return ok(syncBody(since, before)); - }; - - const roomOf = (id: string) => ROOMS.find((r) => r.id === id); - const hierarchy = () => { - const entry = (r: DemoRoom) => ({ - room_id: r.id, - name: r.name, - topic: r.topic, - canonical_alias: r.alias, - num_joined_members: r.members.length, - world_readable: false, - guest_can_join: false, - join_rule: r.id === COMMONS ? 'public' : 'invite', - ...(r.space ? { room_type: 'm.space' } : {}), - children_state: (stateOf.get(r.id) ?? []).filter((e) => e.type === 'm.space.child'), - }); - return ROOMS.filter((r) => !r.direct).map(entry); - }; - - const handle = async (req: DemoRequest): Promise => { - const u = new URL(req.url, DEMO_ORIGIN); - const p = decodeURIComponent(u.pathname); - const m = req.method.toUpperCase(); - const body = (req.body ?? {}) as Record; - let g: RegExpMatchArray | null; - - if (p === '/.well-known/matrix/client') - return ok({ 'm.homeserver': { base_url: DEMO_ORIGIN } }); - if (!p.startsWith('/_matrix/')) return err(404, 'M_NOT_FOUND', 'Not found'); - const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, ''); - if (p.endsWith('/versions')) - return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); - if (c === '/login') { - if (m === 'GET') return ok({ flows: [{ type: 'm.login.password' }] }); - return ok({ - user_id: ME, - access_token: DEMO_TOKEN, - device_id: DEMO_DEVICE, - home_server: DEMO_SERVER, - }); - } - if (c === '/register') - return err(403, 'M_FORBIDDEN', 'Registration is closed on the pretend server.'); - if (c === '/account/whoami') return ok({ user_id: ME, device_id: DEMO_DEVICE }); - if (c === '/capabilities') return ok({ capabilities: {} }); - g = c.match(/^\/profile\/(.+)$/); - if (g) return ok({ displayname: nameOf(g[1]) }); - if (c.endsWith('/filter') && m === 'POST') return ok({ filter_id: '1' }); - if (c.includes('/filter/')) return ok({}); - if (c === '/sync') { - const raw = u.searchParams.get('since'); - const since = raw ? Number(raw.replace(/^d/, '')) || 0 : 0; - return sync(since, Number(u.searchParams.get('timeout') ?? 0), req.signal); - } - if (c === '/pushrules/' || c === '/pushrules') { - return ok({ global: { override: [], underride: [], room: [], sender: [], content: [] } }); - } - g = c.match(/^\/user\/[^/]+\/account_data\/(.+)$/); - if (g) { - if (m === 'PUT') { - accountData.set(g[1], req.body ?? {}); - version += 1; - accountDataVersion = version; - notify(); - return ok({}); - } - return accountData.has(g[1]) - ? ok(accountData.get(g[1])) - : err(404, 'M_NOT_FOUND', 'No account data'); - } - g = c.match(/^\/directory\/room\/(.+)$/); - if (g) { - const room = ROOMS.find((r) => r.alias === g![1]); - return room - ? ok({ room_id: room.id, servers: [DEMO_SERVER] }) - : err(404, 'M_NOT_FOUND', 'Room alias not found'); - } - if (c === '/joined_rooms') return ok({ joined_rooms: [...joined] }); - g = c.match(/^\/rooms\/([^/]+)\/hierarchy$/); - if (g) return ok({ rooms: hierarchy() }); - g = c.match(/^\/(?:rooms\/([^/]+)\/join|join\/([^/]+))$/); - if (g && m === 'POST') { - const target = g[1] ?? g[2]; - const room = roomOf(target) ?? ROOMS.find((r) => r.alias === target); - if (!room) return err(404, 'M_NOT_FOUND', 'No such room'); - if (!joined.has(room.id)) { - joined.add(room.id); - if (!room.members.includes(ME)) room.members.push(ME); - stateOf.set(room.id, buildState(room)); - version += 1; - notify(); - } - return ok({ room_id: room.id }); - } - g = c.match( - /^\/rooms\/([^/]+)\/(?:leave|forget|read_markers|typing\/[^/]+|receipt\/.+|report\/.+)$/ - ); - if (g) return ok({}); - g = c.match(/^\/rooms\/([^/]+)\/send\/([^/]+)\/([^/]+)$/); - if (g && m === 'PUT') { - const room = roomOf(g[1]); - if (!room || !joined.has(room.id)) return err(403, 'M_FORBIDDEN', 'Not in room'); - const event = mk(g[2], ME, body, now()); - event.unsigned = { transaction_id: g[3] }; - push(room.id, event); - if (g[2] === 'm.room.message') scheduleReply(room); - return ok({ event_id: event.event_id }); - } - g = c.match(/^\/rooms\/([^/]+)\/state\/([^/]+)(?:\/(.*))?$/); - if (g) { - const room = roomOf(g[1]); - if (!room) return err(404, 'M_NOT_FOUND', 'No such room'); - const key = g[3] ?? ''; - if (m === 'PUT') { - const event = mk(g[2], ME, body, now(), key); - stateOf.set(room.id, [ - ...(stateOf.get(room.id) ?? []).filter((e) => !(e.type === g![2] && e.state_key === key)), - event, - ]); - push(room.id, event); - return ok({ event_id: event.event_id }); - } - const found = (stateOf.get(room.id) ?? []).find( - (e) => e.type === g![2] && e.state_key === key - ); - return found ? ok(found.content) : err(404, 'M_NOT_FOUND', 'No such state event'); - } - g = c.match(/^\/rooms\/([^/]+)\/state$/); - if (g) return ok(stateOf.get(g[1]) ?? []); - g = c.match(/^\/rooms\/([^/]+)\/members$/); - if (g) { - return ok({ chunk: (stateOf.get(g[1]) ?? []).filter((e) => e.type === 'm.room.member') }); - } - g = c.match(/^\/rooms\/([^/]+)\/messages$/); - if (g) return ok({ chunk: [], start: 'p0', end: 'p0' }); - g = c.match(/^\/rooms\/([^/]+)\/(?:summary|context\/.+)$/); - if (g) return err(404, 'M_NOT_FOUND', 'Not available on the pretend server'); - if (c === '/keys/upload') return ok({ one_time_key_counts: { signed_curve25519: 50 } }); - if (c === '/keys/query') return ok({ device_keys: {}, failures: {} }); - if (c === '/keys/claim') return ok({ one_time_keys: {}, failures: {} }); - if (c === '/keys/changes') return ok({ changed: [], left: [] }); - if (c.startsWith('/keys/')) return ok({}); - if (c.startsWith('/sendToDevice/')) return ok({}); - if (c.startsWith('/room_keys/')) return err(404, 'M_NOT_FOUND', 'No backup'); - if (c === '/devices') - return ok({ devices: [{ device_id: DEMO_DEVICE, display_name: 'Preview' }] }); - if (c === '/thirdparty/protocols' || c === '/voip/turnServer') return ok({}); - if (c.startsWith('/presence/')) return ok({}); - if (c === '/user_directory/search') return ok({ results: [], limited: false }); - if (p.endsWith('/media/config') || p.endsWith('/config')) return ok({ 'm.upload.size': 0 }); - return err(404, 'M_UNRECOGNIZED', `The pretend server does not implement ${m} ${p}`); - }; - - return { - handle, - close: () => { - closed = true; - timers.forEach(clearTimeout); - timers.clear(); - notify(); - }, - }; -}; diff --git a/src/sky.css.ts b/src/sky.css.ts deleted file mode 100644 index 341d0eb7..00000000 --- a/src/sky.css.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { globalStyle } from '@vanilla-extract/css'; -import { baked } from './sky/baked'; - -// Fallback background and the colours folds has no token for. Everything is read from -// the bake (albedo x irradiance via sun.js); nothing here chooses a colour. -const skies = [ - { scope: 'body.midnight-sky', sky: baked.midnight, scheme: 'dark' }, - { scope: 'body.noonday-sky', sky: baked.noonday, scheme: 'light' }, -] as const; - -skies.forEach(({ scope, sky, scheme }) => { - const stops = sky.gradient.map( - (c, i) => `${c} ${Math.round((i / (sky.gradient.length - 1)) * 100)}%` - ); - globalStyle(scope, { - colorScheme: scheme, - backgroundColor: sky.gradient[0], - backgroundImage: `linear-gradient(180deg, ${stops.join(', ')})`, - backgroundAttachment: 'fixed', - vars: { - '--tc-link': sky.link, - ...Object.fromEntries(sky.userColors.map((c, i) => [`--mx-uc-${i + 1}`, c])), - '--sky-bubble': sky.bubbles.Other, - '--sky-bubble-text': sky.bubbles.OtherText, - '--sky-bubble-own': sky.bubbles.Own, - '--sky-bubble-own-text': sky.bubbles.OwnText, - }, - }); -}); diff --git a/src/sky/README.md b/src/sky/README.md deleted file mode 100644 index 1f816a15..00000000 --- a/src/sky/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Baked sky - -`npm run bake:sky` (`scripts/bake-sky.mjs`) lights two fixed moments with `sun.js`, the single source of -the sun and the air, and writes `baked.ts`. `baked.midnight`, `baked.noonday` and `baked.scatter` are its -three keys. Everything here is linear RGB unless a name says `hex`. - -## Per sky: `baked.midnight`, `baked.noonday` - -| field | meaning | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `moment` | `{ time, lat, lon }` fed to `sun.js` (equinox, 45N, prime meridian; 00:00 and 12:00 UTC) | -| `elevation` | sun elevation in degrees at that moment: -44.6 midnight, +45.2 noonday | -| `sun.elevation_deg` | the same, for scene code; fade stars by this (and see below for twilight) | -| `sun.azimuth_deg` | degrees clockwise from north, 0..360 | -| `sun.color_linear` | direct sun colour at normal incidence, clear zenith ~ 1; multiply by `max(0, dot(n, toSun))` | -| `ambient_linear` | sky ambient on an upward surface, with a night floor of `AMBIENT_FLOOR` (a moonlit skyglow, not black); daylight is `sun.js`'s own `diffuse` | -| `horizon_hex` | colour of the last gradient stop, the horizon | -| `gradient` | 7 sRGB hex stops, zenith to horizon | -| `tokens`, `bubbles`, ... | theme colours (see `skyTokens.js`) | - -The two skies are single moments. To fade stars through twilight, take the elevation from `sun.js` at the -viewer's real time (`sunPosition(time, lat, lon).elevation`) rather than from `baked..sun`, which only -ever says -44.6 or +45.2. The scattering table below goes dark at -12 degrees and below (the planet's shadow -covers every sunlit air path), so a star fade between about -6 and -12 degrees follows it. - -## `baked.scatter`: atmospheric scattering for a WebGL2 shader - -A Hillaire-style (2020) single-scattering atmosphere, reduced to two small tables. The air is `sun.js`'s: -`TAU` (its zenith optical depth per channel) is the vertical optical depth here, split into a grey aerosol -part (0.02) and a Rayleigh part, so the transmittance table equals `exp(-TAU)` at the zenith. Planet radius -6360 km, atmosphere 100 km, scale heights 8 km (Rayleigh) and 1.2 km (Mie, g = 0.8, albedo 0.9), observer -100 m above the ground. Single scattering only: no ground bounce, no multiple scattering, no sun disc -(the consumer draws it from `sun.color_linear`). That makes dusk a little darker than the real thing. - -Constants: `planet_radius_km`, `atmosphere_height_km`, `observer_altitude_km`, `extinction_per_km` (RGB at the -observer, for aerial perspective: `exp(-k * distance_km)`). - -### Encoding (both tables) - -IEEE 754 half floats (16 bit), little-endian, base64 of the raw bytes, no header. The stored value is -`value * store_scale` (`store_scale` = 64; it lifts the dark end of twilight clear of the half-float -subnormals). Exact decode: for a 16-bit word `h`, `e = (h >> 10) & 31`, `m = h & 1023`, -`half = e == 0 ? m * 2^-24 : (1 + m / 1024) * 2^(e - 15)` (no infinities or NaNs are stored), and -`value = half / store_scale`. In WebGL2 upload as `RGB16F` / `HALF_FLOAT` from a `Uint16Array` over the -decoded bytes, and the GPU does the decode: sample, then divide by `store_scale`. Precision is 11 bits -(about 0.05%): the horizon glow does not step. - -### `transmittance`: `width` 32, `height` 1, 3 channels - -Transmittance from the observer to space along a view elevation, per channel. Texel `i` is the elevation -`-4 + 94 * (i / 31)^2` degrees (`viewElevation(i)` in `scatter.js`; the inverse is `viewIndex(deg)`): -finest at the horizon. Elevations that look at the ground (-4 to about -0.3) are 0. Sample it for the sun's -direct colour: at the sun's elevation, `sun.color_linear`-like attenuation, redder as it sets. - -### `sky_view`: `width` 7, `height` 32, `depth` 12, 3 channels - -Sky radiance per steradian for a sun of unit irradiance (multiply by your own sun colour and exposure). -One 3D texture, `x` fastest: - -- `x`: azimuth of the view away from the sun, 0, 30, ... 180 degrees (`azimuth_step_deg`). The sky is - symmetric about the sun's vertical plane, so use `abs` of the relative azimuth. -- `y`: view elevation, same axis as the transmittance table (`viewElevation(i)`). -- `z`: sun elevation, `sun_elevations_deg` = -18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90. Between them - interpolate linearly; `sunIndex(deg)` in `scatter.js` gives the fractional index. - -Element `((z * 32 + y) * 7 + x) * 3 + c` of the decoded array. Texel centres, for linear filtering: - -```glsl -uniform highp sampler3D uSky; // RGB16F, 7 x 32 x 12, LINEAR, CLAMP_TO_EDGE -uniform float uSunIndex; // sunIndex(sunElevationDeg), 0..11, from the CPU -vec3 skyRadiance(float viewElDeg, float azFromSunDeg) { - float y = sqrt(clamp((viewElDeg + 4.0) / 94.0, 0.0, 1.0)) * 31.0; - float x = clamp(abs(azFromSunDeg) / 180.0, 0.0, 1.0) * 6.0; - return texture(uSky, vec3((x + 0.5) / 7.0, (y + 0.5) / 32.0, (uSunIndex + 0.5) / 12.0)).rgb / 64.0; -} -``` - -Row width is 21 bytes, so set `gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)` before `texImage3D`. - -Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmittance`; about -twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the -layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, -black below the planet's shadow limit). - -### `aerial_perspective`: the camera volume for ground seen through the air - -After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020, section 5.4, and its -reference code (sebh/UnrealEngineSkyAtmosphere, `RenderCameraVolumePS` in `RenderSkyRayMarching.hlsl`). RGBA half floats -(same encoding, `store_scale` 64), `width` 7, `height` 96, `depth` 12, 4 channels; one 3D texture, `x` fastest: - -- `x`: azimuth of the view ray from the light, 0, 30, ... 180 degrees (as `sky_view`). -- `y`: `slice * 8 + elevation`: 12 slices, each a block of 8 view elevations, `-50 * (1 - i / 7)^2` degrees (finest at 0, - rays at or above the horizon are clamped to 0). Keep the filter inside a block: sample at `(slice * 8 + e + 0.5) / 96`, `e` in 0..7. -- `z`: light elevation, `sun_elevations_deg`, as `sky_view`. The same volume serves the moon: look up at the moon's elevation. -- Slice `k` is the far side of `max_km * ((k + 1) / 12)^2` km (the paper's squared distribution; `max_km` 20). Between slices interpolate - linearly; below slice 0 fade to zero at the observer, as the reference does for `Slice < 0.5`. -- RGB: in-scatter luminance per steradian for a light of unit irradiance. A: opacity `1 - mean(rgb transmittance)`, the paper's grey - transmittance. Apply as `surface * (1 - A) + L * exposure`. - -Follows the paper: the squared slice distribution, the in-scatter integral with each step's extinction integrated analytically -(`S (1 - exp(-e dt)) / e`), single scattering with Rayleigh and Mie (g 0.8) phase, and the grey opacity in alpha. -Shortcuts: sun-relative azimuth and elevation instead of screen position; 12 x 8 x 7 texels, not 32 x 32 x 32; path lengths are the -scene's metres times `distance_scale` (8), because a real atmosphere is nearly clear across a 3 km patch; the ray ignores the ground -(density held at its ground value, sun visibility tested from the ground sphere); no multi-scattering LUT (the paper's dusk fix); -observer at 100 m. Units: distances in km, radiance per unit sun irradiance, as `sky_view`. 86 KB as base64. diff --git a/src/sky/baked.ts b/src/sky/baked.ts deleted file mode 100644 index 73c93bd5..00000000 --- a/src/sky/baked.ts +++ /dev/null @@ -1,276 +0,0 @@ -// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit. -/* eslint-disable */ -export const baked = { - midnight: { - moment: { - time: '2026-09-22T00:00:00Z', - lat: 45, - lon: 0, - }, - elevation: -44.579021600011224, - sun: { - elevation_deg: -44.579021600011224, - azimuth_deg: 2.4988773054640205, - color_linear: [0, 0, 0], - }, - ambient_linear: [0.018, 0.024, 0.04], - horizon_hex: '#484e64', - gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], - tokens: { - Background: { - Container: '#0b0a0f', - ContainerHover: '#0c0c11', - ContainerActive: '#0e0e13', - ContainerLine: '#111016', - OnContainer: '#aeccff', - }, - Surface: { - Container: '#111117', - ContainerHover: '#14131a', - ContainerActive: '#16151c', - ContainerLine: '#191920', - OnContainer: '#aeccff', - }, - SurfaceVariant: { - Container: '#191820', - ContainerHover: '#1c1b23', - ContainerActive: '#1e1d26', - ContainerLine: '#22222b', - OnContainer: '#aeccff', - }, - Primary: { - Main: '#81afc7', - MainHover: '#8cbdd6', - MainActive: '#95c9e4', - MainLine: '#a5defc', - OnMain: '#272938', - Container: '#1c2f2a', - ContainerHover: '#1f342e', - ContainerActive: '#223832', - ContainerLine: '#263f38', - OnContainer: '#aeccff', - }, - Secondary: { - Main: '#9bb6ff', - MainHover: '#a7c4ff', - MainActive: '#b2d1ff', - MainLine: '#c5e6ff', - OnMain: '#272938', - Container: '#2a334c', - ContainerHover: '#2e3852', - ContainerActive: '#323c58', - ContainerLine: '#384362', - OnContainer: '#aeccff', - }, - Success: { - Main: '#81afc7', - MainHover: '#8cbdd6', - MainActive: '#95c9e4', - MainLine: '#a5defc', - OnMain: '#272938', - Container: '#1c2f2a', - ContainerHover: '#1f342e', - ContainerActive: '#223832', - ContainerLine: '#263f38', - OnContainer: '#aeccff', - }, - Warning: { - Main: '#8b98b1', - MainHover: '#96a4be', - MainActive: '#a0aecb', - MainLine: '#b0c1e0', - OnMain: '#272938', - Container: '#212217', - ContainerHover: '#25261a', - ContainerActive: '#28291c', - ContainerLine: '#2d2e20', - OnContainer: '#aeccff', - }, - Critical: { - Main: '#918ac0', - MainHover: '#9c95cf', - MainActive: '#a79fdc', - MainLine: '#b9aff3', - OnMain: '#272938', - Container: '#1f111a', - ContainerHover: '#22141c', - ContainerActive: '#25161f', - ContainerLine: '#2a1923', - OnContainer: '#aeccff', - }, - }, - bubbles: { - Other: '#0a090e', - OtherText: '#aeccff', - Own: '#0d261d', - OwnText: '#aeccff', - }, - userColors: [ - '#4683ff', - '#986fff', - '#3893b1', - '#b26adc', - '#9f8365', - '#358dde', - '#677dff', - '#6a926a', - ], - link: '#8ad1bd', - }, - noonday: { - moment: { - time: '2026-09-22T12:00:00Z', - lat: 45, - lon: 0, - }, - elevation: 45.16994813502928, - sun: { - elevation_deg: 45.16994813502928, - azimuth_deg: 182.58737070473103, - color_linear: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], - }, - ambient_linear: [0.07, 0.132, 0.27], - horizon_hex: '#e2f4ff', - gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], - tokens: { - Background: { - Container: '#bcb9b2', - ContainerHover: '#b6b3ac', - ContainerActive: '#b0ada6', - ContainerLine: '#a6a39d', - OnContainer: '#342d25', - }, - Surface: { - Container: '#d7d4cb', - ContainerHover: '#d0cdc5', - ContainerActive: '#c9c6be', - ContainerLine: '#bebbb4', - OnContainer: '#342d25', - }, - SurfaceVariant: { - Container: '#cac7bf', - ContainerHover: '#c4c0b9', - ContainerActive: '#bdbab2', - ContainerLine: '#b3b0a9', - OnContainer: '#342d25', - }, - Primary: { - Main: '#335d2f', - MainHover: '#315a2d', - MainActive: '#2f572b', - MainLine: '#2c5229', - OnMain: '#ebe7de', - Container: '#b9bbaf', - ContainerHover: '#b3b5a9', - ContainerActive: '#adafa3', - ContainerLine: '#a4a59a', - OnContainer: '#342d25', - }, - Secondary: { - Main: '#392d23', - MainHover: '#372b22', - MainActive: '#352a20', - MainLine: '#31271e', - OnMain: '#ebe7de', - Container: '#bab6ae', - ContainerHover: '#b4b0a8', - ContainerActive: '#adaaa2', - ContainerLine: '#a4a09a', - OnContainer: '#342d25', - }, - Success: { - Main: '#335d2f', - MainHover: '#315a2d', - MainActive: '#2f572b', - MainLine: '#2c5229', - OnMain: '#ebe7de', - Container: '#b9bbaf', - ContainerHover: '#b3b5a9', - ContainerActive: '#adafa3', - ContainerLine: '#a4a59a', - OnContainer: '#342d25', - }, - Warning: { - Main: '#776530', - MainHover: '#73622e', - MainActive: '#6f5e2c', - MainLine: '#695929', - OnMain: '#ebe7de', - Container: '#c4bdaf', - ContainerHover: '#beb7a9', - ContainerActive: '#b7b1a4', - ContainerLine: '#ada79b', - OnContainer: '#342d25', - }, - Critical: { - Main: '#7a4238', - MainHover: '#764036', - MainActive: '#723d34', - MainLine: '#6c3a31', - OnMain: '#ebe7de', - Container: '#c3b7af', - ContainerHover: '#bdb2aa', - ContainerActive: '#b6aba4', - ContainerLine: '#aca29b', - OnContainer: '#342d25', - }, - }, - bubbles: { - Other: '#b9b6af', - OtherText: '#342d25', - Own: '#acb99a', - OwnText: '#342d25', - }, - userColors: [ - '#2b4675', - '#5d366d', - '#214e3e', - '#6d334a', - '#5f401d', - '#204c52', - '#41426c', - '#3f4a20', - ], - link: '#2a4f27', - }, - scatter: { - planet_radius_km: 6360, - atmosphere_height_km: 100, - observer_altitude_km: 0.1, - extinction_per_km: [0.020271980981493083, 0.02767884208799963, 0.044961518003181586], - format: 'half-float', - store_scale: 64, - view_elevation_deg: { - min: -4, - max: 90, - samples: 32, - }, - transmittance: { - width: 32, - height: 1, - channels: 3, - data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArkjBP0wqR02AR145qk9vS4BBtlCITWBGW1EcT3dJ1FE1UN5LL1K9UBdNdVIrUSdOq1KFURlP1lLOUe5P+VILUlRQFlM+UqVQLVNoUupQQFOLUidRUFOpUltRXlPCUohRaVPYUq5Rc1PqUs9Re1P4UupRgVMFUwFShlMPUxRSilMWUyJSjVMcUyxSj1MfUzNSj1MgUzVS', - }, - sky_view: { - sun_elevations_deg: [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90], - azimuth_step_deg: 30, - width: 7, - height: 32, - depth: 12, - channels: 3, - data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', - }, - aerial_perspective: { - slices: 12, - elevations: 8, - elevation_min_deg: -50, - max_km: 20, - distance_scale: 8, - width: 7, - height: 96, - depth: 12, - channels: 4, - data: 'AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADIRgAAAAAAAMhGAAAAAAAAyEYAAAAAAADIRgAAAAAAAMhGAAAAAAAAyEYAAAAAAADIRgAAAAAAAMdGAAAAAAAAx0YAAAAAAADHRgAAAAAAAMdGAAAAAAAAx0YAAAAAAADHRgAAAAAAAMdGAAAAAAAAxEYAAAAAAADERgAAAAAAAMRGAAAAAAAAxEYAAAAAAADERgAAAAAAAMRGAAAAAAAAxEYAAAAAAAC7RgAAAAAAALtGAAAAAAAAu0YAAAAAAAC7RgAAAAAAALtGAAAAAAAAu0YAAAAAAAC7RgAAAAAAAJRGAAAAAAAAlEYAAAAAAACURgAAAAAAAJRGAAAAAAAAlEYAAAAAAACURgAAAAAAAJRGAAAAAAAAfUYAAAAAAAB9RgAAAAAAAH1GAAAAAAAAfUYAAAAAAAB9RgAAAAAAAH1GAAAAAAAAfUYAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADCSAAAAAAAAMJIAAAAAAAAwkgAAAAAAADBSAAAAAAAAMFIAAAAAAAAwUgAAAAAAADBSAAAAAAAAMFIAAAAAAAAwUgAAAAAAADBSAAAAAAAAMBIAAAAAAAAwEgAAAAAAADASAAAAAAAAMBIAAAAAAAAwEgAAAAAAADASAAAAAAAAMBIAAAAAAAAvEgAAAAAAAC8SAAAAAAAALxIAAAAAAAAvEgAAAAAAAC8SAAAAAAAALxIAAAAAAAAvEgAAAAAAAClSAAAAAAAAKVIAAAAAAAApUgAAAAAAAClSAAAAAAAAKVIAAAAAAAApUgAAAAAAAClSAAAAAAAAI5IAAAAAAAAjkgAAAAAAACOSAAAAAAAAI5IAAAAAAAAjkgAAAAAAACOSAAAAAAAAI5IAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAASEoAAAAAAABISgAAAAAAAEhKAAAAAAAAR0oAAAAAAABHSgAAAAAAAEdKAAAAAAAAR0oAAAAAAABHSgAAAAAAAEdKAAAAAAAAR0oAAAAAAABGSgAAAAAAAEZKAAAAAAAARkoAAAAAAABGSgAAAAAAAEZKAAAAAAAARkoAAAAAAABGSgAAAAAAAEJKAAAAAAAAQkoAAAAAAABCSgAAAAAAAEJKAAAAAAAAQkoAAAAAAABCSgAAAAAAAEJKAAAAAAAALEoAAAAAAAAsSgAAAAAAACxKAAAAAAAALEoAAAAAAAAsSgAAAAAAACxKAAAAAAAALEoAAAAAAAAESgAAAAAAAARKAAAAAAAABEoAAAAAAAAESgAAAAAAAARKAAAAAAAABEoAAAAAAAAESgAAAAAAAO1LAAAAAAAA7UsAAAAAAADtSwAAAAAAAO1LAAAAAAAA7UsAAAAAAADtSwAAAAAAAO1LAAAAAAAA7EsAAAAAAADsSwAAAAAAAOxLAAAAAAAA7EsAAAAAAADsSwAAAAAAAOxLAAAAAAAA7EsAAAAAAADsSwAAAAAAAOxLAAAAAAAA7EsAAAAAAADsSwAAAAAAAOxLAAAAAAAA7EsAAAAAAADsSwAAAAAAAOtLAAAAAAAA60sAAAAAAADrSwAAAAAAAOtLAAAAAAAA60sAAAAAAADrSwAAAAAAAOtLAAAAAAAA6ksAAAAAAADqSwAAAAAAAOpLAAAAAAAA6ksAAAAAAADqSwAAAAAAAOpLAAAAAAAA6ksAAAAAAADnSwAAAAAAAOdLAAAAAAAA50sAAAAAAADnSwAAAAAAAOdLAAAAAAAA50sAAAAAAADnSwAAAAAAANJLAAAAAAAA0ksAAAAAAADSSwAAAAAAANJLAAAAAAAA0ksAAAAAAADSSwAAAAAAANJLAAAAAAAAmEsAAAAAAACYSwAAAAAAAJhLAAAAAAAAmEsAAAAAAACYSwAAAAAAAJhLAAAAAAAAmEsAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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', - }, - }, -} as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js deleted file mode 100644 index bd12b7e0..00000000 --- a/src/sky/scatter.js +++ /dev/null @@ -1,362 +0,0 @@ -// Atmospheric scattering tables, baked for a WebGL2 shader (layout: src/sky/README.md). -// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. -// -// bakeScatter() -> { transmittance, sky_view, ... } with base64 RGB16F tables (half floats). -// -// A Hillaire-style single-scattering model (Rayleigh + Mie, spherical planet, observer 100 m up), -// reduced to two small tables: the transmittance of the whole atmosphere along a view direction, and -// the sky's radiance by sun elevation, view elevation and azimuth from the sun. sun.js stays the -// single source of the air: its zenith optical depths (TAU, per channel) are the vertical optical -// depth of this atmosphere, split into a grey aerosol part and a Rayleigh part. So the direct sun -// sun.js returns and the transmittance here agree at the zenith; only the air-mass law differs -// (Kasten-Young there, the spherical integral here). - -import { TAU } from './sun.js'; - -const RAD = Math.PI / 180; -const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); - -// Geometry (km) and scale heights. -export const PLANET_RADIUS_KM = 6360; -export const ATMOSPHERE_HEIGHT_KM = 100; -export const OBSERVER_ALTITUDE_KM = 0.1; -const H_RAYLEIGH = 8; -const H_MIE = 1.2; -const MIE_ALBEDO = 0.9; // single-scattering albedo of the aerosol (EYEBALLED) -const MIE_G = 0.8; // Henyey-Greenstein asymmetry (Hillaire 2020) -const MIE_TAU = 0.02; // grey aerosol vertical optical depth (EYEBALLED); the rest of TAU is Rayleigh - -// Vertical optical depth of an exponential layer of scale height H over the atmosphere. -const column = (H) => H * (1 - Math.exp(-ATMOSPHERE_HEIGHT_KM / H)); -const BETA_RAYLEIGH = TAU.map((tau) => (tau - MIE_TAU) / column(H_RAYLEIGH)); // per km at sea level -const BETA_MIE_EXT = MIE_TAU / column(H_MIE); -const BETA_MIE_SCA = BETA_MIE_EXT * MIE_ALBEDO; - -// Table axes. -export const SUN_ELEVATIONS_DEG = [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90]; -export const VIEW_SAMPLES = 32; -export const AZIMUTH_SAMPLES = 7; // 0, 30, ... 180 degrees from the sun -export const AZIMUTH_STEP_DEG = 30; -export const VIEW_MIN_DEG = -4; -export const VIEW_MAX_DEG = 90; -// Tables hold IEEE 754 half floats, little-endian, holding value * STORE_SCALE. The scale lifts the -// dark end (twilight sky radiance is ~1e-4) clear of the half-float subnormals; the brightest -// stored value, a Mie peak near 0.3 x 64, is far below the format's 65504. -export const STORE_SCALE = 64; - -/** View elevation in degrees of sample index i of VIEW_SAMPLES: finest at the horizon. */ -export const viewElevation = (i) => - VIEW_MIN_DEG + (VIEW_MAX_DEG - VIEW_MIN_DEG) * (i / (VIEW_SAMPLES - 1)) ** 2; - -/** Fractional index into SUN_ELEVATIONS_DEG of a sun elevation (clamped to the table): the z texel coordinate. */ -export const sunIndex = (elevationDeg) => { - const e = clamp( - elevationDeg, - SUN_ELEVATIONS_DEG[0], - SUN_ELEVATIONS_DEG[SUN_ELEVATIONS_DEG.length - 1] - ); - const k = Math.min(SUN_ELEVATIONS_DEG.findIndex((x) => x > e) - 1, SUN_ELEVATIONS_DEG.length - 2); - const i = k < 0 ? SUN_ELEVATIONS_DEG.length - 2 : k; - return i + (e - SUN_ELEVATIONS_DEG[i]) / (SUN_ELEVATIONS_DEG[i + 1] - SUN_ELEVATIONS_DEG[i]); -}; - -/** Inverse of viewElevation: the fractional sample index of an elevation. */ -export const viewIndex = (elevationDeg) => - Math.sqrt(clamp((elevationDeg - VIEW_MIN_DEG) / (VIEW_MAX_DEG - VIEW_MIN_DEG), 0, 1)) * - (VIEW_SAMPLES - 1); - -/** Round a non-negative number to the nearest IEEE 754 half float (ties to even); returns its 16 bits. */ -export function toHalf(x) { - if (!(x > 0)) return 0; - const u = new Uint32Array(new Float32Array([x]).buffer)[0]; - const exp = ((u >> 23) & 0xff) - 127 + 15; - const mant = u & 0x7fffff; - if (exp >= 31) return 0x7bff; - let half; - let rem; - let halfway; - if (exp <= 0) { - if (exp < -10) return 0; - const m = mant | 0x800000; - const shift = 14 - exp; - half = m >> shift; - rem = m & ((1 << shift) - 1); - halfway = 1 << (shift - 1); - } else { - half = (exp << 10) | (mant >> 13); - rem = mant & 0x1fff; - halfway = 0x1000; - } - return rem > halfway || (rem === halfway && half & 1) ? half + 1 : half; -} - -/** The exact value of 16 half-float bits (finite values only). */ -export const fromHalf = (h) => { - const e = (h >> 10) & 31; - const m = h & 1023; - return e === 0 ? m * 2 ** -24 : (1 + m / 1024) * 2 ** (e - 15); -}; - -/** Stored 16 bits of a value, and the value back out of them. */ -export const encode = (v) => toHalf(v * STORE_SCALE); -export const decode = (h) => fromHalf(h) / STORE_SCALE; - -const extinction = (h) => { - const r = Math.exp(-h / H_RAYLEIGH); - const m = Math.exp(-h / H_MIE); - return BETA_RAYLEIGH.map((b) => b * r + BETA_MIE_EXT * m); -}; - -// Distance along ray (origin o, unit direction d) to the sphere of radius r centred on the origin -// of the planet: the far root, or the near root if `near`. Infinity when it misses or is behind. -function sphereHit(o, d, r, near) { - const b = o[0] * d[0] + o[1] * d[1] + o[2] * d[2]; - const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - r * r; - const disc = b * b - c; - if (disc < 0) return Infinity; - const sq = Math.sqrt(disc); - const t = near ? -b - sq : -b + sq; - return t > 0 ? t : Infinity; -} - -// Optical depth per channel from p along d to the top of the atmosphere; Infinity if the planet is in the way. -function opticalDepthToSpace(p, d, steps) { - if (sphereHit(p, d, PLANET_RADIUS_KM, true) !== Infinity) return null; - const top = sphereHit(p, d, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); - const dt = top / steps; - const depth = [0, 0, 0]; - for (let k = 0; k < steps; k += 1) { - const t = (k + 0.5) * dt; - const q = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; - const e = extinction(Math.hypot(...q) - PLANET_RADIUS_KM); - for (let c = 0; c < 3; c += 1) depth[c] += e[c] * dt; - } - return depth; -} - -const observer = [0, PLANET_RADIUS_KM + OBSERVER_ALTITUDE_KM, 0]; -const viewDirection = (elevationDeg, azimuthDeg) => { - const e = elevationDeg * RAD; - const a = azimuthDeg * RAD; - return [Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a)]; -}; -const sunDirection = (elevationDeg) => [ - Math.cos(elevationDeg * RAD), - Math.sin(elevationDeg * RAD), - 0, -]; - -/** Transmittance per channel from the observer along an elevation to space; zeros where the ground is in the way. */ -export function transmittance(elevationDeg) { - const depth = opticalDepthToSpace(observer, viewDirection(elevationDeg, 0), 64); - return depth ? depth.map((x) => Math.exp(-x)) : [0, 0, 0]; -} - -const rayleighPhase = (c) => (3 / (16 * Math.PI)) * (1 + c * c); -const miePhase = (c) => - (1 - MIE_G * MIE_G) / (4 * Math.PI * (1 + MIE_G * MIE_G - 2 * MIE_G * c) ** 1.5); - -/** - * Sky radiance per channel, per steradian, for a sun of unit irradiance (so the consumer multiplies by - * its own sun colour), in a view direction at an elevation and an azimuth measured from the sun. - * Single scattering, no ground bounce, no sun disc. - */ -export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { - const v = viewDirection(viewElevationDeg, azimuthDeg); - const s = sunDirection(sunElevationDeg); - const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; - const pr = rayleighPhase(cosTheta); - const pm = miePhase(cosTheta); - const ground = sphereHit(observer, v, PLANET_RADIUS_KM, true); - const top = sphereHit(observer, v, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); - const end = Math.min(ground, top); - const steps = 24; - const dt = end / steps; - const along = [0, 0, 0]; - const radiance = [0, 0, 0]; - for (let k = 0; k < steps; k += 1) { - const t = (k + 0.5) * dt; - const q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; - const h = Math.hypot(...q) - PLANET_RADIUS_KM; - const e = extinction(h); - for (let c = 0; c < 3; c += 1) along[c] += e[c] * dt; - const toSun = opticalDepthToSpace(q, s, 12); - if (toSun) { - const r = Math.exp(-h / H_RAYLEIGH); - const m = Math.exp(-h / H_MIE); - for (let c = 0; c < 3; c += 1) { - const scatter = BETA_RAYLEIGH[c] * r * pr + BETA_MIE_SCA * m * pm; - radiance[c] += Math.exp(-along[c] - toSun[c]) * scatter * dt; - } - } - } - return radiance; -} - -// ---- Aerial perspective ------------------------------------------------------------------------------------------ -// After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020 (section 5.4, the -// aerial perspective "camera volume"), and its reference implementation, sebh/UnrealEngineSkyAtmosphere -// Resources/RenderSkyRayMarching.hlsl `RenderCameraVolumePS`: a volume of AP_SLICES slices whose depth grows with the -// square of the slice index (`Slice *= Slice`), each texel holding the single-scattered luminance L accumulated along -// the view ray to that depth, and in alpha the opacity 1 - mean(rgb transmittance) (a grey transmittance, as the paper). -// Applied to a surface as out = surface * (1 - alpha) + L, with L the in-scatter along that ray. -// -// What differs from the paper, and why: -// - the volume is indexed by the view ray relative to the sun (azimuth from the sun, elevation) and sun elevation, not by -// screen position, because the terrain camera turns and the sky tables here are sun-relative too; -// - the path lengths are the scene's length times AP_DISTANCE_SCALE: a real atmosphere is nearly clear over the 3 km patch, -// so the baked path is that many times longer (EYEBALLED); it sets how fast far ridges go to the sky; -// - the ray ignores the ground: below the observer's altitude the density is held at its ground value, and the sun's -// visibility is tested from the ground sphere; the terrain is the ground; -// - single scattering with the sky tables' own air (no multi-scattering LUT, which the paper adds for dusk); -// - AP_SLICES = 12 slices, 8 view elevations and 7 azimuths, not 32 x 32 x 32 screen froxels, to stay phone-sized. -export const AP_SLICES = 12; -export const AP_ELEVATIONS = 8; -/** The view elevations the volume holds run from this down to 0 degrees, finest at 0: -AP_ELEVATION_MIN_DEG * (1 - i / 7)^2. */ -export const AP_ELEVATION_MIN_DEG = 50; -/** Scene metres a slice volume covers: the patch's far corner (about 2.1 km) with margin. */ -export const AP_SCENE_MAX_M = 2500; -export const AP_DISTANCE_SCALE = 8; -/** Physical kilometres the last slice stands for. */ -export const AP_MAX_KM = (AP_SCENE_MAX_M * AP_DISTANCE_SCALE) / 1000; -const AP_SUBSTEPS = 4; - -export const apElevation = (i) => -AP_ELEVATION_MIN_DEG * (1 - i / (AP_ELEVATIONS - 1)) ** 2; -/** Fractional elevation index of a view elevation in degrees (clamped to 0 degrees and below). */ -export const apElevationIndex = (deg) => - (AP_ELEVATIONS - 1) * (1 - Math.sqrt(clamp(-deg / AP_ELEVATION_MIN_DEG, 0, 1))); -/** Distance in km at the far side of slice k (k = 0 .. AP_SLICES - 1): the squared distribution. */ -export const apSliceKm = (k) => AP_MAX_KM * ((k + 1) / AP_SLICES) ** 2; -/** Fractional slice index of a distance in km: below 0 the volume fades to nothing at the observer. */ -export const apSliceIndex = (km) => AP_SLICES * Math.sqrt(Math.max(km, 0) / AP_MAX_KM) - 1; - -/** - * In-scatter luminance per channel (for a sun of unit irradiance) and opacity along a view ray from the observer, - * at the far side of each of AP_SLICES slices: [[r, g, b, alpha] x AP_SLICES]. - */ -export function aerialRay(sunElevationDeg, viewElevationDeg, azimuthDeg) { - const v = viewDirection(viewElevationDeg, azimuthDeg); - const s = sunDirection(sunElevationDeg); - const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; - const pr = rayleighPhase(cosTheta); - const pm = miePhase(cosTheta); - const out = []; - const along = [0, 0, 0]; - const radiance = [0, 0, 0]; - let from = 0; - for (let k = 0; k < AP_SLICES; k += 1) { - const to = apSliceKm(k); - const dt = (to - from) / AP_SUBSTEPS; - for (let j = 0; j < AP_SUBSTEPS; j += 1) { - const t = from + (j + 0.5) * dt; - let q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; - const r = Math.hypot(...q); - if (r < PLANET_RADIUS_KM) q = q.map((x) => (x * PLANET_RADIUS_KM) / r); - const h = Math.max(r - PLANET_RADIUS_KM, 0); - const e = extinction(h); - const toSun = opticalDepthToSpace(q, s, 12); - const rm = Math.exp(-h / H_RAYLEIGH); - const mm = Math.exp(-h / H_MIE); - for (let c = 0; c < 3; c += 1) { - // The step's own extinction is integrated analytically (Hillaire eq. 6): S (1 - exp(-e dt)) / e. - const step = Math.exp(-e[c] * dt); - if (toSun) { - const scatter = BETA_RAYLEIGH[c] * rm * pr + BETA_MIE_SCA * mm * pm; - radiance[c] += Math.exp(-along[c] - toSun[c]) * ((scatter * (1 - step)) / e[c]); - } - along[c] += e[c] * dt; - } - } - from = to; - const mean = along.reduce((a, x) => a + Math.exp(-x), 0) / 3; - out.push([...radiance, 1 - mean]); - } - return out; -} - -const base64 = (halves) => { - const bytes = new Uint8Array(halves.length * 2); - halves.forEach((h, i) => { - bytes[i * 2] = h & 0xff; // little-endian - bytes[i * 2 + 1] = h >> 8; - }); - let s = ''; - for (let i = 0; i < bytes.length; i += 0x8000) { - s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); - } - return btoa(s); -}; - -export function bakeScatter() { - const view = Array.from({ length: VIEW_SAMPLES }, (_, i) => viewElevation(i)); - - const t = new Uint16Array(VIEW_SAMPLES * 3); - view.forEach((e, i) => transmittance(e).forEach((v, c) => (t[i * 3 + c] = encode(v)))); - - // x = azimuth, y = view elevation, z = sun elevation, three half floats per texel: a 3D texture. - const sky = new Uint16Array(SUN_ELEVATIONS_DEG.length * VIEW_SAMPLES * AZIMUTH_SAMPLES * 3); - let at = 0; - SUN_ELEVATIONS_DEG.forEach((sun) => - view.forEach((e) => { - for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) { - skyRadiance(sun, e, a * AZIMUTH_STEP_DEG).forEach((v) => { - sky[at] = encode(v); - at += 1; - }); - } - }) - ); - - // x = azimuth from the sun, y = elevation block k * AP_ELEVATIONS + i (slice-major), z = sun elevation: four half floats per texel (L rgb, alpha). - const aerial = new Uint16Array( - SUN_ELEVATIONS_DEG.length * AP_SLICES * AP_ELEVATIONS * AZIMUTH_SAMPLES * 4 - ); - let ap = 0; - SUN_ELEVATIONS_DEG.forEach((sun) => { - const rays = Array.from({ length: AP_ELEVATIONS }, (_, i) => - Array.from({ length: AZIMUTH_SAMPLES }, (_, a) => - aerialRay(sun, apElevation(i), a * AZIMUTH_STEP_DEG) - ) - ); - for (let k = 0; k < AP_SLICES; k += 1) - for (let i = 0; i < AP_ELEVATIONS; i += 1) - for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) - rays[i][a][k].forEach((x) => { - aerial[ap] = encode(x); - ap += 1; - }); - }); - - return { - planet_radius_km: PLANET_RADIUS_KM, - atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, - observer_altitude_km: OBSERVER_ALTITUDE_KM, - // Extinction per km at the observer, per channel, for aerial perspective: exp(-k * distance_km). - extinction_per_km: extinction(OBSERVER_ALTITUDE_KM), - format: 'half-float', - store_scale: STORE_SCALE, - view_elevation_deg: { min: VIEW_MIN_DEG, max: VIEW_MAX_DEG, samples: VIEW_SAMPLES }, - transmittance: { width: VIEW_SAMPLES, height: 1, channels: 3, data: base64(t) }, - sky_view: { - sun_elevations_deg: SUN_ELEVATIONS_DEG, - azimuth_step_deg: AZIMUTH_STEP_DEG, - width: AZIMUTH_SAMPLES, - height: VIEW_SAMPLES, - depth: SUN_ELEVATIONS_DEG.length, - channels: 3, - data: base64(sky), - }, - aerial_perspective: { - slices: AP_SLICES, - elevations: AP_ELEVATIONS, - elevation_min_deg: -AP_ELEVATION_MIN_DEG, - max_km: AP_MAX_KM, - distance_scale: AP_DISTANCE_SCALE, - width: AZIMUTH_SAMPLES, - height: AP_SLICES * AP_ELEVATIONS, - depth: SUN_ELEVATIONS_DEG.length, - channels: 4, - data: base64(aerial), - }, - }; -} diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts deleted file mode 100644 index e0ea9023..00000000 --- a/src/sky/scatter.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - AP_ELEVATIONS, - AP_MAX_KM, - AP_SLICES, - apElevation, - apElevationIndex, - apSliceIndex, - apSliceKm, - aerialRay, - AZIMUTH_SAMPLES, - AZIMUTH_STEP_DEG, - STORE_SCALE, - SUN_ELEVATIONS_DEG, - VIEW_SAMPLES, - bakeScatter, - decode, - fromHalf, - skyRadiance, - sunIndex, - toHalf, - transmittance, - viewElevation, - viewIndex, -} from './scatter.js'; -import { TAU } from './sun.js'; -import { baked } from './baked'; - -const table = (b64: string): Uint16Array => { - const bin = atob(b64); - const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); - return new Uint16Array(bytes.buffer); -}; -// Checksums of the baked halves, so a change to the model or the encoding is a deliberate re-pin. -const SKY_CHECKSUM = 622927107; -const TRANS_CHECKSUM = 1498461058; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const scatter = baked.scatter as any; -const sky = table(scatter.sky_view.data); -const trans = table(scatter.transmittance.data); -const sunIndex_ = (deg: number) => SUN_ELEVATIONS_DEG.indexOf(deg); -// Texel of the sky table: x = azimuth, y = view elevation, z = sun elevation; value = three halves. -const texel = (s: number, v: number, a: number) => - [0, 1, 2].map((c) => decode(sky[((s * VIEW_SAMPLES + v) * AZIMUTH_SAMPLES + a) * 3 + c])); - -describe('half floats', () => { - it('round-trip within half precision across the table range, ties to even', () => { - [6.1e-5, 1e-4, 0.0123, 0.3, 1, 20, 1000].forEach((x) => - expect(Math.abs(fromHalf(toHalf(x)) - x) / x).toBeLessThan(5.1e-4) - ); - expect(toHalf(0)).toBe(0); - expect(toHalf(1)).toBe(0x3c00); - expect(toHalf(2 ** -24)).toBe(1); // smallest subnormal - expect(toHalf(1 + 2 ** -11)).toBe(0x3c00); // exact tie goes to the even neighbour - expect(toHalf(1 + 3 * 2 ** -11)).toBe(0x3c02); - expect(fromHalf(0x7bff)).toBe(65504); - }); - - it('keeps twilight radiance, ~1e-4, within 0.1% thanks to the store scale', () => { - const x = 1.234e-4; - expect(Math.abs(decode(toHalf(x * STORE_SCALE)) - x) / x).toBeLessThan(1e-3); - }); -}); - -describe('sunIndex', () => { - it('is the fractional position of an elevation on the sun axis, clamped to the table', () => { - expect(sunIndex(-18)).toBe(0); - expect(sunIndex(90)).toBe(SUN_ELEVATIONS_DEG.length - 1); - expect(sunIndex(-100)).toBe(0); - expect(sunIndex(100)).toBe(SUN_ELEVATIONS_DEG.length - 1); - expect(sunIndex(0)).toBe(sunIndex_(0)); - expect(sunIndex(1.5)).toBeCloseTo(sunIndex_(0) + 0.5, 12); // halfway between 0 and 3 - expect(sunIndex(35)).toBeCloseTo(sunIndex_(25) + 0.5, 12); // halfway between 25 and 45 - }); -}); - -describe('scattering tables', () => { - it('are what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => { - expect(JSON.parse(JSON.stringify(bakeScatter()))).toEqual(JSON.parse(JSON.stringify(scatter))); - }); - - it('have the documented layout and size', () => { - expect(scatter.format).toBe('half-float'); - expect(scatter.sky_view).toMatchObject({ - width: AZIMUTH_SAMPLES, - height: VIEW_SAMPLES, - depth: SUN_ELEVATIONS_DEG.length, - channels: 3, - azimuth_step_deg: AZIMUTH_STEP_DEG, - }); - expect(sky.length).toBe(AZIMUTH_SAMPLES * VIEW_SAMPLES * SUN_ELEVATIONS_DEG.length * 3); - expect(trans.length).toBe(VIEW_SAMPLES * 3); - expect(sky.length * 2).toBe(16128); // bytes: the budget the README states - }); - - it('pin the view-elevation axis and its inverse', () => { - expect(viewElevation(0)).toBe(-4); - expect(viewElevation(VIEW_SAMPLES - 1)).toBe(90); - expect(viewElevation(6)).toBeCloseTo(-4 + 94 * (6 / 31) ** 2, 10); - [0, 5, 6, 17, 31].forEach((i) => expect(viewIndex(viewElevation(i))).toBeCloseTo(i, 9)); - }); - - it('transmittance to space agrees with sun.js at the zenith and falls toward the horizon', () => { - const top = VIEW_SAMPLES - 1; - [0, 1, 2].forEach((c) => - expect(Math.abs(decode(trans[top * 3 + c]) / Math.exp(-TAU[c]) - 1)).toBeLessThan(0.01) - ); - expect(transmittance(90).map((v, c) => v / Math.exp(-TAU[c]))).toSatisfy((r: number[]) => - r.every((x) => Math.abs(x - 1) < 0.01) - ); - // blue goes first: lower view, lower blue, and redder light at the horizon - let prev = Infinity; - for (let i = top; i >= 8; i -= 1) { - const blue = decode(trans[i * 3 + 2]); - expect(blue).toBeLessThanOrEqual(prev); - prev = blue; - } - expect(decode(trans[8 * 3]) / decode(trans[8 * 3 + 2])).toBeGreaterThan(10); // ~2 degrees up: sunset red - expect(decode(trans[0])).toBe(0); // -4 degrees looks at the ground - }); - - it('colour the daytime sky blue, and a low sun redder toward it than away from it', () => { - const noon = texel(sunIndex_(45), VIEW_SAMPLES - 1, 0); - expect(noon[2]).toBeGreaterThan(noon[1]); - expect(noon[1]).toBeGreaterThan(noon[0]); - const horizon = Math.round(viewIndex(5)); - const toward = texel(sunIndex_(3), horizon, 0); - const away = texel(sunIndex_(3), horizon, AZIMUTH_SAMPLES - 1); - expect(toward[0] / toward[2]).toBeGreaterThan(5); - expect(toward[0] / toward[2]).toBeGreaterThan(2 * (away[0] / away[2])); - expect(toward[0]).toBeGreaterThan(5 * away[0]); // the glow is around the sun - }); - - it('fade through twilight to black below the planet-shadow limit', () => { - const mid = Math.round(viewIndex(30)); - const lum = (s: number) => texel(sunIndex_(s), mid, 3).reduce((x, y) => x + y, 0); - expect(lum(8)).toBeGreaterThan(lum(3)); - expect(lum(3)).toBeGreaterThan(lum(-2)); - expect(lum(-2)).toBeGreaterThan(lum(-5)); - expect(lum(-5)).toBeGreaterThan(0); - expect(lum(-12)).toBe(0); - expect(lum(-18)).toBe(0); - }); - - it('hold the model to half-float precision, with no quantised steps along the horizon glow', () => { - // Every table texel off a handful of sampled ones is the model's own value to < 0.2%. - [ - [sunIndex_(3), 6, 0], - [sunIndex_(3), 9, 2], - [sunIndex_(0), 8, 0], - [sunIndex_(45), 20, 3], - [sunIndex_(-2), 12, 1], - ].forEach(([s, v, a]) => { - const direct = skyRadiance(SUN_ELEVATIONS_DEG[s], viewElevation(v), a * AZIMUTH_STEP_DEG); - texel(s, v, a).forEach((got, c) => expect(Math.abs(got / direct[c] - 1)).toBeLessThan(2e-3)); - }); - // Moving up from the horizon toward the sun, red drops smoothly: distinct, strictly decreasing. - const s = sunIndex_(3); - const reds = Array.from({ length: 8 }, (_, k) => texel(s, 7 + k, 0)[0]); - reds.slice(1).forEach((r, k) => expect(r).toBeLessThan(reds[k])); - expect(new Set(reds).size).toBe(reds.length); - }); - - it('are the same bytes as when pinned', () => { - const sum = (a: Uint16Array) => a.reduce((x, y) => (x * 31 + y) % 2147483647, 7); - expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); - }); -}); - -describe('aerial perspective volume', () => { - const ap = scatter.aerial_perspective; - - it('has the documented layout and size: RGBA half floats, x azimuth, y slice-major elevations, z sun elevation', () => { - expect([ap.width, ap.height, ap.depth, ap.channels]).toEqual([ - AZIMUTH_SAMPLES, - AP_SLICES * AP_ELEVATIONS, - SUN_ELEVATIONS_DEG.length, - 4, - ]); - expect(table(ap.data)).toHaveLength(ap.width * ap.height * ap.depth * 4); - }); - - it("slices follow the paper's squared distribution, ending at the covered distance", () => { - expect(apSliceKm(AP_SLICES - 1)).toBeCloseTo(AP_MAX_KM, 9); - expect(apSliceKm(0)).toBeCloseTo(AP_MAX_KM / AP_SLICES ** 2, 9); - for (let k = 0; k < AP_SLICES; k += 1) expect(apSliceIndex(apSliceKm(k))).toBeCloseTo(k, 9); - expect(apSliceIndex(0)).toBeCloseTo(-1, 9); - }); - - it('elevations are finest at the horizon and the index inverts them', () => { - expect(apElevation(AP_ELEVATIONS - 1)).toBeCloseTo(0, 12); - for (let i = 0; i < AP_ELEVATIONS; i += 1) - expect(apElevationIndex(apElevation(i))).toBeCloseTo(i, 9); - expect(apElevation(AP_ELEVATIONS - 1) - apElevation(AP_ELEVATIONS - 2)).toBeLessThan( - apElevation(1) - apElevation(0) - ); - }); - - it("opacity grows with path, in-scatter grows with path, and a sun under the planet's shadow scatters nothing", () => { - const r = aerialRay(45, -2, 90); - for (let k = 1; k < AP_SLICES; k += 1) { - expect(r[k][3]).toBeGreaterThan(r[k - 1][3]); - expect(r[k][0]).toBeGreaterThan(r[k - 1][0]); - } - for (const slice of aerialRay(-18, -2, 0)) expect(slice.slice(0, 3)).toEqual([0, 0, 0]); - }); - - it('is brighter toward a low sun than away, and redder toward it (Rayleigh and Mie phase)', () => { - const toward = aerialRay(2, -2, 0)[AP_SLICES - 1]; - const away = aerialRay(2, -2, 180)[AP_SLICES - 1]; - expect(toward[0]).toBeGreaterThan(20 * away[0]); - expect(toward[0] / toward[2]).toBeGreaterThan(away[0] / away[2]); - }); - - it('opacity does not depend on the sun, only on the path', () => { - expect(aerialRay(45, -2, 0)[5][3]).toBeCloseTo(aerialRay(-18, -2, 90)[5][3], 12); - }); - - it("the baked table is the model's output: spot texels equal aerialRay, to half-float precision", () => { - const t = table(ap.data); - const at = (sun: number, slice: number, el: number, az: number, c: number) => - decode(t[((sun * ap.height + slice * AP_ELEVATIONS + el) * ap.width + az) * 4 + c]); - const r = aerialRay(SUN_ELEVATIONS_DEG[8], apElevation(6), 60)[7]; - for (let c = 0; c < 4; c += 1) expect(at(8, 7, 6, 2, c)).toBeCloseTo(r[c], 3); - }); -}); diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js deleted file mode 100644 index 5152a4fb..00000000 --- a/src/sky/skyTokens.js +++ /dev/null @@ -1,220 +0,0 @@ -// Midnight Sky and Noonday Sky: theme tokens as albedo x irradiance. -// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. -// -// bakeSky('midnight' | 'noonday') -> { moment, elevation, sun, ambient_linear, horizon_hex, -// gradient: [hex x GRADIENT_STOPS], tokens, ... } -// -// The sky is lit by sun.js at a fixed moment (clear weather: a build-time bake has no feed). -// A surface is `albedo * irradiance`, where irradiance is the sun and sky on an upward-facing -// surface, rescaled to a per-role brightness `key` (the eye adapts to the scene; only the -// light's colour survives, not its absolute level). Everything marked EYEBALLED is tuned by -// looking and then held to WCAG AA by skyTokens.test.ts. - -import { light } from './sun.js'; - -const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); -const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t); -const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - -export const GRADIENT_STOPS = 7; -export const AA = 4.5; // WCAG 2.x AA, normal text - -// The lightest the Night sky draws behind the page (src/app/features/scene/skyState.ts, `skyBackdrop('night')`; a test in -// skyTheme.test.ts fails if the two drift). Midnight Sky's link also sits on bare sky, so it must clear AA against this. -export const NIGHT_SKY_LIGHTEST = 0.081642; - -// Where and when the theme is lit. Equinox, 45N on the prime meridian: noon sun at 45 degrees. -export const MOMENTS = { - noonday: { time: '2026-09-22T12:00:00Z', lat: 45, lon: 0 }, - midnight: { time: '2026-09-22T00:00:00Z', lat: 45, lon: 0 }, -}; - -// Lowest ambient a baked sky reports, linear RGB. sun.js's night sky is ~0.004, which is -// starlight-true but too dark to see by on a screen; this lifts it to a bluish moonlit-skyglow -// level. Daylight ambient is far above it, so the floor only ever touches the night. -export const AMBIENT_FLOOR = [0.018, 0.024, 0.04]; - -// Linear-RGB albedos of the things Yggdrasil is made of. EYEBALLED around measured ranges -// (dark bark 0.05-0.1, birch bark ~0.6, leaves ~0.05 red/0.15 green/0.03 blue, wet soil ~0.1). -export const ALBEDO = { - soil: [0.1, 0.065, 0.04], - bark: [0.075, 0.048, 0.03], - barkPale: [0.6, 0.57, 0.5], // birch and lichen - barkChar: [0.035, 0.026, 0.018], - leaf: [0.06, 0.2, 0.05], - leafPale: [0.3, 0.5, 0.16], // new growth - clay: [0.36, 0.1, 0.07], - ochre: [0.4, 0.28, 0.06], -}; - -// Irradiance on an upward surface, unit-luminance: only the light's colour is kept. -function chroma({ direct, diffuse, elevation }) { - const up = Math.max(0, Math.sin((elevation * Math.PI) / 180)); - const e = diffuse.map((d, i) => d + direct[i] * up); - const y = luma(e); - return e.map((v) => v / y); -} - -// sRGB transfer, linear <-> encoded. -const encode = (v) => (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055); -const decode = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); -export const toHex = (lin) => - `#${ - lin - .map((v) => - Math.round(clamp(encode(clamp(v, 0, 1)), 0, 1) * 255) - .toString(16) - .padStart(2, '0') - ) - .join('')}`; -export const fromHex = (hex) => - [1, 3, 5].map((i) => decode(parseInt(hex.slice(i, i + 2), 16) / 255)); -export const luminance = (hex) => luma(fromHex(hex)); -export const contrast = (a, b) => { - const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); - return (hi + 0.05) / (lo + 0.05); -}; - -// Per-theme brightness keys (target luminance of an albedo-1 surface). EYEBALLED. -const DARK = { - skyKey: 0.06, // zenith of the baked gradient - main: 0.85, // buttons and accents: lit pale albedos - container: 0.06, // tinted panels: lit mid albedos - text: 1.1, - steps: { Hover: 1.18, Active: 1.36, Line: 1.7 }, -}; -const LIGHT = { - skyKey: 0.55, - main: 0.55, - container: 1.0, - text: 1.0, - steps: { Hover: 0.93, Active: 0.86, Line: 0.76 }, -}; - -// Pigments for other people's names (--mx-uc-1..8): flower and berry albedos. EYEBALLED hues. -const PIGMENTS = [ - [0.1, 0.25, 0.7], - [0.45, 0.15, 0.6], - [0.1, 0.5, 0.3], - [0.7, 0.15, 0.3], - [0.8, 0.35, 0.08], - [0.1, 0.5, 0.55], - [0.3, 0.3, 0.8], - [0.45, 0.6, 0.12], -]; - -export function bakeSky(name) { - const dark = name === 'midnight'; - const T = dark ? DARK : LIGHT; - const moment = MOMENTS[name]; - const l = light(moment); - const c = chroma(l); - const lit = (albedo, key) => albedo.map((a, i) => a * c[i] * key); - const hex = (albedo, key) => toHex(lit(albedo, key)); - - // Sky gradient, top to bottom: zenith radiance ~ diffuse, horizon whitened by aerosol and - // warmed by whatever direct light is left. EYEBALLED mix. - const zenith = l.diffuse; - const grey = luma(zenith); - const horizon = zenith.map((z, i) => z * 0.6 + grey * 0.7 + l.direct[i] * 0.05); - const scale = T.skyKey / luma(zenith); - const gradient = Array.from({ length: GRADIENT_STOPS }, (_, k) => { - const t = (k / (GRADIENT_STOPS - 1)) ** 1.6; // dwell on the upper sky - return toHex(mix3(zenith, horizon, t).map((v) => v * scale)); - }); - - const stepped = (albedo, key) => - Object.fromEntries(Object.entries(T.steps).map(([n, s]) => [n, hex(albedo, key * s)])); - const PALE = ALBEDO.barkPale; - const tint = (a) => (dark ? a : mix3(a, PALE, 0.8)); // light containers are pale tints - const text = hex(dark ? PALE : ALBEDO.barkChar, T.text); - - const panel = (albedo, key) => ({ - Container: hex(albedo, key), - ...Object.fromEntries( - Object.entries(stepped(albedo, key)).map(([n, v]) => [`Container${ n}`, v]) - ), - OnContainer: text, - }); - const accent = (albedo) => { - const main = dark ? mix3(albedo, PALE, 0.35) : albedo; - // Label: whichever of a charred or a pale lit surface reads better. The button is then - // pushed away from it (brighter on dark, darker on light) until the pair clears AA. - const labels = [hex(ALBEDO.barkChar, 0.9), hex(PALE, 1.4)]; - let key = T.main; - let mainHex = hex(main, key); - const onMain = labels.sort((a, b) => contrast(mainHex, b) - contrast(mainHex, a))[0]; - for (let n = 0; n < 40 && contrast(mainHex, onMain) < AA; n += 1) { - key *= dark ? 1.04 : 0.96; - mainHex = hex(main, key); - } - return { - Main: mainHex, - ...Object.fromEntries(Object.entries(stepped(main, key)).map(([n, v]) => [`Main${ n}`, v])), - OnMain: onMain, - Container: hex(tint(albedo), T.container), - ...Object.fromEntries( - Object.entries(stepped(tint(albedo), T.container)).map(([n, v]) => [`Container${ n}`, v]) - ), - OnContainer: text, - }; - }; - - // Depth reads as brightness: Background < Surface < SurfaceVariant on dark, the reverse on light. - const depth = dark ? [0.05, 0.12, 0.2] : [0.85, 1.15, 1.0]; - const tokens = { - Background: panel(dark ? ALBEDO.soil : PALE, depth[0]), - Surface: panel(dark ? ALBEDO.bark : ALBEDO.barkPale, depth[1]), - SurfaceVariant: panel(dark ? ALBEDO.bark : ALBEDO.barkPale, depth[2]), - Primary: accent(dark ? ALBEDO.leafPale : ALBEDO.leaf), - Secondary: accent(dark ? ALBEDO.barkPale : ALBEDO.bark), - Success: accent(dark ? ALBEDO.leafPale : ALBEDO.leaf), - Warning: accent(ALBEDO.ochre), - Critical: accent(ALBEDO.clay), - }; - - // Chat bubbles, same lighting: other people's on bark, your own on leaf. - const bubbles = { - Other: hex(dark ? ALBEDO.bark : mix3(ALBEDO.barkPale, ALBEDO.bark, 0.0), dark ? 0.06 : 0.82), - OtherText: text, - Own: hex(dark ? ALBEDO.leaf : mix3(ALBEDO.leafPale, PALE, 0.55), dark ? 0.1 : 0.9), - OwnText: text, - }; - - // Names must clear AA on every panel a message can sit on: pushed away from them until they do. - const grounds = [ - tokens.Background.Container, - tokens.Surface.Container, - tokens.SurfaceVariant.Container, - bubbles.Other, - bubbles.Own, - ]; - const readable = (h) => grounds.every((g) => contrast(h, g) >= AA); - const fit = (albedo, ok = () => true) => { - let key = 0.5; - let h = hex(albedo, key); - for (let n = 0; n < 80 && !(readable(h) && ok(h)); n += 1) { - key *= dark ? 1.05 : 0.95; - h = hex(albedo, key); - } - return h; - }; - - return { - moment, - elevation: l.elevation, - // Sun for consumers that light a scene themselves: direction in degrees (azimuth clockwise - // from north) and the direct light's colour at normal incidence (black while it is below the horizon). - sun: { elevation_deg: l.elevation, azimuth_deg: l.azimuth, color_linear: l.direct }, - ambient_linear: l.diffuse.map((v, i) => Math.max(v, AMBIENT_FLOOR[i])), - horizon_hex: gradient[GRADIENT_STOPS - 1], // last stop: the horizon end of the gradient - gradient, - tokens, - bubbles, - userColors: PIGMENTS.map((p) => fit(p)), - // The dark link also has to clear AA on the bare Night sky: brightened, in its own hue, until it does. - link: dark - ? fit(ALBEDO.leafPale, (h) => luminance(h) >= AA * (NIGHT_SKY_LIGHTEST + 0.05) - 0.05) - : fit(ALBEDO.leaf), - }; -} diff --git a/src/sky/skyTokens.test.ts b/src/sky/skyTokens.test.ts deleted file mode 100644 index 78a29b03..00000000 --- a/src/sky/skyTokens.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { AA, AMBIENT_FLOOR, bakeSky, contrast } from './skyTokens.js'; -import { light } from './sun.js'; -import { baked } from './baked'; - -const names = ['midnight', 'noonday'] as const; -const roles = ['Primary', 'Secondary', 'Success', 'Warning', 'Critical'] as const; -const states = ['', 'Hover', 'Active']; - -describe.each(names)('%s sky', (name) => { - const sky = baked[name] as any; - const t = sky.tokens; - - it('is what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => { - expect(JSON.parse(JSON.stringify(bakeSky(name)))).toEqual(JSON.parse(JSON.stringify(sky))); - }); - - it('is lit by the sun on the side of the horizon its name says', () => { - expect(name === 'noonday' ? sky.elevation : -sky.elevation).toBeGreaterThan(40); - }); - - it('carries the sun, an ambient light and a horizon colour for consumers that light a scene', () => { - const { sun } = sky; - expect(Object.keys(sun).sort()).toEqual(['azimuth_deg', 'color_linear', 'elevation_deg']); - expect(sun.elevation_deg).toBe(sky.elevation); - expect(sun.color_linear).toHaveLength(3); - sun.color_linear.forEach((v: number) => { - expect(Number.isFinite(v)).toBe(true); - expect(v).toBeGreaterThanOrEqual(0); - }); - expect(sky.ambient_linear).toHaveLength(3); - expect(sky.horizon_hex).toMatch(/^#[0-9a-f]{6}$/); - expect(sky.horizon_hex).toBe(sky.gradient[sky.gradient.length - 1]); - }); - - it('puts the sun where Nibu expects it, azimuth clockwise from north', () => { - const azimuth: number = sky.sun.azimuth_deg; - expect(azimuth).toBeGreaterThanOrEqual(0); - expect(azimuth).toBeLessThan(360); - expect(Math.abs(azimuth - (name === 'noonday' ? 182.6 : 2.5))).toBeLessThan(1); - }); - - it('keeps ambient above the night floor and leaves daylight alone', () => { - sky.ambient_linear.forEach((v: number, i: number) => - expect(v).toBeGreaterThanOrEqual(AMBIENT_FLOOR[i]) - ); - if (name === 'noonday') { - sky.ambient_linear.forEach((v: number) => expect(v).toBeGreaterThan(0.05)); - expect(sky.ambient_linear).toEqual(light(sky.moment).diffuse); - } - }); - - it('clears WCAG AA for text on every panel state', () => { - ['Background', 'Surface', 'SurfaceVariant'].forEach((p) => - states.forEach((s) => - expect(contrast(t[p][`Container${ s}`], t[p].OnContainer)).toBeGreaterThanOrEqual(AA) - ) - ); - }); - - it('clears AA for button labels and tinted-container text in every state', () => { - roles.forEach((r) => - states.forEach((s) => { - expect(contrast(t[r][`Main${ s}`], t[r].OnMain)).toBeGreaterThanOrEqual(AA); - expect(contrast(t[r][`Container${ s}`], t[r].OnContainer)).toBeGreaterThanOrEqual(AA); - }) - ); - }); - - it('clears AA for message text in both bubbles, links and names', () => { - expect(contrast(sky.bubbles.Other, sky.bubbles.OtherText)).toBeGreaterThanOrEqual(AA); - expect(contrast(sky.bubbles.Own, sky.bubbles.OwnText)).toBeGreaterThanOrEqual(AA); - const grounds = [ - t.Background.Container, - t.Surface.Container, - t.SurfaceVariant.Container, - sky.bubbles.Other, - sky.bubbles.Own, - ]; - grounds.forEach((g) => { - expect(contrast(sky.link, g)).toBeGreaterThanOrEqual(AA); - sky.userColors.forEach((c: string) => expect(contrast(c, g)).toBeGreaterThanOrEqual(AA)); - }); - }); - - it('clears AA for body text straight over the fallback sky gradient', () => { - sky.gradient.forEach((g: string) => - expect(contrast(t.Surface.OnContainer, g)).toBeGreaterThanOrEqual(AA) - ); - }); -}); diff --git a/src/sky/sun.js b/src/sky/sun.js deleted file mode 100644 index 3173eb4d..00000000 --- a/src/sky/sun.js +++ /dev/null @@ -1,109 +0,0 @@ -// Sun and sky light for a place and a moment. Dependency-free ES module: no DOM, -// no imports, so a build-time token script and a runtime shader can share it. -// -// light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) -// -> { elevation, azimuth, direct: [r,g,b], diffuse: [r,g,b], wetness } -// -// time Date, epoch milliseconds, or an ISO string (read as UTC) -// lat, lon degrees, north and east positive -// cloud_cover 0..1 (Open-Meteo reports percent: divide by 100) -// precipitation mm/h -// elevation degrees above the horizon, no refraction -// azimuth degrees clockwise from north (0 N, 90 E, 180 S), 0..360, no refraction -// direct sun colour at normal incidence, linear RGB. Clear zenith sun ~ 1. -// The consumer multiplies by max(0, dot(normal, toSun)). -// diffuse sky ambient on an upward surface, linear RGB -// wetness 0..1, how soaked surfaces are right now; darkens bark and soil -// -// Everything marked EYEBALLED is a tuned constant, not a measurement. Change it -// by looking at the result. Sun position follows the low-precision NOAA/Meeus -// series, good to about a hundredth of a degree for 1900..2100. - -const RAD = Math.PI / 180; -const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); -const smooth = (a, b, x) => { - const t = clamp((x - a) / (b - a), 0, 1); - return t * t * (3 - 2 * t); -}; -const mix = (a, b, t) => a + (b - a) * t; - -const toMs = (time) => (time instanceof Date ? time.getTime() : typeof time === 'string' ? Date.parse(time) : Number(time)); - -/** Sun elevation and azimuth in degrees at a UTC moment and place. Azimuth runs clockwise from north. */ -export function sunPosition(time, lat, lon) { - const d = toMs(time) / 86400000 - 10957.5; // days since J2000.0 (2000-01-01 12:00 UTC) - const L = (280.46 + 0.9856474 * d) * RAD; // mean longitude - const g = (357.528 + 0.9856003 * d) * RAD; // mean anomaly - const lambda = L + (1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * RAD; // ecliptic longitude - const eps = (23.439 - 0.0000004 * d) * RAD; // obliquity - const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)); - const ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)); - const gmst = (280.46061837 + 360.98564736629 * d) * RAD; - const hourAngle = gmst + lon * RAD - ra; - const phi = lat * RAD; - const sinEl = Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(hourAngle); - // Hour angle is westward-positive, so atan2 gives azimuth from south, westward; +180 turns it into - // clockwise from north. - const south = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)); - return { - elevation: Math.asin(clamp(sinEl, -1, 1)) / RAD, - azimuth: (((south / RAD + 180) % 360) + 360) % 360, - }; -} - -/** Sun elevation in degrees at a UTC moment and place. */ -export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elevation; - -// Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). -// Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep -// low sun orange rather than brown). -export const TAU = [0.06, 0.12, 0.26]; - -// Relative air mass, Kasten & Young 1989. Elevation in degrees, >= 0. -const airMass = (el) => 1 / (Math.sin(el * RAD) + 0.50572 * Math.pow(el + 6.07995, -1.6364)); - -// Zenith sky colour and brightness. EYEBALLED. -const SKY_DAY = [0.22, 0.42, 0.85]; // blue, linear -const SKY_DAY_LEVEL = 0.3; // clear-noon ambient relative to direct = 1 -const SKY_TWILIGHT = [0.5, 0.35, 0.55]; // warm violet at the horizon (weight 0.05 below) -const SKY_NIGHT = [0.004, 0.006, 0.015]; // starlight and airglow floor - -const GREY_TINT = [0.95, 1, 1.08]; // cool overcast. EYEBALLED -const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - -export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { - const { elevation, azimuth } = sunPosition(time, lat, lon); - const cloud = clamp(cloud_cover, 0, 1); - const rain = Math.max(0, precipitation); - - // Rain implies cloud: even a reported 0 % cover goes grey once it is falling. EYEBALLED. - const overcast = Math.max(cloud, smooth(0, 1, rain) * 0.85); - - // Direct sun: attenuated per channel along the air mass, off at the horizon. - // Disc fully gone at -0.83 deg (centre below the upper limb); soft to 1.5 deg. EYEBALLED fade. - const above = smooth(-0.83, 1.5, elevation); - const m = elevation > 0 ? airMass(elevation) : airMass(0); - const clearDirect = TAU.map((tau) => Math.exp(-tau * m) * above); - // Cloud takes the sun away fast: 50 % cover leaves about a third. EYEBALLED. - const directKeep = Math.pow(1 - overcast, 1.6); - const direct = clearDirect.map((c) => c * directKeep); - - // Sky ambient: blue by day, a violet band around sunset, a floor at night. - const dayAmount = smooth(-6, 10, elevation); - const twilight = smooth(-18, -2, elevation) * (1 - smooth(-2, 8, elevation)); - const clearSky = SKY_NIGHT.map((night, i) => night + SKY_DAY[i] * SKY_DAY_LEVEL * dayAmount + SKY_TWILIGHT[i] * 0.05 * twilight); - - // Cloud: part of the sun's lost light is scattered into the sky (EYEBALLED 0.3), - // and the colour drifts to a cool grey (EYEBALLED tint and 0.7 pull). - const lostLuma = luma(clearDirect) * (1 - directKeep); - const scattered = clearSky.map((c) => c + lostLuma * 0.3); - const grey = luma(scattered); - const rainDim = mix(1, 0.8, smooth(0, 4, rain)); // heavy rain darkens the sky. EYEBALLED - const diffuse = scattered.map((c, i) => mix(c, grey * GREY_TINT[i], overcast * 0.7) * rainDim); - - // Surfaces take time to dry, but this is a point sample of the feed: - // 0 dry, ~0.63 at 1 mm/h, ~0.98 at 4 mm/h. EYEBALLED. - const wetness = 1 - Math.exp(-rain); - - return { elevation, azimuth, direct, diffuse, wetness }; -} diff --git a/src/sky/sun.node-test.js b/src/sky/sun.node-test.js deleted file mode 100644 index 20e1624c..00000000 --- a/src/sky/sun.node-test.js +++ /dev/null @@ -1,62 +0,0 @@ -// Fixed-moment checks for sun.js. Plain node, no framework dependency: -// node --test sun.node-test.js (run from a copy next to sun.js) -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { light, sunElevation } from './sun.js'; - -const near = (a, b, tol, msg) => assert.ok(Math.abs(a - b) <= tol, `${msg}: ${a} vs ${b} (tol ${tol})`); -const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; - -// Greenwich, equator: sun overhead within a degree or two at equinox solar noon. -test('equinox noon on the equator: sun near the zenith, white-ish and bright', () => { - const l = light({ time: '2026-03-20T12:00:00Z', lat: 0, lon: 0 }); - assert.ok(l.elevation > 85, `elevation ${l.elevation}`); - assert.ok(l.direct[0] > 0.7 && l.direct[0] <= 1, `direct ${l.direct}`); - assert.ok(l.direct[0] > l.direct[1] && l.direct[1] > l.direct[2], 'sun is warmer than white'); - assert.ok(l.diffuse[2] > l.diffuse[0], 'sky is blue'); - assert.equal(l.wetness, 0); -}); - -test('midsummer noon at Berlin: Meeus elevation 90 - 52.52 + 23.44 ~= 60.9', () => { - near(sunElevation('2026-06-21T11:07:00Z', 52.52, 13.405), 60.9, 0.5, 'elevation'); -}); - -test('midnight in Berlin in December: sun far below, direct is zero, sky at floor', () => { - const l = light({ time: '2026-12-21T23:30:00Z', lat: 52.52, lon: 13.405 }); - assert.ok(l.elevation < -50, `elevation ${l.elevation}`); - assert.deepEqual(l.direct, [0, 0, 0]); - assert.ok(luma(l.diffuse) < 0.02, `diffuse ${l.diffuse}`); - assert.ok(luma(l.diffuse) > 0, 'night is not pure black'); -}); - -test('equinox sunrise on the equator at lon 0: ~06:00 UTC, sun on the horizon, red-orange and dim', () => { - const l = light({ time: '2026-03-20T06:00:00Z', lat: 0, lon: 0 }); - near(l.elevation, 0, 2, 'elevation'); - const lo = light({ time: '2026-03-20T06:10:00Z', lat: 0, lon: 0 }); - assert.ok(lo.elevation > 0.5, `elevation ${lo.elevation}`); - assert.ok(lo.direct[0] > 5 * lo.direct[2], `reddened ${lo.direct}`); - assert.ok(lo.direct[0] < 0.5, `dim ${lo.direct}`); -}); - -test('cloud shrinks direct, grey-cools diffuse; rain is darker and wet', () => { - const base = { time: '2026-06-21T11:07:00Z', lat: 52.52, lon: 13.405 }; - const clear = light(base); - const cloudy = light({ ...base, cloud_cover: 1 }); - const rainy = light({ ...base, cloud_cover: 1, precipitation: 4 }); - assert.ok(luma(cloudy.direct) < 0.1 * luma(clear.direct)); - assert.ok(luma(cloudy.diffuse) > 0.5 * luma(clear.diffuse)); - const blueness = (c) => c[2] / c[0]; - assert.ok(blueness(cloudy.diffuse) < blueness(clear.diffuse), 'greyer'); - assert.ok(luma(rainy.diffuse) < luma(cloudy.diffuse), 'rain darker'); - assert.ok(rainy.wetness > 0.95 && cloudy.wetness === 0); - assert.ok(light({ ...base, precipitation: 1 }).wetness > 0.6, 'rain with 0 cover still wet'); -}); - -test('every channel is finite and non-negative across a day and bad inputs', () => { - for (let h = 0; h < 24; h += 0.5) { - for (const cc of [0, 0.5, 1, 7, -1]) { - const l = light({ time: Date.UTC(2026, 10, 6, 0, 0) + h * 3600000, lat: 52.5, lon: 13.4, cloud_cover: cc, precipitation: cc > 0 ? 2 : 0 }); - for (const v of [...l.direct, ...l.diffuse, l.wetness]) assert.ok(Number.isFinite(v) && v >= 0, `${h} ${cc} ${v}`); - } - } -}); diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 36c1e21b..98715996 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,6 +3,8 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', + CinnySpaces = 'in.cinny.spaces', + ElementRecentEmoji = 'io.element.recent_emoji', PoniesUserEmotes = 'im.ponies.user_emotes', diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 8d6b89dd..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -23,7 +23,6 @@ export enum StateEvent { RoomMember = 'm.room.member', RoomThirdPartyInvite = 'm.room.third_party_invite', RoomPowerLevels = 'm.room.power_levels', - GameCultNotice = 'org.gamecult.notice', RoomName = 'm.room.name', RoomTopic = 'm.room.topic', RoomAvatar = 'm.room.avatar', diff --git a/vite.config.js b/vite.config.js index 891c8216..a5af94d8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -42,10 +42,6 @@ const copyFiles = { src: 'public/locales', dest: 'public/', }, - { - src: 'public/poses', - dest: 'public/', - }, ], };