Compare commits

..
Author SHA1 Message Date
77e95c2826 Merge pull request 'Release candidate: dev c2cdde1e into main' (#166) from dev into main 2026-11-14 12:59:10 +00:00
Void
c2cdde1ee7 Idunn recipe: pin dist b30ddac0 (ridge plane rebake, #165)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m47s
Build pull request / Build pull request (pull_request) Successful in 4m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 12:55:17 +00:00
b30ddac052 Ridge baker: near plane opaque to the horizon, occlusion-matched far planes, near-cover check; rebake Vienna (#165)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-14 12:52:20 +00:00
786b41ddf1 Merge pull request 'Release candidate: dev eefaa871 into main' (#164) from dev into main 2026-11-14 11:33:46 +00:00
Void
eefaa8716d Idunn recipe: pin dist 5b7b4c2e (ridge backdrop A+D, #163)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m27s
Build pull request / Build pull request (pull_request) Successful in 4m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 11:19:59 +00:00
5b7b4c2eeb Ridge backdrop A+D: baked ridge planes over the live sky (Vienna) (#163)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m6s
2026-11-14 11:07:58 +00:00
39c9924042 Merge pull request 'Release candidate: dev c6fb57d9 into main' (#146) from dev into main 2026-11-14 08:09:08 +00:00
Void
c6fb57d9f9 Idunn recipe: correct the config.json comment (the carried file is aliased over the tree's)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 5m2s
Build pull request / Build pull request (pull_request) Successful in 4m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 07:59:09 +00:00
Void
5310f606cd Idunn recipe: Ratatoskr static release from the pinned dist tarball (934f8238)
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Rat dev preview / Build dev preview (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 07:55:39 +00:00
934f82386d Merge pull request 'Scene sky-change test: stay inside the timeout under load (bake each sky once)' (#162) from void/scene-test-flake into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m57s
Build pull request / Build pull request (pull_request) Successful in 3m9s
2026-11-14 02:47:33 +00:00
Void
a61252e664 Scene test: pin the stand-down of a replaced rebuild deterministically
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:32:31 +00:00
Void
1b101c2889 Scene sky-change sweep: bake each state's sky once so the test stays inside its timeout under load
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:27:25 +00:00
b13080ebd2 Merge pull request 'Files desk: name loose files the album list cannot show; pin the old publisher wire shape' (#161) from void/files-desk-loose-files into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 02:18:49 +00:00
Void
ed2205d6d6 Loose-files note: gating is a tested function; a redaction refreshes it
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:18:47 +00:00
Void
33e31a6102 Files desk: name loose files the album list cannot show; pin the old publisher wire shape
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:14:21 +00:00
d5b9e33536 Merge pull request 'docs: harvest physical-room row and sources for the 41 s and degraded-score claims' (#160) from void/harvest-physical-room into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 01:54:37 +00:00
Void
6b40809f9d docs: harvest polarity row loses the shotgun claim; physical room run row; sources for the 41 s and degraded-score claims
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:54:29 +00:00
38b2c064b2 Merge pull request 'Upload failures tell the sender a reason and a next step' (#159) from void/upload-failure-reasons into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 01:53:57 +00:00
Void
ec2637a2bb Upload reasons: offline and background-tab only explain a dropped request; Details lays out in the card
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:53:47 +00:00
Void
c36b6e2cd9 Upload failures tell the sender a reason and a next step; diagnostics behind Details
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
uploadFailure attaches what the client observed (unreadable, offline, hidden tab, aborted,
progressed, stopped) and keeps a homeserver errcode; uploadReason turns that into a cause and an
action. The upload cards and the share panel's refusal list use it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:52:06 +00:00
bc097d0490 Merge pull request 'docs: Kiko's corrections to the symphony grammar harvest' (#158) from void/harvest-corrections into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 01:47:42 +00:00
398b172e21 Merge pull request 'Files desk: refuse files over the server's upload cap before sending' (#157) from void/files-desk-cut5 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-14 01:47:41 +00:00
Void
907635917e Size refusal: a failed run still names the refused files; a cap that is not a positive number is no cap
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m59s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:47:07 +00:00
Void
d3894f5b51 docs: apply Kiko's corrections to the symphony grammar harvest
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:46:39 +00:00
Void
65d7e7a46a Files desk: refuse files over the server's upload cap before sending
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
A file larger than m.upload.size is named in the note and dropped from the pick before any
upload or album root; a pick of only such files sends nothing. No stated cap or no file size
refuses nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:45:42 +00:00
da822e9e4e Merge pull request 'Phone-hit guard: no blind spots for aliased, namespace and non-tsx IconButton; scope-aware PhoneHit check' (#156) from void/phone-hit-guard-hardening into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 5m5s
Build pull request / Build pull request (pull_request) Successful in 5m12s
2026-11-13 23:46:37 +00:00
Void
0271d5f692 Phone hit guard: see every spelling of IconButton, read scope and the AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m54s
The coverage guard now scans every script file under src, resolves aliased and
namespace-member IconButton tags, and fails loudly on any other mention
(as={IconButton}, rebinding, re-export, destructuring, element access). A
PhoneHit that an inner scope rebinds, or that is imported as a type, no longer
counts. drawnPx/sizeProp read the parsed tag, not its text. The emoji Sidebar
column's 4 px gap is pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:34:49 +00:00
08be0a792d Merge pull request 'Desk: one owner for the open count, derived row fold state, guest and settled fixtures' (#155) from void/desk-slice-1-findings into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:33:40 +00:00
Void
6a209649dc Desk Soul fixes: memoize the navigator's list so the hash check runs once; a check that cannot run withdraws every card; pin the navigator's verified count
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:33:39 +00:00
Void
b8741f9dcc Desk: one verified count, rotation-safe row fold, fixtures for guest and settled
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m35s
The navigator's 'Desk · N open' counted cards the desk withdraws on an
options_hash mismatch. buildDesk now owns the count (open_count less the
withdrawn open cards) and the navigator reads it through the same
useUnverified check the page uses; before the check lands it shows no count.
A row's fold is derived from the screen size and a tap, so rotation cannot
leave it stale. Adds guest and settled-while-answered fixtures and tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:29:06 +00:00
0b3e2294ca Merge pull request 'docs: Mimir bioacoustic research harvest for the symphony grammar' (#154) from void/symphony-grammar-harvest into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:28:25 +00:00
Void
d42584cd72 docs: harvest of Mimir's bioacoustic research for Aqua's activity grammar
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m17s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:28:20 +00:00
f3d0ebdb06 Merge pull request 'Media row reads room ids from @rooms.file_zones' (#153) from void/stewardships-rooms-key into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:25:20 +00:00
Void
cf1ecd64da Media row reads the room id from @rooms.file_zones, the shape provisioning writes
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m42s
The provisional media[<mind>] key was never written by anything; gamecult-ops #111 records ids under @rooms. The old key is no longer read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:24:00 +00:00
ca3046e662 Merge pull request 'docs: Files desk status after cuts 1-4 and Stop' (#152) from void/files-desk-doc-status into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:18:19 +00:00
Void
817f2e46d6 docs: Files desk status names what is built and that the Commons row is not
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:18:17 +00:00
a078346ecc Merge pull request 'Files desk: Stop a send in flight, show replaces loops, freshest last event on add' (#151) from void/files-desk-send-cancel into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:17:42 +00:00
Void
c98d0175ff Soul fixes: sent-but-unechoed events count for the freshest last event; pre-aborted send starts no upload
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:17:40 +00:00
Void
e406ec7dc4 Files desk: Stop a send in flight; files in a replaces loop all show; Add files threads after the freshest event
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Stop aborts the running upload (onPromise + mx.cancelUpload) and leaves it and the rest picked as 'stopped'. foldVersions shows files caught in a replaces loop with no head instead of hiding them. An add reads the room timeline at send time for the newest event in the thread.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:14:42 +00:00
d12e7bd578 Merge pull request 'Files desk cut 4: add files to an open album, named upload refusals' (#150) from void/files-desk-cut4 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:11:35 +00:00
Void
90c6d5e278 Cut 4: pin that ShareSet sends into an existing album
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:11:33 +00:00
Void
0622d7d65d Files desk cut 4: add files to an open album; name the upload refusals
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
sendShareSet takes a title or an existing album (no new root; the first file threads after the album's newest event). Failed files carry the refusal for M_TOO_LARGE, M_FORBIDDEN and M_LIMIT_EXCEEDED in the result note. AlbumView mounts an Add files panel inside an open album.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:05:37 +00:00
f491df04f9 Merge pull request 'Files desk cut 3: versions via org.gamecult.publish.replaces' (#149) from void/files-desk-cut3 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:03:48 +00:00
Void
5835e565cd Cut 3 Soul fixes: replaces counts only from the same sender; tests for the loop guard and the cover
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 6m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:03:33 +00:00
Void
796bc161a9 Cut 3: doc marks versions done
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:00:10 +00:00
Void
ac6018b57c Files desk cut 3: show the latest version of a file chain, earlier versions folded under it
A file event naming org.gamecult.publish.replaces supersedes the event it names. foldVersions reads the chain from the events alone; buildAlbums lists heads (count too) and AlbumView unfolds 'N earlier versions'. Both events stay ordinary files for other clients.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:59:52 +00:00
143bcc2dc6 Merge pull request 'Files desk cut 2: pagination in useAlbums, ShareSet folder test' (#148) from void/files-desk-cut2 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 22:59:50 +00:00
Void
6aaee8badd Cut 2: doc names the page cap; truncation note says albums or files
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:59:33 +00:00
Void
01dfb155c5 Files desk cut 2: read every page of threads and replies; pin folder -> album title in ShareSet
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
useAlbums followed no next_batch, so a zone past 100 albums or 100 files per album was silently cut. readPages follows the token up to 20 pages per endpoint and says when the cap stopped it; AlbumView shows a dim note then.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:55:30 +00:00
cf8b16ff5d Merge pull request 'docs: Commons and per-Persona Files desk' (#142) from void/commons-files-desk into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 22:47:34 +00:00
Void
036146ce40 docs: fold Eureka's answers into the Files desk doc (room, cap, poster, final marker, Range unverified)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:47:25 +00:00
bb6155709a Merge pull request 'Files desk cut 1: folders by album title path, share into the current folder' (#147) from void/files-desk-view into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m34s
Build pull request / Build pull request (pull_request) Successful in 4m31s
2026-11-13 21:46:36 +00:00
Void
b8eed50bd7 Soul fixes: a folder that empties keeps its Back row, blank titles get a label
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 21:46:28 +00:00
Void
53a724ae27 Files desk cut 1 under Matrix authority: folders by album title path, share into the current folder
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Albums titled a/b/c group into folders in AlbumView; ShareSet prefixes the
current folder onto the album title it sends. No wire change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 21:45:00 +00:00
229591e22c Merge pull request 'Soft logout keeps local data; MAS front hides register and reset' (#145) from void/mas-client-fixes into dev
Some checks failed
Rat dev preview / Build dev preview (push) Successful in 3m39s
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 19:32:10 +00:00
Void
cd30c3dc40 wipeLocalData test: lint-clean never-settling store
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:13:14 +00:00
Void
8fb95402f5 wipeLocalData: bounded clearStores, storage clear that cannot reject; tests that bite
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:13:04 +00:00
Void
31d58075db Second Soul pass: soft event without a session wipes, wipe never rejects, rendered MAS tests
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
wipeLocalData moves to its own module with injectable ports: per-database failures are contained and localStorage clears in a finally. A soft logout with no session to remember is treated as hard. Login/PasswordLoginForm/ResetPassword MAS behavior is now tested by rendering. Login and register completion settle once per response.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:56:08 +00:00
Void
3a5258c3ef Soul fixes: idempotent logout handler, keep register consent entry under MAS, settle stores on register
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
First SessionLoggedOut decides; later events are ignored so a second soft event cannot fall into the wipe. Under MAS a configured registrationEntry (with its training notice) is kept; the MAS notice replaces only the in-client form and bare link. Registration goes through the same keep-or-wipe settle as login. Soft record drops its unused baseUrl. One shared https-or-localhost predicate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:40:59 +00:00
Void
4fd83fb4c4 Soft logout keeps local data and re-logs in on the same device; MAS front hides register and reset
Soft (soft_logout === true) drops only the access token and records the device; sign-in is fixed to that user and sends device_id; same user and device keeps the stores, anything else wipes via the one wipeLocalData. Hard logout still wipes. When discovery advertises an msc2965 account or issuer, the register and forgot-password entries give way to a notice pointing at the account page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:36:51 +00:00
dc9a541820 Merge pull request 'Phone glyphs: bigger icons in IconButtons and nav chevrons on phones' (#144) from void/phone-glyphs into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m49s
2026-11-13 18:24:24 +00:00
Void
9d65a34507 phoneGlyph test: pin each Icon size's px to folds' own CSS
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:20:12 +00:00
Void
9db91378c5 Phone glyphs: icons inside IconButtons and the nav chevron draw larger on phones, box unchanged
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m0s
folds draws a glyph at 16-24 px; on a phone the 16/18/20 px ones read small in their buttons. One global
rule per folds Icon size scales the glyph (22/24/24/26/28 px) at paint time inside an IconButton, so no
button, PhoneHit area or row changes size and desktop is untouched. phoneGlyph.test.ts pins the folds
class names, the px range and that the rule is a phone-only scale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 17:52:10 +00:00
Void
1455988a83 Merge dev 8ba407cd into main
No content change: tree equals dev's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 15:48:20 +00:00
Void
a8e3acbde0 docs: Files desk decided as Matrix-only (A); Eureka's notes folded in; cut 1 recorded
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 15:46:23 +00:00
Void
0ab185bb57 docs: Files desk as a comparison, Matrix as the single authority (A) against the host service (B); publisher rule
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 14:48:01 +00:00
Void
a8fc78ff55 docs: Files desk upload flow (approved for members and Minds), codes and caps as placeholders
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 14:31:47 +00:00
Void
88fd0a889a docs: Commons and per-Persona Files desk, design only
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 14:30:46 +00:00
8ba407cd2a Merge pull request 'Phone hit: repo-wide guard for bare IconButtons, PhoneHitInPlace for preview arrows, drop dead Editor.preview' (#141) from void/phone-hit-guard into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m39s
2026-11-13 13:51:43 +00:00
Void
d5a0c1b4ff Phone hit: a repo-wide guard for bare IconButtons, PhoneHitInPlace for the preview arrows, drop the dead Editor.preview
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m22s
phoneHit.coverage.test.ts walks every .tsx under src/app and fails on any IconButton without a PhoneHit class
unless it is named with its reason (EstateNavigator Find a room, LobbyHeader Members). UrlPreviewCard's two
absolute arrows take PhoneHitInPlace, which adds the hit area without PhoneHit's position: relative.
Editor.preview.tsx was imported nowhere and held 11 bare IconButtons.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 13:46:44 +00:00
ad9172a311 Merge pull request 'Phone hit: page back buttons (group 3)' (#133) from void/phone-hit-pages into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m11s
2026-11-13 13:43:43 +00:00
4e6b4f0c90 Merge branch 'dev' into void/phone-hit-pages
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m12s
2026-11-13 13:34:34 +00:00
1a3eedf6cb Merge pull request 'Phone hit: editor toolbar, emoji sidebar, upload cards, client root' (#139) from void/phone-hit-editor into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m43s
2026-11-13 13:34:32 +00:00
9153ee945d Merge branch 'dev' into void/phone-hit-editor
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m57s
2026-11-13 13:26:44 +00:00
d5ad73fa5e Merge pull request 'Phone hit: message content and data editors (group 10)' (#138) from void/phone-hit-content into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m43s
2026-11-13 13:26:43 +00:00
4c16e51559 Merge branch 'dev' into void/phone-hit-content
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m11s
2026-11-13 12:58:51 +00:00
f0c8dc2122 Merge pull request 'Phone hit: room/space settings, lobby, message search, explore (group 7)' (#137) from void/phone-hit-room-space into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m12s
2026-11-13 12:58:50 +00:00
d6a6e300a4 Merge pull request 'Phone hit: settings B, auth and verification icon buttons' (#136) from void/phone-hit-settings-b into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m40s
2026-11-13 12:49:59 +00:00
91993ace99 Merge branch 'dev' into void/phone-hit-room-space
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
2026-11-13 12:49:59 +00:00
145e268894 Merge branch 'dev' into void/phone-hit-settings-b
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m50s
2026-11-13 12:42:11 +00:00
8d25db2775 Merge pull request 'Phone hit: viewer headers and UIA stages (group 9)' (#135) from void/phone-hit-viewers into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m13s
2026-11-13 12:42:10 +00:00
6c395d7aef Merge branch 'dev' into void/phone-hit-viewers
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m10s
2026-11-13 12:11:44 +00:00
a7ac67683f Merge pull request 'Phone hit: common-settings IconButtons (group 6)' (#134) from void/phone-hit-common-settings into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m58s
2026-11-13 12:11:43 +00:00
8a52fa9353 Merge branch 'dev' into void/phone-hit-common-settings
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m46s
2026-11-13 10:16:51 +00:00
32b95fb60b Merge pull request 'Phone hit: dialogs and prompts (group 8)' (#132) from void/phone-hit-dialogs into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m34s
2026-11-13 10:16:50 +00:00
Void
ff1a29dbec Merge dev into phone-hit-pages (kit: dev's AST reader already accepts null entries)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 10:15:31 +00:00
2c39f89fe4 Merge branch 'dev' into void/phone-hit-dialogs
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
2026-11-13 09:31:02 +00:00
fd2626d366 Merge pull request 'Phone hit: settings A icon buttons (group 4)' (#131) from void/phone-hit-settings-a into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m37s
2026-11-13 09:31:00 +00:00
09e85fad02 Merge branch 'dev' into void/phone-hit-settings-a
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
2026-11-13 09:05:41 +00:00
b2a970f987 Merge pull request 'Phone hit: call controls (audit group 1)' (#130) from void/phone-hit-call into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m7s
2026-11-13 09:05:39 +00:00
45939d544b Merge branch 'dev' into void/phone-hit-call
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m23s
2026-11-13 08:28:38 +00:00
0f7faa902c Merge pull request 'Phone hit kit: pin the className expression, not its source text' (#140) from void/phone-hit-kit-strict into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m48s
2026-11-13 08:28:35 +00:00
Void
4eaa6f9c55 Phone hit kit: read IconButton className off the TypeScript AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
className must appear once, with no spread after it, as the imported PhoneHit*
identifier bare, a classNames/clsx/cx direct argument, or a template span, and
the identifier must occur nowhere else in the expression. Tag enumeration uses
the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as
the phone-hit-pages branch does, and is exercised by a test on a temp source.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:28:37 +00:00
Void
761d13fac5 Phone hit editor tests: pin estate Targets nesting and selector, ClientRoot inline absolute, sidebar gap
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:21:57 +00:00
Void
d13cbc9b2e Phone hit kit: read the className expression, not its source text
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
A PhoneHit name inside a string, built by concatenation, behind ?:/&&/||/??, or
followed by a props spread that can override className no longer satisfies the pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:21:00 +00:00
Void
da05210db2 Phone hit: editor toolbar, emoji sidebar, upload cards, client root
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:46 +00:00
Void
a0d047c139 Phone hit: message content and data editor icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:41 +00:00
Void
84ee800e3f Phone hit: room/space settings, lobby, message search, explore icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:31 +00:00
Void
32dc46c21f Phone hit: PhoneHit on the settings B, auth and verification icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:11 +00:00
Void
200cf8fdb8 Phone hit: viewer headers and UIA stages
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m58s
Back/close buttons beside non-tappable titles take PhoneHit; the zoom out/in
buttons beside the zoom Chip take PhoneHitSpread24. Test pins each variant and
ties the sized ones to the drawn size.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:07 +00:00
Void
e209688de1 Phone hit: common-settings IconButtons get PhoneHit, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:02 +00:00
Void
cd90a044ed Phone hit: page back buttons and Lobby/Notifications lone buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m59s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:02 +00:00
Void
0b40f4c66f Phone hit: PhoneHit on the ten dialog and prompt close buttons, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:44 +00:00
Void
b8bef8c175 Phone hit: settings A icon buttons get PhoneHit, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:25 +00:00
Void
351d1bc745 Phone hit: call controls get their hit-area variants, with a test pinning them
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:10 +00:00
54ac1ba480 Merge pull request 'Phone hit: Spread28/36, Room36, shared test kit and size-tie check' (#129) from void/phone-hit-foundation into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m56s
2026-11-13 07:13:33 +00:00
Void
fe12d8a4b1 Phone hit kit: pin Room composition, spread props are undetermined, tighter tag match
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m4s
Room<N> throws when no Spread of its size exists, and a test reads the compiled class lists to prove Room<N>
composes Spread<N> and PhoneHit. Props spread into an IconButton or Icon make the size undetermined (allow-list
entry required). <IconButton and <Icon match only before whitespace, > or /. iconButtonTags derives from iconButtons.
expectSizeTie fails on a file with no IconButton. Fixture tests live in phoneHitTestKit.test.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:05:58 +00:00
b77efddcdc Merge pull request 'buildSha: an explicit BUILD_SHA no longer masks a dirty tree; temp-file exclusion is untracked-only' (#128) from void/build-sha-untracked-filter into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m39s
2026-11-13 07:02:44 +00:00
Void
d917ccd3bb Phone hit tests: shared kit, size tie, pinned new variants
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m11s
The helpers moved to phoneHitTestKit.ts so each later group adds its own phoneHit.<group>.test.ts. A new
check ties PhoneHitSpread<N>/Room<N> to the px the button draws (IconButton size padding + Icon size); a size the
source does not state needs an explicit allow-list entry. It found RoomInput's reply-cancel (16 px icon, 24 px
button) carrying Spread32; it is Spread24 now. Room<N> takes its inline margin from the Spread class of the same
size, so the two axes cannot disagree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:59:50 +00:00
Void
2b019a4302 Phone hit: Spread28, Spread36 and Room36 variants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:58:48 +00:00
Void
eff894a89c docs: BUILD_SHA also stands where git cannot be read
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:57:45 +00:00
Void
c82193f0fb buildSha: git speaks only for a tree it roots, mismatch and git failure fail closed, temp-file name matched exactly
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul found: explicit sha stamped clean over a different HEAD; status failure returned the bare explicit
sha; an archive inside another repo got that repo's verdict; the temp regex let any untracked .mjs pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:56:27 +00:00
Void
c45cdc7bd7 buildSha: BUILD_SHA no longer masks a dirty tree; the vite temp-file exclusion is untracked-only
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m24s
The pathspec exclude hid a tracked file of that name; the status lines are filtered instead.
Tests against a real repo: explicit sha plus stray file, stray file beside the temp file in root and
subfolder, tracked file of the temp name, new folder. Six mutants of the logic each fail a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:52:32 +00:00
14ee4bee03 Merge pull request 'Phone: 44 px hit areas for the chat path's remaining icon buttons, and tests that pin them' (#127) from void/phone-hit-areas-2 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m38s
2026-11-13 05:50:20 +00:00
Void
0c8474231e phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m1s
Soul re-gate of 446306b1: swapped Spread24/26 definitions, display:none or overflow on the ::after, position:static on a
Spread rule and an aliased import all passed; all killed now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:50:10 +00:00
Void
446306b1e7 PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul pass on fc92809e: RoomNavItem's buttons are 24 px, not 32 (36 px pitch, hit areas overlapped); the unpin button is 26 px
beside the Open chip. Six mutants survived (position: relative, pointer-events, z-index, title for className, aliased
import); all now killed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:46:21 +00:00
Void
fc92809eb9 Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
MessageEditor, RoomNavItem, MembersDrawer, ReactionViewer, RoomPinMenu, RoomViewTyping, CallChatView and JumpToTime
get PhoneHit (alone) or PhoneHitSpread32 (two neighbours in a row). phoneTapTargets.test.ts now lists them, plus
PasswordInput, Desk, SkyStateControl and PasswordLoginForm, which had no test, checks each class is imported from
styles/PhoneHit.css, and pins the ::after content (a mutant without it passed before).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:40:21 +00:00
2aa468c36a Merge pull request 'Consumer-side tests for #123: RoomOutbox as rendered; hung send to watchdog reload to resend' (#124) from void/stall-consumer-tests into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m18s
2026-11-13 04:11:32 +00:00
67f1267653 Merge pull request 'Phone: 44 px hit areas for icon buttons, reaction chips and short sender names' (#125) from void/phone-hit-areas into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-13 04:11:26 +00:00
Void
ae3760fcc7 RoomOutbox actions test: Resend keeps the entry in the outbox, pending again
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:11:15 +00:00
Void
bd2d980023 phoneTapTargets: pin top, left and transform of the hit-area box
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:10:46 +00:00
Void
da9ef70c03 Consumer tests: startClient wiring, Resend/Discard handlers, the 30 s slow-send threshold
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:04:45 +00:00
Void
0e3115809e phoneTapTargets: pin the exact PhoneHit variant of each IconButton
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:02:30 +00:00
Void
e04c7c074f stallRecovery test: a held SDK echo makes the resend use a fresh txn id
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
fab6a04894 stallRecovery test: lint clean
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
09b062d7be stallRecovery test: distinct txn ids per send; pin visible/online guards and own-tab pending filter; fix TS2698
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
9d1d39922c stallRecovery test: lint clean 2026-11-13 04:01:36 +00:00
Void
76c1f56bd8 stallRecovery test: lint 2026-11-13 04:01:36 +00:00
Void
2a1f2c6b17 Consumer-side tests: RoomOutbox as rendered; hung send to watchdog reload to resend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
21ff167177 phoneTapTargets: every IconButton in Message, RoomInput, RoomViewHeader carries a PhoneHit class
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
ee55c32214 Phone: quick-reaction emoji buttons and delete/report dialog close buttons get a 44 px hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
856096e215 Desk back button: phone hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
f7395f12e3 Phone: icon buttons, reaction chips and short sender names get a 44 px hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
95f79a0517 Merge pull request 'Build stamps its git sha into index.html (build-sha meta tag)' (#126) from void/build-sha-stamp into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-13 04:01:23 +00:00
Void
917f9a0da2 buildSha test: remove the temp repos after each test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:48:22 +00:00
Void
46e96500e4 buildSha: ignore vite's temp config file, run git in the repo dir
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:20:59 +00:00
Void
ccb7a8ebb6 Build stamps its git sha into index.html as a build-sha meta tag
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 01:40:05 +00:00
b6b1d522ab Merge pull request 'client: unconfirmed sends are visible and recovered; watchdog for a dead connection' (#123) from void/resume-reload into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m43s
2026-11-12 21:35:47 +00:00
Void
bb6391a70b initMatrix: resolve sessionStorage inside the guard's try; ownPendingAges tested
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:35:46 +00:00
Void
69d752ff0b watchdog: send stall only, own-page entries, online only; resume sync nudges and never restarts
Soul pass on 77edebff: the watchdog no longer counts interrupted entries from
a previous page (reload loop at startup), no longer reloads on sync ERROR/STOPPED
(js-sdk recovers offline by itself) or while offline, refuses instead of
throwing when sessionStorage is unusable, startClient is idempotent, and the
slow-send label says Resend may duplicate. resumeSync drops its health check
and recover hook: nudge only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:34:21 +00:00
Void
77edebff9c client: watchdog, resend on reload, outbox for every send, no in-place client restart
Sends have no timeout and sync polls do, so a dead connection leaves a send
SENDING forever with no request reaching the server while its echo looked
delivered. A watchdog (send pending past 20 s, sync ERROR/STOPPED for 30 s)
calls a guarded reload; what the reload interrupts is sent again under its
own txn id. Uploads and stickers go through the outbox. Resend takes a fresh
txn id while the SDK still holds the failed echo. The resume-sync recovery no
longer calls stopClient/startClient, which closes the rust crypto for good
("null pointer passed to rust" on later encrypted sends).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:31:29 +00:00
Void
a92f6c4294 timeline: hide unconfirmed local echoes; the outbox is the one place they show
The SDK puts a local echo in the live timeline on sendMessage and marks it
NOT_SENT on failure; the timeline rendered it like any delivered message and a
reload dropped it, so a send killed with the page looked sent and vanished.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:25:50 +00:00
92a98664c9 Merge pull request 'composer: persist the draft on input, keep unconfirmed sends in a durable outbox' (#122) from void/draft-persist into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 21:17:30 +00:00
Void
a531976675 lint: import order, explicit no-op test stubs
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:17:26 +00:00
Void
350ec987e0 outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul pass findings: a failed localStorage write no longer loses the entry
(memory copy wins until a write succeeds), a stale draft is dropped when
its rewrite fails, storage reads/removes cannot throw, and pending entries
older than the page load read as interrupted with Resend available.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:17:06 +00:00
Void
7316c92ff3 composer: persist the draft on input, keep unconfirmed sends in a durable outbox
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
The draft lived in a memory-only jotai atom written only when RoomInput
unmounted, so a reload lost it. A sent message was cleared from the editor
at once and held nowhere durable. The draft is now written to localStorage
(debounced, flushed on pagehide/hidden) and restored on load; a send stays
in a per-account outbox under its txn id until the server confirms, shown
above the composer with Resend and Discard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:14:16 +00:00
a8d39ee917 Merge pull request 'pwa: installable manifest and Web Push stages 0-1 (dark by default)' (#121) from void/pwa-push-stage-0-1 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m46s
2026-11-12 20:05:41 +00:00
Void
695a016d4f push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Soul pass findings on the stage 0-1 branch: the worker stored the token on every deployment, push configured or not
(now only while the browser holds a push subscription; the page re-sends its session after the toggle changes it);
the toggle read the browser's subscription, not the account's pusher (now both); a subscription under an old VAPID
key was reused (now replaced); a failing client list now still shows the notification.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 20:05:10 +00:00
Void
64a2656ad7 pwa: installable manifest fixes and Web Push stage 0-1 (worker handlers, session in IndexedDB, settings toggle, dark config block)
Stage 0: manifest gains id "/" (what Chrome already derives from start_url), scope, a 512 maskable
icon, and drops the portrait lock. The service worker keeps the session ({baseUrl, accessToken}) in
IndexedDB `ratatoskr-sw` so a push can wake a worker with no tab; the page's existing logout message
deletes it.

Stage 1: `push` and `notificationclick` handlers (src/app/features/push), a pusher toggle in
notification settings, `push` block in config.json shipped empty (off). Tested against a mock
homeserver and Matrix gateway body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 19:58:40 +00:00
f79613ca5f Merge pull request 'docs: chat shell compression, measured state and brotli plan' (#120) from void/chat-shell-compression into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m44s
2026-11-12 18:16:04 +00:00
Void
74fc2fbcd4 docs: fix build id, SW claim, step order, twin size after Soul pass
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m48s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 18:15:59 +00:00
Void
9297ba08dd docs: chat shell compression, measured state and brotli plan
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 18:09:47 +00:00
3d8c98e1c8 Merge pull request 'runbook: handoff build ships the repo config.json; live check always runs' (#119) from void/runbook-config-flow into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m33s
2026-11-12 17:08:05 +00:00
Void
3543b1013b runbook: sha256sum --ignore-missing hides nothing; name the config compare
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 17:07:54 +00:00
Void
45579b8ec8 runbook: the handoff build ships the repo's config.json and its live check always runs
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:54:58 +00:00
5cce38f38b Merge pull request 'docs: client build, install, verify and rollback runbook' (#118) from void/client-config-runbook into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 16:51:49 +00:00
Void
45facff436 runbook: handoff build ships its own pinned config; drift check is not a pass check
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:51:41 +00:00
Void
fef7d34fdb runbook: record the served-config drift as observed
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:50:28 +00:00
Void
3751adcb64 docs: Ratatoskr client build, install, verify and rollback runbook
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:50:16 +00:00
02a0c10665 Merge pull request 'Phone and desk visual findings: notice scrim, tap areas stop at neighbours, desk affordance' (#117) from void/phone-desk-visual-fixes into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 6m5s
2026-11-12 14:31:17 +00:00
Void
96954c0fb5 Desk: fold chevron, quiet text 0.9, solid note border, recorded rulings on their own ground
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:28:34 +00:00
Void
d4463a80cf Phone and desk visual findings: notice line owns its ground, tap hit areas stop at their neighbours, nav rows as tall as their targets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:19:25 +00:00
494d2a7ed4 File zone share: component tests, state machine moved out of ShareSet (#116)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 13:15:49 +00:00
c5d1b9c90f File zone: share a set of files as one album (flag off) (#115)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 13:01:46 +00:00
f9f94808d5 An explicit theme choice (even Midnight Sky) wins over the sky (#114)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 11:41:43 +00:00
df26eb4c99 Footer band test pins the emitted tint and ink (#113)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m57s
2026-11-12 10:50:08 +00:00
010e68b5e8 A long reply quote stays inside its column (#112)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 10:50:03 +00:00
07b82f2477 Pin sceneButtons folds recipe classes to the installed folds (#111)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m6s
2026-11-12 09:51:15 +00:00
df245e0b76 Ridge scene: physical sky over Vienna, real horizon, live weather (Nibu) (#110)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Nibu's ridge atmosphere (nibu/ridge-atmosphere b0489bc9): Hillaire sky, Copernicus horizon, TAWES weather with clear-sky fallback. Credit: Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at. Soul passed (Nibu 5 findings fixed; Void Soul pass).

Co-authored-by: Nibu <nibu@minds.nibu.gamecult.org>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 02:48:04 +00:00
d3fa27859d Merge pull request 'File receipts say "fetched for <Mind>", not "read by"' (#109) from void/file-received-wording into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m8s
2026-11-12 02:07:03 +00:00
Void
6ba5647ea8 File receipts read 'fetched for <Mind>', never 'read by'
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m32s
The receipt means the mouth fetched and stored the file for the Mind, not that it is in her sandbox or was read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 01:58:39 +00:00
98b4ec8ab1 Merge pull request 'File zone: one shared room subscription for read-by rows' (#108) from void/file-readers-store into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m47s
2026-11-11 22:47:21 +00:00
Void
1c5331da4d File zone: one room subscription for every 'read by' row
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
receiptStore.ts holds one Timeline/Redaction/TimelineReset listener set per room while any row is mounted and a snapshot of the receipt events; useFileReaders derives each row's readers from it with readersOf (same rules). 200 rows register one listener per event, not 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:41:35 +00:00
4876d31428 Merge pull request 'File zone: read-by receipts found anywhere in the room' (#107) from void/file-receipts-anywhere into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m13s
2026-11-11 22:39:14 +00:00
9aa34905c9 Merge pull request 'config.json: ship the ridge scene style' (#106) from void/config-ridge into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 22:35:38 +00:00
Void
da2445044d File zone: the readers hook rescans on redaction and reset; replies guard is one local; reader order by time
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m26s
Soul findings 1, 2 (doc), 4 and the merge order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:34:39 +00:00
Void
00890b2108 File zone: 'read by' finds receipts anywhere in the room, in the album listing and on the timeline row
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
readersOf (fileReceipt.ts) is the one reader: a receipt is a thread reply or a plain room event, matched by content.event_id; the sender must be @<mind>:<this homeserver>, the hash the file event's, an m.reference present must name the same event, one entry per Mind. The timeline file message draws it too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:33:36 +00:00
Void
64ecdc03f7 config.json: ship the ridge scene style (Meta, 2026-11-11)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:08:12 +00:00
83baee2c2d Merge pull request 'File zone: Mind contract, plaintext sha256 on send, read-by receipts' (#104) from void/file-zone-contract into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m38s
2026-11-11 22:01:23 +00:00
08bf515997 Merge pull request 'Release candidate: dev into main' (#88) from dev into main 2026-11-11 02:13:08 +00:00
683d3b92f8 Merge pull request 'Release candidate: dev 64edacac to main' (#71) from release/dev-64edacac into main 2026-11-10 17:42:15 +00:00
334 changed files with 17253 additions and 2501 deletions

View file

@ -18,10 +18,14 @@ jobs:
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".node-version"
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Ridge asset gate
run: |
npx vitest run src/app/features/scene/ridge/ridgeAssets.test.ts
echo "ridge assets: $(find public/ridge -type f | wc -l) files, $(du -sb public/ridge | cut -f1) bytes"
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'

View file

@ -20,11 +20,19 @@
},
"backgroundScene": {
"tier": "full",
"style": "terrain"
"style": "ridge"
},
"dings": {
"enabled": false
},
"fileZoneShare": {
"enabled": false
},
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": {
"enabled": false,
"url": ""

View file

@ -0,0 +1,34 @@
schema = "gamecult.idunn.target_declaration.v1"
target = "ratatoskr"
# Idunn stamps the source commit into the build environment under this name. The release is not built from source: the
# dist tarball below is built by scripts the release owner runs (build-prod-dist.sh) and enters pinned by sha256, so
# each release is a recipe commit whose pin is its provenance. The stamp the served index carries is the commit the
# tarball was built from, which is not this commit.
source_stamp_environment = "RATATOSKR_BUILD_COMMIT"
# Release dist b30ddac0522cc110d44d12276416c000841d086a: the output of build-prod-dist.sh (ALLOW_CONFIG_DRIFT=1), packed
# as dist/ at the top of one gzip tar. The tree carries the repo's own config.json; what is served is the operator's
# carried file, which Idunn copies beside dist/ and the vhost aliases over the tree's (a vhost without that alias would
# serve the repo config).
[[external_inputs]]
id = "dist-tarball"
url = "https://eureka.gamecult.org/forge/GameCult/Ratatoskr/releases/download/dist-b30ddac0/ratatoskr-b30ddac0-dist.tar.gz"
sha256 = "1654dc5f0ceefc5d1abf997fce1cd31ed7aef28d50e52c8cde6ce7b5c14efd23"
runner = "unpack"
destination = "release/dist.tar.gz"
[[steps]]
id = "unpack-dist"
phase = "prepare"
runner = "unpack"
argv = ["tar", "-xzf", "release/dist.tar.gz", "-C", "release"]
[[artifacts]]
id = "dist"
source_kind = "runner-output"
runner = "unpack"
source = "release/dist"
destination = "dist"
[static_release]
artifact = "dist"

291
docs/commons-files-desk.md Normal file
View file

@ -0,0 +1,291 @@
# Files desk: Commons and per-Persona file zones, two designs compared
Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta.
### Eureka's answers (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; final answers folded in 2026-11-13)
- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large.
- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too.
- **Room (Q-E3, answered).** The Commons room is `#commons:gamecult.org` (create type `org.gamecult.file_zone`, unencrypted, membership by the estate Space, publisher bot at power level 50). Provisioning and push muting are Eureka's.
- **Cap (Q-E1, answered).** 50 MiB per file. The client reads it live from `m.upload.size`, not from this document.
- **Poster (Q-E1, answered).** The publisher posts as the owning Mind: each file appears under the Mind whose tree it came from, not under a generic bot.
- **Final marker (Q-E2, answered).** A handoff document is final when a sibling `NAME.final` holds its sha256; the publisher watches for that, posts once, and carries `org.gamecult.publish.replaces` (prior event id and sha256) on a later final version.
- **Range (Q-E4, unverified).** Whether Synapse media honours `Range` has not been checked against a live mxc. Playback therefore stays on the blob download until it is.
- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256.
Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials).
## What exists on Matrix today (the base A must fit)
Read from this repo at dev `8ba407cd`:
- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#<mind>-files:<server>`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group.
- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown.
- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only.
- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`.
- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays.
- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room.
Option A is an extension of this, not a second system.
## The two options
**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms.
**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line.
| | A: Matrix only | B: host service |
| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) |
| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again |
| Audit of writes | the event `sender`, kept by the homeserver | the host's own log |
| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code |
| Upload size cap | the homeserver's `m.upload.size`, global, 50 MiB (Eureka) | the host's own, chosen freely |
| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) |
| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live |
| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree |
| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves |
| Folders | albums; nesting by path in the album title | real directories |
| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need |
| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob |
| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits |
## Recommendation: A (decided)
Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change.
What A gives up, stated plainly:
- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons.
- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one.
- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again.
- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today).
- **Hierarchy beyond the album level** as a native structure; see Folders.
What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure.
## Option A in detail
**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons:gamecult.org`, join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new.
**Folders.** Four ways to have folders on Matrix, in order of preference:
1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.**
2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread.
3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone.
4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut.
**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code.
**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot account that posts as the owning Mind, per Eureka's answer above). The Self never holds this account; the publisher holds it. It owns the allowlist.
- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know.
- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk.
- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes.
- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away.
- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice.
- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`.
**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows:
| Host rule | Under A |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) |
| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread |
| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending |
| type allowlist | client-side only; see "gives up" |
| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender |
| audit log with the Matrix id | the event `sender` |
What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`).
## What each costs on the phone
Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies).
- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not).
- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension.
- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI.
- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit.
## Changes to Eureka's host work
Under A the paused service is not built, and most of its list disappears:
- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list).
- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed.
- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired.
## Cuts under A
Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer.
2. **Pagination** of albums and files in `useAlbums` (done, PR #148).
3. **Folders by title path** and the album list grouped by it.
4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149).
5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`.
PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake.
## Not in scope
The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels.
## Open questions
**Eureka:** answered; see "Eureka's answers" at the top. Still open on Eureka's side: Range support (Q-E4) and the live runtime `max_upload_size`.
**Meta:**
- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later?
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.)
- **Q-M3.** May Users or Patrons upload if they are ever let in?
- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room.
- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough?
---
# Option B in full (kept for the comparison; superseded in part by Option A above)
The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above.
### What exists, and what this adds
| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) |
| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Storage | Matrix media in a `#files-<mind>` room, albums as threads | Directories on Yggdrasil, served by a small host |
| Listing | thread roots, `buildAlbums` | `GET /index?path=` |
| Unit | album of posted files | file in a directory tree |
| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone |
| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) |
They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live.
### Zones (host)
| Zone | Contents | Who sees it |
| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- |
| Commons | a curated tree of `/opt/commons` | members and Minds |
| Persona `<slug>` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only |
| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves |
Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory.
Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind-<slug>-outbox`, owned by `mind-<slug>-self`, 0750, ACL read for `mind-<slug>` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker.
### Phone-first UI (host)
Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use.
- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`.
- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside).
- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header.
- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button.
- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on.
- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access.
### Host contract
The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state.
- `GET /index?path=<p>` returns `{ role, zone, path, entries: [{ name, kind: "file"|"dir", size, mtime, mime, hash }] }`. `mtime` is unix seconds. `hash` is the lower-case hex sha256 of the plaintext bytes, absent for directories, and may be absent for a file whose hash is not computed yet (client shows nothing, never blocks on it). `mime` is sniffed by the host from magic bytes, not guessed from the extension.
- `GET /file?path=<p>` streams the bytes with `Accept-Ranges: bytes` and 206 for `Range` (audio and video seek; a phone on mobile data does not fetch 40 MB to play 10 seconds). `Content-Type` is the sniffed mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything that can run in the origin (`html`, `svg`, `js`, `xhtml`), and a `Content-Security-Policy: sandbox` on every response.
- `path` is `<zone>/<relative path>`, with the zone one of the names the host announces. The host resolves the real path of every request and refuses anything that leaves the zone's root: `..`, absolute paths, NUL, symlinks that cross the root, dotfiles, and `handoff-private`, which is not on any allowlist. Errors are typed (`401 unauthenticated`, `403 zone not allowed`, `404`, `416`), and a path outside the allowlist is 404, not 403, so it says nothing about what exists.
- **Auth: Matrix OpenID.** The client calls `mx.getOpenIdToken()` (already used in `CallWidgetDriver.ts`) and sends it as `Authorization: Bearer <token>`. The host verifies it against the homeserver's federation `openid/userinfo` endpoint, **pinned to our server name** (an `@void:evil.example` token must fail), caches the verified `sub` for the token's lifetime, and derives the role. It never sees the user's access token, and a Mind's Self never holds a token (ruling above): Minds reach the same bytes through the filesystem or the mouth, not this host.
- **Role** in the response: `member`, `mind`, `steward` (host decides from the Matrix id and the room/space membership the host reads; the client trusts none of it for access). **Q-E:** exact source of membership (estate Space membership read by the host with a service account, versus the Persona catalogue file for Minds).
- **Uploads** (Meta approved for members and Minds, #swarm 2026-11-13 14:30Z; rules are Eureka's, below). `PUT /file?path=<p>` writes only under an `uploads/` subtree of a zone, with `Content-Length` required; the host enforces a size cap, a type allowlist (sniffed, not the client's claim), no overwrite unless the request carries an explicit flag, and never replaces a file a Mind wrote into the handoff tree; every write is audit-logged with the caller's Matrix id. Reads cover the whole allowlisted tree, so an uploaded file is listed like any other. Success is `201` with the stored name, size and hash. The `role` in the index response gates the upload control; the host gates the write.
### The one problem the contract leaves: media elements cannot send headers
`<audio src>`, `<video src>` and `<img src>` cannot attach `Authorization`. Options, none free:
1. **Fetch to a blob.** Works with the contract as written. No seeking before the whole file is in memory; bad for a 40 MB clip on a phone. Fine for images and small audio.
2. **Short-lived path ticket.** `POST /ticket?path=` with the bearer token returns a URL with an unguessable token valid for a few minutes for that one path and its ranges. The player gets a normal URL and seeks. One more endpoint on the host and one more thing to expire and log.
3. **Service worker adds the header** to `/files-host/*` requests. The PWA already has a worker (`src/sw.ts`); no host change, but the worker must carry a fresh OpenID token and range requests through it are fiddly.
Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.**
### Upload flow (host)
Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `<CAP_FILE>` (per-file size), `<CAP_BATCH>` (per-pick total, if any), `<TYPES>` (the allowlist), `<OVERWRITE_FLAG>` (the explicit query flag), and the codes `<E_AUTH>`, `<E_ROLE>`, `<E_PATH>`, `<E_TYPE>`, `<E_SIZE>`, `<E_EXISTS>`, `<E_PROTECTED>`, `<E_QUOTA>`, `<E_SERVER>`.
**Where.** The control exists only where an upload is allowed: inside a folder under a zone's `uploads/` subtree (or at the `uploads/` row itself), and only when the index `role` is member or mind. Elsewhere there is no button; the list does not carry a disabled one. A folder outside `uploads/` shows a one-line dim note, "Uploads go in uploads/", which links to that folder. Creating subfolders under `uploads/` is not in this design (**Q-E8**).
**Pick or drop.** One `Upload` row at the top of an `uploads/` folder opens the native picker (any number of files; on a phone this is the OS file sheet, camera roll included). On wide screens the list is also a drop target; the drop highlight appears only while a drag is over it. Nothing starts on pick: the picked files appear as a staging list (name, size, a type icon) with `Upload` and `Cancel` as full-width rows, as `ShareSet` does for a Matrix set. Files that already fail the client's cheap checks (over `<CAP_FILE>`, a type outside `<TYPES>`) are marked in the staging list with the reason and left out of the send; the client mirrors the cap and allowlist as a courtesy only, the host is the authority, and the list is read from the host (**Q-E9**: an `/index` field or a `/limits` call) rather than hard-coded.
**Send.** Files go one at a time, in pick order, each a `PUT /file?path=<folder>/<name>` with the bearer token. One at a time keeps a phone on mobile data from starving every transfer, and a refusal names exactly one file. The transport is `XMLHttpRequest`, not `fetch`: `fetch` has no upload progress and the row needs it. The body is the `File` itself (streamed by the browser, not read into memory; unlike the Matrix path, the client computes no hash first, the host returns one). The name is the file's own name, with path separators and control characters replaced, never a path the user typed.
**Progress.** The staging list becomes the progress list: per file a thin bar and "412 KB of 3.1 MB", per batch "2 of 5". Finished files show a tick and the stored name; refused ones show the refusal in place; waiting ones are dim. Only the active file animates. The list stays on screen after the last file until dismissed, so a refusal is not lost to a refresh of the folder underneath it. The folder listing refetches after each success, so the file appears in the list as it lands.
**Cancel.** `Cancel` on the active file aborts the request (`xhr.abort()`) and goes on to the next; `Cancel all` on the batch aborts the active file and drops the rest. The host must discard an aborted partial write and never leave a half file in the listing (**Q-E10**, written to a temporary name and renamed on completion). Leaving the page aborts whatever runs, as the Matrix share does; the client does not resume.
**Overwrite.** A name that already exists is refused with `<E_EXISTS>` and shown as "<name> is already there". A `Replace` row appears under that refusal only for a file the caller wrote themselves, and sends the same `PUT` with `<OVERWRITE_FLAG>`; a Mind-written handoff file refuses with `<E_PROTECTED>` and offers only "Keep both", which re-sends under a suffixed name (`name (2).ext`) chosen by the client. Whether the client can tell "mine" from the index (an `owner` field with the Matrix id) or learns it only from the refusal is **Q-E11**; absent an owner field, `Replace` is simply offered after `<E_EXISTS>` and the host's answer decides.
**Refusal messages.** One plain sentence per code, shown in the file's row; nothing says "error" without saying why, and none names a server path.
| Code | Message (draft) |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `<E_AUTH>` | Sign-in expired. Reload to upload again. (The client fetches a fresh OpenID token once and retries before showing this.) |
| `<E_ROLE>` | Uploads are for members and Minds. |
| `<E_PATH>` | That folder does not take uploads. |
| `<E_TYPE>` | "<name>" is a <type> file; allowed types are <TYPES>. |
| `<E_SIZE>` | "<name>" is <size>; the limit is <CAP_FILE>. |
| `<E_EXISTS>` | "<name>" is already there. |
| `<E_PROTECTED>` | "<name>" was written by a Mind and cannot be replaced. |
| `<E_QUOTA>` | The shared space is full. Tell a steward. |
| `<E_SERVER>` / network / timeout | Could not reach the file host. The file was not saved. |
Retry is one tap on a failed row; the batch never retries on its own. An unknown code shows the host's status text as it is, still without a path.
**Honest end state.** Done means the host answered `201` and the stored name, size and hash are shown. "Saved" is not said earlier; the hash the host returns is displayed so a sender can compare it with their own.
**Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check.
### Client shape (host)
Under `src/app/features/files`, mirroring `features/albums`:
- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`, `put(path, file, onProgress, signal)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test.
- `useFilesZone.ts`: react-query on `['files', zone, path]`, `staleTime` 30 s; the role comes with every index.
- `FilesDesk.tsx`, `FileRow.tsx`, `FilePreview.tsx`: pure rendering from the above; the preview picks its renderer from the host's `mime`, not the extension.
- `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client.
- Test host: `src/preview-demo` already answers for a pretend homeserver through a fetch guard (`install.ts`); it gains a fake `/index` and `/file` with ranges so the previews are checked in a real browser before the real host exists.
### Cuts (host)
1. **Contract and fake host** (no UI): `filesHost.ts`, types, `preview-demo` fake, path tests (traversal strings from the host side are Eureka's; the client must only build `path` from entries it was given).
2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off.
3. **Preview and player**: image, audio with ranges, text, video; the ticket if Q-E settles on 2.
4. **Per-Persona rows** in each Mind's group, once the outbox `shared/` convention and the zone provisioning exist.
5. **Uploads**: the flow above; needs Eureka's codes and caps and the host's `PUT`.
Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land.
### Not in scope (host)
The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mouth's); encrypted rooms; deletion and rename (a read surface first; the files belong to whoever wrote them on disk); search across zones; a permission model of our own beyond what the host derives from Matrix identity.
### Open questions (host)
**For Eureka (host):**
- **Q-E1.** Which roots form Commons: the allowlist, and whether `handoff` is ever served (it holds custody receipts and ops notes; recommendation: no, a reviewed `commons/` directory instead).
- **Q-E2.** Where the host runs and what it is: Rust by default per doctrine; nginx with `auth_request` to a verifier is smaller if the sniff, range and role logic fit there. Which user; read-only mounts; ACLs on the outboxes.
- **Q-E3.** Media-element auth: blob, ticket or service worker (above).
- **Q-E4.** Role source: how the host learns membership and which Minds are Minds; token verification pinned to `gamecult.org`.
- **Q-E5.** Per-Persona publishing: a `shared/` subdirectory versus a marker, and who populates it when the Persona decides.
- **Q-E6.** Hash cost: compute lazily and cache by (path, mtime, size), or at publish time.
- **Q-E7.** CORS for `chat.gamecult.org` and the dev previews, rate limits, and the `Content-Disposition` list.
- **Q-E8.** Subfolders under `uploads/`: can a client create them (by a `PUT` path that implies them, or a `MKCOL`-like call), or is `uploads/` flat?
- **Q-E9.** Cap and allowlist discovery: fields in the index response, or a `/limits` endpoint; and the exact codes and cap numbers behind every placeholder in the upload flow.
- **Q-E10.** Partial writes: temporary name then rename, so an aborted or failed upload never appears in a listing.
- **Q-E11.** Does the index carry an `owner` Matrix id per file (so `Replace` is offered only on one's own files), and is the audit log readable by stewards from the client or only on the host?
**For Meta:**
- **Q-M1.** Audience: Commons for members and Minds as proposed; are Users and Patrons ever in (the Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork)?
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds?
- **Q-M3.** ~~Uploads~~ approved for members and Minds (2026-11-13 14:30Z); open only: may Users or Patrons upload if they are ever let in?
- **Q-M4.** Name: "Files" for the row, "Commons" for the shared zone.
- **Q-M5.** Does the Matrix Media row stay beside the Files row on each Mind, or does one absorb the other once the Files desk exists (recommendation: both stay; marks and receipts are Matrix's).

View file

@ -25,7 +25,7 @@ A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.
| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) |
The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this
client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `read by`
client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for`
for it.
`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail
@ -35,7 +35,7 @@ reference; audio carries no duration. A Mind should not rely on any of those.
- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap;
its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and
not read by a Mind.
not fetched for a Mind.
- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted
file, over the cap, content type not matching the magic bytes. The client does not re-check them and
nothing in the client depends on the mouth's log.
@ -45,7 +45,9 @@ reference; audio carries no duration. A Mind should not rely on any of those.
## Receipt: `org.gamecult.file_received`
The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the
file, as a reply in the album's thread so it arrives with the album's own listing (no extra request):
file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's
own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both
count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread:
```json
{
@ -58,28 +60,39 @@ file, as a reply in the album's thread so it arrives with the album's own listin
}
```
A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: <file event id>}`, which lets
the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no
relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts
for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one
`fetched for` for that Mind.
Two rules, both enforced on each side:
1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it:
no Mind field is read. `read by X` counts only when the sender is X's own Matrix identity (the
no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the
full id is `@<mind>:<this homeserver>` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count).
2. **Read means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file
2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file
event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed
refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the
file event's `info.sha256`. A file event with no `info.sha256` can show no `read by`.
file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`.
The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only
`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's
log is the fuller record for Minds; this event is the public tick.
The row draws `read by <Mind>`: one receipt per Mind per file (the latest), several Minds side by side. A
The row draws `fetched for <Mind>`: one receipt per Mind per file (the latest), several Minds side by side. A
receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored.
The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read".
The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond
what the hash and the Mind's own account show.
## Not built yet
- Files sent from the composer carry no thread relation, so they reach an album only if written into a
thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Receipts are
thread replies, so they show only where the file is.
thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain
room events, and the timeline row for the file shows `fetched for` from them.
- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are
unencrypted and the hash is written only there, so this is the encrypted-room limit again.
- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not
seen there (an album's own listing reads the thread, which is paged separately).
- Encrypted zones are not listed.

View file

@ -71,21 +71,21 @@ 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.
- "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` |
| 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).
@ -104,3 +104,20 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin
- 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).
## Share a set (built, behind `fileZoneShare.enabled`)
The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events.
Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else.
Client, `src/app/features/albums`:
- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`).
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only).
- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`.
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).

View file

@ -0,0 +1,108 @@
# chat.gamecult.org: shell compression and caching
Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live
host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table
below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is
`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied
to the host.
## What the host serves now
The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the
`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable
`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`.
| Resource | raw | gzip (served now) | brotli (served now) |
|---|---|---|---|
| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned |
| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none |
| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none |
| `/` (index.html, no-cache) | 3,066 | gzip | none |
Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`;
`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed
responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below
`gzip_min_length 1024` and goes uncompressed, which is correct.
So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve:
`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module.
## What brotli buys
Level-5 gzip is what the host does per request. Offline measurement on the same files
(node `zlib`, 2026-11-12):
| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live |
|---|---|---|---|---|---|
| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% |
| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% |
| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% |
| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% |
A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the
saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the
service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so
`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it
needs one root step.
Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so
the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11
on the fly; that is why the plan is precompressed twins.
## Root list for Eureka (host)
1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6,
depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade
alongside, say so before accepting). Do not enable the config yet.
2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way
(`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing.
3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines:
```nginx
brotli_static on; # serve <file>.br when the client sends br
gzip_static on; # serve <file>.gz; falls back to the on-the-fly gzip above
brotli on; # on-the-fly br for files with no twin (index.html, small files)
brotli_comp_level 5;
brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm;
```
The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers.
No other change: the `Cache-Control` and `Vary` lines stay as they are, and
`gzip_vary on` already makes the on-the-fly and static paths vary correctly.
4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every
client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request.
Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused.
## Build side
`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains
`PRECOMPRESS=1`, which runs `precompress.cjs <dist>` before the manifest is written. It
writes `<file>.br` (q11) and `<file>.gz` (level 9, no mtime, deterministic for one node) next
to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original.
Default is off, so builds installed before the host change do not ship ~27 MB of twins that
nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies.
Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full
`PRECOMPRESS=1` build has not been run because it waits on step 1.
The script is not in this repo; its placement is a standing open item of the build handoff.
## Before/after measurement
Run from any machine; `X` is the asset URL. Before = today's table above.
```sh
for ae in identity gzip br; do
printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X"
done
curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control'
```
Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with
`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity`
the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are
still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`.
The service worker's registration and an existing session must still work (open chat on
the operator's phone, hard reload once, check the network panel's "transferred" column).
Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build
script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them.

View file

@ -0,0 +1,121 @@
# Ratatoskr client: build, install, verify, roll back
Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy.
Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared
procedure. Nothing here is run by the build step against the live host.
## Facts about the host
- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web
server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are
`ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback.
- The client is static. `config.json` sits in the release root beside `index.html` and is read
at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the
chat.gamecult.org path.
- Check the pointer before anything else: `readlink /srv/cinny/current`.
## 1. Build (steward)
Two builds exist, and they treat `config.json` differently.
**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/`
(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended
served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up
(see Open). Do not hand-edit `dist/config.json`.
```sh
git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr
git checkout <full-40-hex-sha> # build a named commit, not a branch tip
npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook
node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked
# src/app/components/message/pose/catalogue.json
NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/
```
Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for
production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style).
**The handoff build (what the host installs).**
`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh <full-sha>` (see its `README.md`)
writes `/opt/commons/handoff/ratatoskr-main-<full-sha>/{dist,SHA256SUMS,BUILD-INFO,build.log}`,
locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at
that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy.
Its live check always runs: it fetches the served config and compares it byte for byte with the
repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and
BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the
handoff states which config differs and the installer decides. From Yggdrasil the public name does
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names.
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
Before handing off:
```sh
cd <folder>/dist && sha256sum -c ../SHA256SUMS # 0 failures
grep -rl 'demo.invalid\|demo-preview-token' <folder>/dist # must print nothing
# from Yggdrasil the public name may not resolve; --resolve points it at the local server
diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) <folder>/dist/config.json
```
A config difference is a finding to report, not something to paper over: either live drifted or
the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships.
## 2. Install (host owner, root, by hand)
Note the rollback target first: `readlink /srv/cinny/current`.
Unpack beside the live release; do not touch the live directory. The target must not exist, or
`cp -r` nests `dist` inside it.
```sh
R=ratatoskr-<8hex>
H=/opt/commons/handoff/ratatoskr-main-<full-sha>
test ! -e /srv/cinny/releases/$R
cp -r $H/dist /srv/cinny/releases/$R
cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json
(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS)
```
`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one,
so even equal content differs. Read the difference (`diff` the two) and settle which config
ships. To keep the host's config byte-for-byte, as past installs did,
`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags
`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch).
Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link):
```sh
ln -sfn releases/$R /srv/cinny/current
```
## 3. Verify
- `readlink /srv/cinny/current` names `releases/$R`.
- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`.
- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the
previous assets). Sign-in card shows, the background scene draws, the browser console has no
page errors, a poses/asset request returns 200 and not index.html.
## 4. Roll back
Point `current` at the release noted before the switch. Live at the time of writing is
`ratatoskr-nemesis-9b9b495d`:
```sh
ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current
```
Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis.
## Open
- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style`
`terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`,
`fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline.
The new keys default off or inert when absent, so only the style is a decision. Meta says which
style ships. Until the served file is replaced, every handoff build stops at the live check
unless `ALLOW_CONFIG_DRIFT=1` is set (section 1).

View file

@ -97,9 +97,11 @@ Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
events from the Minds' stewardship documents; each project room's alias is
`#<repo lowercased>` (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.
joined to the root `org.gamecult.stewardships`. The
projection now also names room ids under `@rooms.projects` and
`@rooms.file_zones` (gamecult-ops #111); the client reads `file_zones` for
the Media row (`mediaRoom.ts`) and still joins projects by alias, since
nothing yet consumes `@rooms.projects`.
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

View file

@ -0,0 +1,16 @@
# Ridge backdrop target
The scene's bottom silhouette is replaced by baked A+D ridge planes. The baker is Python and offline; the browser composes pre-lit planes over the live physical sky. Typed state for this campaign (ideas, questions, rulings, cuts) lives in the instance mind `nibu`, campaign `ratatoskr-backdrop`; this file keeps only the ends.
## Why
The live noise ridge is nearly invisible on a phone (about 1.35:1 against the darkest sky) because every ridge pixel is lifted to the text-contrast floor, and the star dots show between nav and header glyphs because the shell is transparent. Meta, 2026-11-13: do not polish the old backdrop; ship the A+D ridge pipeline (round 2 mockup: https://minds.gamecult.org/nibu/ridge-mockups-ad/).
## Ends
- One owner per decision: the baker owns ridge geometry and shading; the runtime owns only frame choice, blending, camera drift and compositing. No ridge geometry is computed in the browser.
- Nothing from the scene paints above app content: the scene is one stacking context at the back of the app, and nothing it creates has its own z-index or lives outside it.
- Phone cost is bounded: no per-frame script for the ridge, decoded size and fetched size gated in CI.
- The baker has fixtures with known right answers (the far-plateau spike, chain-end taper, slot union, seam) and a mutation runner that must kill each re-introduced cause.
- A baked asset names its baker version, DEM source and licence in a validated manifest; the credits shown in the Sky menu come from the manifest.
## Not in scope
Button size (Void's). Patching the old noise-ridge path. Cone sampling. Live DEM access from the browser.

View file

@ -0,0 +1,101 @@
# Symphony grammar harvest: Mimir's bioacoustic research
Follow-up: `ratatoskr:follow_up:symphony-grammar-harvest-mimir`. Source: GameCult/Mimir at
commit c6f4541eea61 (exists; read at that ref, not main), read-only through the forge API.
Paths below are Mimir paths. Written for Aqua (AquaSynth) before the generative activity grammar. It lives here because the symphony is a Ratatoskr cut (dings, then orbiting personas) and this repo is where its spec will be read; the follow-up named AquaSynth, where void holds no push right, so move it there if Aqua prefers.
## 1. The problem
One Mind's identity has to be a timbre plus a contour that stays recognisable after it is mixed with
the other Minds and passed through a lossy path. The symphony adds harmony (several Minds sounding
together as music), which Mimir never needed: Mimir emits one voice at a time, a low-gain song per speaker.
## 2. What Mimir found that transfers
Confidence: A = measured number in a receipt, B = measured but synthetic or single run,
C = design claim or authored opinion with no measurement behind it.
| Claim | Evidence | Conf. |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Tidy lab beeps are the wrong signal for a room; a birdsong-like motif replaced them. | `docs/bioacoustic-timeline-watermark.md` L3-10 (stated as a decision, no listening test). | C |
| Identity is carried redundantly: contour survives pitch shift, rhythm survives bad frequency response, formant envelope survives a dead band, speaker variants expose room asymmetry. | same doc L82-95. Argument, not an ablation. | C |
| A word needs no sequence grammar: one heard word identifies its event directly (128 words x 2 speaker variants, 4 syllables each), in an event slot that repeats about every 41 s (derived, not reported: 128 words x 2 speakers = 256 events at 0.16 s spacing = 40.96 s, from `MimirBioacousticTimeline.cs` L34-39 and `SymbolForEvent` L735-739; no receipt or doc states it). No de Bruijn grammar. | same doc L26-33; `src/Mimir.Runtime/Synchronization/MimirBioacousticTimeline.cs` (WordCount, SymbolCount = WordCount\*SpeakerCount, BuildMotifs ~L690-730). | B |
| Word identity survives mel-cepstral warp/blur degradation; timing does not unless fitted as one global clock/delay hypothesis. | `docs/bioacoustic-training-harness.md` L58-112: identity 0.908-0.997 on clean input only; degraded identity 0.532-0.908 and timing 0.187-0.728 for compact-fast-index across the four degraded cases in `artifacts/bioacoustic-training/bioacoustic-20260524-150729/compact-fast-index/*/summary.json` (`IdentityScore`, `TimingScore`); clean timing 0.357 before vs 0.905 after the global clock fit. | A (synthetic, clean-only range) |
| Contestant shapes were scored against the same decoder family and damage panel; the best scorer was the six-syllable "canary-packet-trill", a hypothesis not confirmed on a physical room (two timing chips plus four payload-coloured formant notes), not the literal bird imitations. | `research/bioacoustic-contestants-2026-05-24.md` L24-38, L128-174 (honest floor 88.4 language_score, 19 bps, payload 1.000 clean), L487-489 (96 ms motif is the best tradeoff). | B |
| Too-short or too-tight songs fail under blur: 0.100 s event spacing lost anchors to overlap; 0.105 s kept; 78 ms motif and 5-syllable finch-burst-packet collapsed (blur-light 4.358, the razor decoder; 218.368 with the sprint decoder, L453-454). | contestants doc L169-171, L446-455, L487-489. | A |
| A 3-bit payload variant (separate band/rhythm bit) did not survive degradation (best 12.849, clean 8.084). | contestants doc L183-185. | A |
| Real birds carry identity through redundant contour, roughness, onset statistics and cepstral envelope together; Redpoll is dense chatter with short vertical attacks, not a neat token stream; Guan is a slow broad curved phrase, good for probing the channel, poor for bitrate. The packet song was judged "still too ladder-like". | contestants doc L565-581 (two Xeno-canto mirrors via Wikimedia; Brown Thornbill file invalid, L511-513, L534-539). Flux numbers L549-562. | B (two recordings, hand reading) |
| Positive log-mel spectral flux is a good onset/anchor surface; sustained bands recede, attacks and glides light up. | contestants doc L541-545, L577-581; but the naive flux decoder lost on speed (33.466 vs 88.129 score, clean roundtrip, L158 and L231-237). | B |
| Once the schedule is known, the receiver only discriminates among a few local candidates; wide identity search is only for acquisition. Streaming decoder reached 1121 score at 62.6x realtime, payload 1.000 on all nine damage cases. | contestants doc L248-309. | A (synthetic damage) |
| Polarity must be a nuisance variable; mic paths flip sign. | contestants doc L391-395. | B (physical, one capture) |
| Physical room run: with per-channel acquisition offsets the co-streamer shotgun mic (ch1) locked 37/37 events, payload 1.000, timing 0.828; the cardioid/near mic (ch0) reached 26/37, payload 0.595, and stayed unreliable. | contestants doc L371-388. | B (physical, one capture) |
| Speaker split: left and right vocabularies differ in root (x0.94 / x1.08) and syllable weights, so each side is separable. | `MimirBioacousticTimeline.cs` BuildMotifs ~L704-725 (read as code, no experiment isolates the benefit). | C |
Rendering recipe actually in code (`MimirBioacousticTimeline.cs` AddSyllable ~L610-640): each syllable is
a log-frequency glide (exponential sweep) with a raised-cosine envelope, summed partials at
1, 1.5, 2, 2.75 times the glide (weights 1, 0.42, 0.24, 0.10) plus 0.08 weight of a slow "air" term,
20-32 ms long, four per word, gain 0.030 (canary profile 0.024 in `MimirBioacousticContestant.cs`).
Word roots are log-spaced 2.6 to 9.6 kHz; syllable frequencies are clamped 2.3 to 14.6 kHz;
word length 118 ms. Contours come from a small hand-written bank (8 contour shapes and 8 rhythm
offset sets, chosen by hash of word and speaker).
## 3. Design constraints for Aqua's grammar
1. Identity lives in several channels at once: contour (syllable glide shapes), log-frequency position
(root), formant envelope (the partial ratios), rhythm (inter-syllable offsets) and per-speaker
timbre. No single channel may be load-bearing. Place Minds on a log-frequency axis, not linear.
2. Do not build a sequence grammar for identity (de Bruijn or otherwise). The whole word is the unit;
a hearer should identify the Mind from one short phrase. (watermark doc L31-33.) What it gives up:
a de Bruijn code has a provable recovery property from a symbol stream; per-word identity has only
an empirical one.
3. Keep words short and distinct, but not so tight that they overlap: Mimir's measured edge was about
100 to 105 ms event spacing and a 96 ms six-syllable motif; below that identity decayed. Aqua's
events need not carry timing, so it can relax this, which is untested.
4. Use sharp onsets for placement but avoid a ladder of evenly spaced rungs; Mimir's own verdict was
that its packet song was still too ladder-like and real birds use irregular, redundant contour
(contestants doc L573-576). Vary rhythm and syllable length per word as the code already does.
5. Reject: row of tidy lab beeps (watermark doc L3-6); literal bird imitation as an end in itself (the
prettiest contestant, nightingale-cascade, was expensive and easy to confuse under smearing, per
`MimirBioacousticContestant.cs` profile note); a third payload bit on a second axis (measured loss).
6. Keep the generator deterministic from a typed description (contour, root, rhythm, speaker tint),
authored once and rendered, not regenerated in the audio callback. Contestants doc L493-496 states
this as the AquaSynth lesson: separate authoring intent from realtime DSP.
## 4. What does not transfer, or is unknown
- Harmony. Mimir emits one voice per speaker and decodes one stream; nothing in the research tests
simultaneous voices, intervals between Minds, consonance, or masking between concurrent songs.
Searched for "harmon"; the only hit is the `HarmonicNotch` anchor kind (an envelope notch used as a
timing anchor, `MimirBioacousticContestant.cs` ~L33, L231), not musical harmony.
- Orbiting spatialisation. Mimir's left/right split is a calibration device (one vocabulary per
speaker to learn each side of the room), not a perceptual pan or orbit. Nothing here informs it.
- Pleasantness. There is no listening test, no psychoacoustic metric, and no loudness-weighted score
in any file read. The only pleasantness evidence is the authored one-line verdicts on each profile in
`MimirBioacousticContestant.cs` L52-170 ("lab bird with a clipboard", "most plausible room sound",
"prettiest contestant") and the "wrong animal for a room" sentence. The score is
`realtime * timing * frequency * payload_bitrate` (contestants doc L39-59): decodability only.
Gain 0.021-0.030 is chosen for low audibility, not for pleasing anyone.
- Mimir's numbers are synthetic damage (mel-cepstral and log-mel warp/blur) plus a few Scarlett loopback
runs. The first physical room runs decoded mics only partially (contestants doc L355-395), and the
later live receipt's cardioid result was retracted as noise
(`calibration/bioacoustic/complex-contour-live-20260525-063229.md`, correction at watermark doc L225-230).
- The pipeline decodes against a known schedule and tiny candidate set; Aqua's listener is a human
mix, so none of the decoder scores say how distinguishable Minds are to a person.
- Not read: `src/Mimir.BufferSmoke` (the harness executable). It does not match a "bioacoustic" path
filter on the tree listing, so I read its documentation (`docs/bioacoustic-training-harness.md`) and
the `MimirBioacoustic*.cs` sources instead. The `artifacts/bioacoustic-training/` receipts were listed
but not opened beyond the summary text in the docs.
## 5. Open questions for Aqua
- What is the "decoder" for Aqua: a human listener, a classifier, or both? Constraints above only
hold against a machine decoder.
- Does a Mind keep one word for life, or does the grammar generate many songs sharing a timbre and root
(activity-dependent contour over a fixed identity)? Mimir only has fixed words.
- How many Minds, and how are roots allocated on the log axis when several sound at once? Mimir used 2
speaker variants per word and 128 words; harmony will change the spacing rule.
- Which dimension bends with activity (contour, rhythm density, gain) and which must stay fixed
(timbre, root)? Mimir has no activity channel to copy.
- Is a measured pleasantness proxy wanted (roughness, masking, loudness-weighted)? Mimir has none to reuse.
- Spatialised orbit plus a speaker-keyed timbre: if the pan moves, does identity still rest on timbre
alone? Mimir's per-side vocabulary is not a precedent.

View file

@ -29,6 +29,7 @@ esbuild
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
define: { 'import.meta.env.BASE_URL': '"/"' },
})
.then(() => {
if (ridge) {

View file

@ -1,6 +1,5 @@
// 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. The bake record (each chunk's cost, from
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
// tier, `?probe=1` shows the rAF median/p95 readout. The long tasks the page saw are on `window.longTasks`
// for a driver to read. Not shipped.
import { startBackgroundScene } from '../../src/app/features/scene';
import type { SceneTier } from '../../src/app/features/scene';
@ -8,8 +7,7 @@ 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;
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
w.bakes = [];
const w = window as unknown as { scene: unknown; longTasks: number[]; firstViewMs: number };
w.longTasks = [];
try {
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
@ -21,7 +19,6 @@ try {
const t0 = performance.now();
startBackgroundScene({ tier }, {}, {
canvas,
onBake: (b: unknown) => w.bakes.push(b),
} as never).then((scene) => {
w.firstViewMs = performance.now() - t0;
w.scene = scene;

View file

@ -1,12 +1,13 @@
{
"name": "Ratatoskr",
"short_name": "Ratatoskr",
"id": "/",
"description": "GameCult's Matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"scope": "./",
"background_color": "#07111a",
"theme_color": "#07111a",
"icons": [
@ -54,6 +55,12 @@
"src": "./public/android/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

7
public/ridge/index.json Normal file
View file

@ -0,0 +1,7 @@
{
"schema": 1,
"default": "vienna",
"places": {
"vienna": "vienna-12e733dc9f09"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -0,0 +1,232 @@
{
"schema": 1,
"baker_version": 1,
"bake_id": "12e733dc9f09",
"place": {
"id": "vienna",
"name": "Vienna, looking SW to Rax/Schneeberg",
"lat": 48.1929826,
"lon": 16.3534237,
"altitude_km": 0.18330000000000002,
"tz_h": 2,
"tz_name": "CEST",
"date": "2026-09-23"
},
"view": {
"look_az_deg": 214.0,
"plane_h": 600,
"px_per_deg_h": 17.77777777777778,
"px_per_deg_v": 177.77777777777777,
"horizon_row": 314.5166414048937,
"sky_row": 24.0,
"near_row": 396.0,
"window_deg": 72,
"plane_w": 1280
},
"frames": [
{
"key": "dawn",
"sun": {
"az": 91.31344091054159,
"el": 1.9676060128650812
},
"hour_utc": 4.923611110972221,
"planes": {
"far": {
"file": "dawn-far.webp",
"bytes": 76030,
"w": 1280,
"h": 600
},
"mid": {
"file": "dawn-mid.webp",
"bytes": 53926,
"w": 1280,
"h": 600
},
"near": {
"file": "dawn-near.webp",
"bytes": 113422,
"w": 1280,
"h": 600
}
},
"bytes": 243378,
"luma": {
"min": 0.02173927318967578,
"max": 0.2161143719823614
}
},
{
"key": "morning",
"sun": {
"az": 122.50757257559601,
"el": 26.09311435610136
},
"hour_utc": 7.5,
"planes": {
"far": {
"file": "morning-far.webp",
"bytes": 77330,
"w": 1280,
"h": 600
},
"mid": {
"file": "morning-mid.webp",
"bytes": 58076,
"w": 1280,
"h": 600
},
"near": {
"file": "morning-near.webp",
"bytes": 120478,
"w": 1280,
"h": 600
}
},
"bytes": 255884,
"luma": {
"min": 0.023307705502610833,
"max": 0.5966843818121018
}
},
{
"key": "noon",
"sun": {
"az": 179.90578338676787,
"el": 42.09425207059211
},
"hour_utc": 10.778105086666667,
"planes": {
"far": {
"file": "noon-far.webp",
"bytes": 78634,
"w": 1280,
"h": 600
},
"mid": {
"file": "noon-mid.webp",
"bytes": 58684,
"w": 1280,
"h": 600
},
"near": {
"file": "noon-near.webp",
"bytes": 123036,
"w": 1280,
"h": 600
}
},
"bytes": 260354,
"luma": {
"min": 0.028024916825206435,
"max": 0.7110067687110413
}
},
{
"key": "golden",
"sun": {
"az": 263.5260325423649,
"el": 6.139314360766868
},
"hour_utc": 16.193611110972224,
"planes": {
"far": {
"file": "golden-far.webp",
"bytes": 76038,
"w": 1280,
"h": 600
},
"mid": {
"file": "golden-mid.webp",
"bytes": 54686,
"w": 1280,
"h": 600
},
"near": {
"file": "golden-near.webp",
"bytes": 114944,
"w": 1280,
"h": 600
}
},
"bytes": 245668,
"luma": {
"min": 0.02139437660612246,
"max": 0.2869579287555448
}
},
{
"key": "dusk",
"sun": {
"az": 273.0513939324655,
"el": -1.8289117519819837
},
"hour_utc": 17.04388888875,
"planes": {
"far": {
"file": "dusk-far.webp",
"bytes": 75552,
"w": 1280,
"h": 600
},
"mid": {
"file": "dusk-mid.webp",
"bytes": 52176,
"w": 1280,
"h": 600
},
"near": {
"file": "dusk-near.webp",
"bytes": 112176,
"w": 1280,
"h": 600
}
},
"bytes": 239904,
"luma": {
"min": 0.018028911802351475,
"max": 0.18804654725014905
}
},
{
"key": "night",
"sun": {
"az": 283.9869292652113,
"el": -11.998720821772542
},
"hour_utc": 18.004444444305555,
"planes": {
"far": {
"file": "night-far.webp",
"bytes": 74732,
"w": 1280,
"h": 600
},
"mid": {
"file": "night-mid.webp",
"bytes": 50192,
"w": 1280,
"h": 600
},
"near": {
"file": "night-near.webp",
"bytes": 110780,
"w": 1280,
"h": 600
}
},
"bytes": 235704,
"luma": {
"min": 0.012974147057371975,
"max": 0.14255636176898956
}
}
],
"attribution": {
"credit_line": "Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.",
"licence": "Copernicus DEM licence: free, full and open access, with the credit line shown",
"dem_source": "Copernicus DEM GLO-30 (30 m)",
"url": "https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

56
scripts/buildSha.js Normal file
View file

@ -0,0 +1,56 @@
// The commit a bundle was built from, stamped into its index.html as <meta name="build-sha">, so a built
// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`.
// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has
// uncommitted changes, or `<BUILD_SHA>-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp
// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source
// archive), a full-sha BUILD_SHA stands, else 'unknown'.
import { execFileSync } from 'child_process';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the
// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name,
// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only.
const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/;
export const gitIn =
(cwd) =>
(...args) =>
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const FULL_SHA = /^[0-9a-f]{40}$/;
export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => {
const fromEnv = (env.BUILD_SHA ?? '').trim();
const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null;
// Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other
// repository has no git of its own, and that repository's HEAD says nothing about it.
let governed;
try {
governed = run('rev-parse', '--show-prefix').trim() === '';
} catch {
governed = false;
}
if (!governed) return explicit ?? 'unknown';
// Git governs and answers: from here a failure is 'unknown', never the bare explicit sha.
try {
const head = run('rev-parse', 'HEAD').trim();
if (!FULL_SHA.test(head)) return 'unknown';
if (explicit && explicit !== head) return `${explicit}-mismatch`;
const dirty = run('status', '--porcelain', '-uall', '--', '.')
.split('\n')
.some((l) => l.trim() !== '' && !VITE_TEMP.test(l));
return dirty ? `${head}-dirty` : head;
} catch {
return 'unknown';
}
};
export const buildShaPlugin = (sha = readBuildSha()) => ({
name: 'ratatoskr-build-sha',
transformIndexHtml: () => [
{ tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' },
],
});

170
scripts/buildSha.test.js Normal file
View file

@ -0,0 +1,170 @@
import { execFileSync } from 'child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
const gitAt =
(head, status = '') =>
(cmd, ...args) =>
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
describe('readBuildSha', () => {
it('reads the checkout it is built from', () => {
expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
});
it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
});
it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
const noGit = () => {
throw new Error('not a git repository');
};
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
// a value that is not a lowercase full sha is ignored
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
);
// git that answers but cannot say what the tree holds fails closed
const brokenStatus = (cmd, ...args) => {
if (cmd === 'status') throw new Error('index file smaller than expected');
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
};
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
// inside another repository's subfolder git does not govern: the explicit sha stands
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
expect(readBuildSha({}, nested)).toBe('unknown');
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
});
it("says 'unknown' rather than failing the build when git is absent", () => {
expect(
readBuildSha({}, () => {
throw new Error('spawn git ENOENT');
})
).toBe('unknown');
});
});
describe('readBuildSha against a real repository', () => {
const made = [];
afterEach(() => {
made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
});
const repo = () => {
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
made.push(dir);
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
g('init', '-q');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
g('add', '.');
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
return dir;
};
it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
const dir = repo();
const run = gitIn(dir);
writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
});
});
describe('readBuildSha dirty rules against a real repository', () => {
const made2 = [];
afterEach(() => {
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
});
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
const repo2 = (tracked = []) => {
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
made2.push(dir);
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
g('init', '-q');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
g('add', '.');
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
return {
dir,
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
};
};
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
const { dir, head } = repo2();
const run = gitIn(dir);
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
});
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
const { dir } = repo2();
const inner = join(dir, 'vendor', 'ratatoskr');
mkdirSync(inner, { recursive: true });
writeFileSync(join(inner, 'x.ts'), '');
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
});
it("notices an untracked .mjs that is not vite's temp file", () => {
const { dir, head } = repo2();
writeFileSync(join(dir, 'foo.mjs'), '');
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
rmSync(join(dir, 'foo.mjs'));
// only vite's exact shape is its own: vite.config.<ext>.timestamp-<digits>-<hex>.mjs
[
'vite.config.js.timestamp-1-ab.mjs.bak',
'vite.config.js.timestamp-x-ab.mjs',
'vite.config.js.timestamp-1-zz.mjs',
'vite.config.a.b.timestamp-1-ab.mjs',
'vite.config.js.timestamp-1-ab-c.mjs',
].forEach((name) => {
writeFileSync(join(dir, name), '');
expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`);
rmSync(join(dir, name));
});
expect(readBuildSha({}, gitIn(dir))).toBe(head);
});
it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => {
const { dir, head } = repo2();
const run = gitIn(dir);
writeFileSync(join(dir, TEMP), '');
writeFileSync(join(dir, 'src', TEMP), '');
expect(readBuildSha({}, run)).toBe(head);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
});
it('marks a tracked file of the temp name dirty once it changes', () => {
const { dir, head } = repo2([TEMP]);
const run = gitIn(dir);
expect(readBuildSha({}, run)).toBe(head);
writeFileSync(join(dir, TEMP), 'changed\n');
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
});
it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => {
const { dir, head } = repo2();
mkdirSync(join(dir, 'new'));
writeFileSync(join(dir, 'new', TEMP), '');
expect(readBuildSha({}, gitIn(dir))).toBe(head);
writeFileSync(join(dir, 'new', 'b.ts'), '');
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
});
});
describe('buildShaPlugin', () => {
it("hands vite a meta tag for the page's head carrying the sha", () => {
const plugin = buildShaPlugin(SHA);
expect(plugin.transformIndexHtml()).toEqual([
{ tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' },
]);
});
});

View file

@ -1,5 +1,5 @@
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
// in the repo is the live chat.gamecult.org config (style terrain), so previews pin the style too, ridge unless
// in the repo is the live chat.gamecult.org config (style ridge), and previews pin the style too, ridge unless
// given. 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

1
scripts/ridge-bake/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__/

View file

@ -0,0 +1,40 @@
# ridge-bake
Offline baker for the ridge backdrop: it turns a Copernicus DEM into six sun frames of three transparent WebP planes (far, mid, near) per place, plus a `manifest.json` and an `index.json` that the runtime reads. The baker is not shipped; `public/ridge/` holds its output.
## Run
```sh
python3 -I scripts/ridge-bake/export.py <place-id> --tiles DIR --out public/ridge/ [--work DIR] [--sheet contact.png]
```
Needs Python 3 with `numpy`, `tifffile` and `imagecodecs`. `--tiles` is a directory of Copernicus GLO-30 tiles (never committed); `--work` caches the mosaic and rays (default `$TMPDIR/ridge-bake-work`); `--sheet` writes the six frames over one flat sky. `python3 -I scripts/ridge-bake/export.py --write-schema` rewrites `ridge-bake.schema.json`; `export.py` holds the one dict it comes from.
Tests: `RIDGE_PY=<python> RIDGE_TILES=DIR scripts/ridge-bake/tests/run.sh [test_ridge.py|mutate.py|mutate_export.py]` (the full file takes about 9 minutes; `mutate.py` runs the mutants of taper, chains and sweep and `mutate_export.py` those of the planes path and export, each failing if one survives).
## Tiles per preset (`places.json`, date 2026-09-23)
Files are `Copernicus_DSM_COG_10_<N|S>dd_00_<E|W>ddd_00_DEM.tif`; the list below is `<N|S>dd<E|W>ddd`.
| place id | tiles |
|---|---|
| vienna | N47E015 N47E016 N47E017 N48E015 N48E016 N48E017 N49E015 N49E016 |
| matterhorn | N45E007 N45E008 N46E007 N46E008 |
| fuji | N34E138 N34E139 N35E137 N35E138 N35E139 |
| halfdome | N37W121 N37W120 N38W120 |
| amadablam | N27E086 N27E087 N28E086 N28E087 |
| trecime | N46E011 N46E012 |
## Planes
`relight_planes` renders every layer twice, with haze colour 0 and 1; the relight is affine in the haze colour, so the difference is each pixel's haze share. That share becomes transparency and the sky behind supplies the haze. Far is slot 0 plus the floor, near is the last slot, mid is every slot between (a place needs at least 3). Colour is exact over the frame's own haze colour; over another sky it drifts by the difference between the two. Stacked planes would leak: each plane's haze is alpha over whatever is behind it, so a nearer plane's haze would veil the farther ridge instead of the sky, and the farther ridge would show through it (a slanted seam through Vienna's near plane). `relight_planes` therefore scales a farther plane's alpha, near to far, to what the nearer layers leave open in the opaque frame (their transmittance over the stack's), looked up as the most open pixel within `OCCLUDE_MARGIN_PX` columns and eased by a Gaussian, because the runtime slides the planes against each other and a hidden pixel must stay hidden after the slide. The nearest layer is the ground in front of the eye: `bake_layer(near=True)` keeps its body opaque down to the horizon (its dissolve starts there), where the farther layers keep theirs (the haze arches). `export.check_near_cover` refuses, at `write_bake`, a near plane with a fully transparent column or a hole between a column's crest and the horizon row, in every frame; the farther planes may have empty columns. Over a flat sky the stack differs from the opaque frame by 1.8/255 over the whole frame (Vienna noon; 4.9 before the knock-out), but that is 11.2/255 mean over the 16.4% of pixels where two planes overlap and 0.005/255 everywhere else; the tests bound the frame at 3/255 and the overlap at 12.5/255. The spec's 2/255 pin is met on the frame; the overlap keeps the margin's soft blob.
Manifest `luma` is the min and max relative luminance (WCAG, linear 0..1) of the three planes composited far, mid, near over a flat mid-grey sky, over the pixels whose composite coverage `1 - prod(1 - alpha)` is at least 0.5. No plane pixel is opaque (haze is alpha), so no per-plane solid set exists; the composite is what the runtime shows.
## Licence
Elevation: Copernicus DEM GLO-30. The credit line is written into every manifest (`attribution`) and must be shown with any rendering:
> Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.
Licence: free, full and open access, with the credit line shown (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model).

View file

@ -0,0 +1,189 @@
"""Ridge backdrop export: bake a place with ridge.py and write its three WebP planes per sun key, a manifest and the index.
python3 -I scripts/ridge-bake/export.py <place-id> --tiles DIR --out public/ridge/ [--work DIR] [--sheet contact.png]
python3 -I scripts/ridge-bake/export.py --write-schema (rewrites ridge-bake.schema.json from SCHEMA below)
The runtime reads manifest.json (SCHEMA is its contract) and nothing else about the bake. The pure parts (budget, bake_id, manifest, index, schema check) import no baker;
bake_place imports ridge, which needs RIDGE_TILES and RIDGE_WORK (main sets them from --tiles / --work)."""
import os, sys, json, math, shutil, hashlib, argparse, tempfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import numpy as np, imagecodecs
import sun
BAKER_VERSION = 1
KEYS = ('dawn', 'morning', 'noon', 'golden', 'dusk', 'night')
PLANES = ('far', 'mid', 'near')
NIGHT_EL = -12.0 # the night frame: sun this far below the horizon
WINDOW_DEG = 72
PLANE_W = 1280 # every plane is the whole 72 deg window
PLANE_W_MAX = 4096
PAIR_BYTES_MAX = 800_000 # two adjacent frames (the runtime's worst case) together; the same number is a CI constant in cut-ridge-ad-gates
DECODE_BYTES_MAX = 20_000_000 # the six planes of a frame pair decoded (2 x 3 x w x h x 4 bytes = 18.4 MB at the full window)
WEBP_QUALITY = 80
LUMA_SKY = 0.5 # the flat mid-grey sky the planes are composited over for the luma probe
LUMA_COVER = 0.5 # composite coverage (1 - prod(1 - alpha)) at or above which a pixel is measured
NEAR_CREST_ALPHA = 64 # alpha (0..255) at which a near-plane pixel counts as the ridge's body: the crest of a column is its first such row
NEAR_HOLE_ALPHA = 24 # alpha below which a pixel under the crest is a hole (the sky would show through the ground in front of the eye)
SHEET_SKY = (0.80, 0.86, 0.94) # the flat sky of the contact sheet
ATTRIBUTION = dict(
credit_line='Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.',
licence='Copernicus DEM licence: free, full and open access, with the credit line shown',
dem_source='Copernicus DEM GLO-30 (30 m)',
url='https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model')
# ------------------------------------------------------------------------------------ schema (the one dict the schema file and the validator both come from)
_num, _int, _str = {'type': 'number'}, {'type': 'integer'}, {'type': 'string'}
def _obj(**props): return {'type': 'object', 'required': list(props), 'additionalProperties': False, 'properties': props}
_plane = _obj(file=_str, bytes=_int, w=_int, h=_int)
SCHEMA = {'$schema': 'https://json-schema.org/draft/2020-12/schema', 'title': 'ridge-bake manifest v1 and index v1', '$defs': {
'manifest': _obj(schema={'const': 1}, baker_version=_int, bake_id=_str,
place=_obj(id=_str, name=_str, lat=_num, lon=_num, altitude_km=_num, tz_h=_num, tz_name=_str, date=_str),
view=_obj(look_az_deg=_num, window_deg={'const': WINDOW_DEG}, plane_w={'type': 'integer', 'maximum': PLANE_W_MAX}, plane_h=_int, px_per_deg_h=_num, px_per_deg_v=_num, horizon_row=_num, sky_row=_num, near_row=_num),
frames={'type': 'array', 'minItems': len(KEYS), 'maxItems': len(KEYS), 'items': _obj(key={'enum': list(KEYS)}, sun=_obj(az=_num, el=_num), hour_utc=_num, planes=_obj(**{k: _plane for k in PLANES}), bytes=_int, luma=dict(_obj(min=_num, max=_num), description='min and max WCAG relative luminance of the three planes composited far, mid, near over a flat mid-grey sky, over pixels whose composite coverage 1 - prod(1 - alpha) is at least 0.5'))},
attribution=_obj(credit_line=_str, licence=_str, dem_source=_str, url=_str)),
'index': _obj(schema={'const': 1}, default=_str, places={'type': 'object', 'additionalProperties': _str})}}
def validate(x, s, path='$'):
"""The subset of JSON Schema SCHEMA uses; raises ValueError naming the path."""
if '$ref' in s: return validate(x, SCHEMA['$defs'][s['$ref'].split('/')[-1]], path)
ty = s.get('type'); py = {'object': dict, 'array': list, 'string': str, 'number': (int, float), 'integer': int}
if ty and (not isinstance(x, py[ty]) or isinstance(x, bool)): raise ValueError(f'{path}: not a {ty}')
if 'const' in s and x != s['const']: raise ValueError(f"{path}: must be {s['const']!r}")
if 'enum' in s and x not in s['enum']: raise ValueError(f'{path}: not one of {s["enum"]}')
if 'maximum' in s and x > s['maximum']: raise ValueError(f'{path}: above {s["maximum"]}')
if ty == 'array':
if not s.get('minItems', 0) <= len(x) <= s.get('maxItems', 1 << 30): raise ValueError(f'{path}: wrong length')
for i, v in enumerate(x): validate(v, s['items'], f'{path}[{i}]')
if ty == 'object':
props = s.get('properties', {}); extra = s.get('additionalProperties', True)
for k in s.get('required', []):
if k not in x: raise ValueError(f'{path}: missing {k}')
for k, v in x.items():
if k in props: validate(v, props[k], f'{path}.{k}')
elif extra is False: raise ValueError(f'{path}: extra key {k}')
elif isinstance(extra, dict): validate(v, extra, f'{path}.{k}')
# ------------------------------------------------------------------------------------ sun keys
def sun_set(p, date):
"""The five day keys from the preset date (copied from the mockup's run2.sun_set) plus the night key at NIGHT_EL: key -> {az, el, hour_utc}."""
day = datetime.fromisoformat(date).replace(tzinfo=timezone.utc); lat, lon, tz = p['lat'], p['lon'], p['tz_h']
geo = lambda t: sun.solar(day + timedelta(hours=t), lat, lon)[2]
def find(target, a, b):
fa = geo(a) - target
for _ in range(50):
m = (a + b) / 2; fm = geo(m) - target
if (fa < 0) == (fm < 0): a, fa = m, fm
else: b = m
return (a + b) / 2
noon = max((12 - lon / 15.0 - 1 + x / 600 for x in range(0, 1200)), key=lambda t: sun.solar(day + timedelta(hours=t), lat, lon)[1])
rise = find(-0.833, noon - 9, noon)
hours = dict(dawn=rise + 0.25, morning=9.5 - tz, noon=noon, golden=find(6.0, noon, noon + 9), dusk=find(-2.5, noon, noon + 9), night=find(NIGHT_EL, noon, noon + 14))
return {k: dict(az=sun.solar(day + timedelta(hours=h), lat, lon)[0], el=sun.solar(day + timedelta(hours=h), lat, lon)[1], hour_utc=h) for k, h in hours.items()}
# ------------------------------------------------------------------------------------ planes -> bytes
def u8(plane): return np.clip(np.rint(plane * 255.0), 0, 255).astype(np.uint8)
def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane), level=WEBP_QUALITY, lossless=False))
def decode(b):
"""A plane's bytes as RGBA uint8; the codec returns RGB for a plane that is opaque everywhere, which is solid."""
a = imagecodecs.webp_decode(b)
return a if a.shape[-1] == 4 else np.concatenate([a, np.full(a.shape[:2] + (1,), 255, np.uint8)], -1)
def luma_range(decoded):
"""WCAG relative luminance (linear 0..1) of the sRGB composite of the planes (far, mid, near order) over a flat LUMA_SKY grey, over the pixels whose composite coverage 1 - prod(1 - alpha) is at least LUMA_COVER: (min, max).
The planes are haze-as-alpha (no pixel is opaque), so the measure is of the composite, never of one plane's solid pixels."""
img = np.full(decoded[0].shape[:2] + (3,), LUMA_SKY); clear = np.ones(img.shape[:2])
for a in decoded:
al = a[..., 3:].astype(np.float64) / 255.0; img = img * (1 - al) + a[..., :3].astype(np.float64) / 255.0 * al; clear = clear * (1 - al[..., 0])
m = (1 - clear) >= LUMA_COVER
if not m.any(): raise ValueError('a frame has no pixel at coverage >= %s to measure' % LUMA_COVER)
c = img[m]; lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4); y = lin @ np.array([0.2126, 0.7152, 0.0722])
return float(y.min()), float(y.max())
def check_near_cover(near, horizon_row):
"""Refuse a near plane (RGBA uint8) that does not cover the ground in front of the eye: every column must have a crest (an alpha >= NEAR_CREST_ALPHA row), and from the crest down to the horizon row no pixel may be a hole (alpha < NEAR_HOLE_ALPHA).
A transparent column, or a wedge of holes under a crest, shows the sky through the foreground; the farther planes may leave columns empty (a place with no ridge at that distance), the nearest may not."""
a = near[..., 3]; h, w = a.shape; crest = np.argmax(a >= NEAR_CREST_ALPHA, axis=0); none = ~(a >= NEAR_CREST_ALPHA).any(0)
if none.any(): raise ValueError(f'near plane: {int(none.sum())} fully transparent columns, first at x={int(np.argmax(none))}')
rows = np.arange(h)[:, None]; hole = (a < NEAR_HOLE_ALPHA) & (rows >= crest[None, :]) & (rows < int(horizon_row))
if hole.any(): x = int(np.argmax(hole.any(0))); raise ValueError(f'near plane: {int(hole.any(0).sum())} columns have a hole between the crest and the horizon, first at x={x} row {int(np.argmax(hole[:, x]))}')
def check_budget(frame_bytes, plane_w, plane_h):
"""Refuse a bake the runtime could not hold: any two adjacent frames (night with dawn too) above PAIR_BYTES_MAX, or the planes of a frame pair decoding above DECODE_BYTES_MAX."""
if plane_w > PLANE_W_MAX: raise ValueError(f'plane width {plane_w} above {PLANE_W_MAX}')
for i in range(len(frame_bytes)):
j = (i + 1) % len(frame_bytes) # night wraps to dawn, as the runtime's frameChoice and the CI sizeGate pair them
if frame_bytes[i] + frame_bytes[j] > PAIR_BYTES_MAX: raise ValueError(f'frames {KEYS[i]} + {KEYS[j]} are {frame_bytes[i] + frame_bytes[j]} bytes, above {PAIR_BYTES_MAX}')
decoded = 2 * len(PLANES) * plane_w * plane_h * 4
if decoded > DECODE_BYTES_MAX: raise ValueError(f'a frame pair decodes to {decoded} bytes, above {DECODE_BYTES_MAX}')
def bake_id(frames_webp):
"""First 12 hex of sha256 over the plane bytes in key order, then far, mid, near."""
h = hashlib.sha256()
for k in KEYS:
for n in PLANES: h.update(frames_webp[k][n])
return h.hexdigest()[:12]
def write_bake(out, place, view, suns, frames_webp, default=None):
"""Write out/<place>-<bake_id>/ (planes + manifest.json), rewrite out/index.json, delete the place's previous dir. Refuses before writing a bake whose planes are not one size, differ from view plane_h, or exceed the budget measured on the decoded planes. Returns the directory."""
sizes = {k: {n: len(frames_webp[k][n]) for n in PLANES} for k in KEYS}; dec = {k: [decode(frames_webp[k][n]) for n in PLANES] for k in KEYS}
shapes = {a.shape[:2] for k in KEYS for a in dec[k]}
if len(shapes) != 1: raise ValueError(f'the planes are not one size: {sorted(shapes)}')
h, w = shapes.pop()
if h != view['plane_h']: raise ValueError(f"the planes are {h} rows, the view says {view['plane_h']}")
check_budget([sum(sizes[k].values()) for k in KEYS], w, h)
for k in KEYS: check_near_cover(dec[k][-1], view['horizon_row'])
bid = bake_id(frames_webp); name = f"{place['id']}-{bid}"; out = Path(out); out.mkdir(parents=True, exist_ok=True); dest = out / name
frames = []
for k in KEYS:
lo, hi = luma_range(dec[k])
frames.append(dict(key=k, sun=dict(az=suns[k]['az'], el=suns[k]['el']), hour_utc=suns[k]['hour_utc'],
planes={n: dict(file=f'{k}-{n}.webp', bytes=sizes[k][n], w=w, h=h) for n in PLANES}, bytes=sum(sizes[k].values()), luma=dict(min=lo, max=hi)))
manifest = dict(schema=1, baker_version=BAKER_VERSION, bake_id=bid, place=place, view=dict(view, window_deg=WINDOW_DEG, plane_w=w), frames=frames, attribution=ATTRIBUTION)
validate(manifest, SCHEMA['$defs']['manifest'])
tmp = Path(tempfile.mkdtemp(dir=out, prefix='.bake-'))
for k in KEYS:
for n in PLANES: (tmp / f'{k}-{n}.webp').write_bytes(frames_webp[k][n])
(tmp / 'manifest.json').write_text(json.dumps(manifest, indent=1) + '\n')
idx_path = out / 'index.json'; idx = json.loads(idx_path.read_text()) if idx_path.exists() else dict(schema=1, default=place['id'], places={})
old = idx['places'].get(place['id']); idx['places'][place['id']] = name; idx['default'] = default or (idx['default'] if idx['default'] in idx['places'] else place['id'])
validate(idx, SCHEMA['$defs']['index'])
if dest.exists(): shutil.rmtree(dest)
os.rename(tmp, dest); (out / '.index.tmp').write_text(json.dumps(idx, indent=1) + '\n'); os.replace(out / '.index.tmp', idx_path)
if old and old != name: shutil.rmtree(out / old, ignore_errors=True)
return dest
# ------------------------------------------------------------------------------------ the bake
def bake_place(pid, relight_keys=KEYS):
"""stage 0, sweep, set_view, bake, then relight_planes for every sun key: (place dict, view dict, suns, {key: {plane: float RGBA}}). Needs RIDGE_TILES / RIDGE_WORK."""
import ridge
assert ridge.WIN == PLANE_W and ridge.H > 0
p = ridge.configure(ridge.load_preset(pid)); ridge.mosaic(p); d, A, Hh, _ = ridge.stage0(False); r = ridge.sweep(d, A, Hh); v = ridge.set_view(r['slots']); layers = ridge.bake(r['slots']); statics = ridge.statics()
suns = sun_set(p, p['date']); planes = {k: ridge.relight_planes(layers, suns[k]['az'], suns[k]['el'], *statics) for k in relight_keys}
place = dict(id=p['id'], name=p['name'], lat=p['lat'], lon=p['lon'], altitude_km=p['ground_m'] / 1000.0, tz_h=p['tz_h'], tz_name=p['tz_name'], date=p['date'])
view = dict(look_az_deg=p['az_center'], plane_h=ridge.H, px_per_deg_h=ridge.HPX_DEG, px_per_deg_v=v['px_per_deg'], horizon_row=v['horizon_row'], sky_row=v['sky_row'] * ridge.H, near_row=v['near_row'] * ridge.H)
return place, view, suns, planes
def sheet(planes):
"""Contact sheet: the six frames over one flat sky, 2 columns x 3 rows (png bytes)."""
rows = []
for k in KEYS:
img = np.broadcast_to(np.array(SHEET_SKY), planes[k]['far'].shape[:2] + (3,)).copy()
for n in PLANES: a = planes[k][n]; img = img * (1 - a[..., 3:]) + a[..., :3] * a[..., 3:]
rows.append(img)
grid = np.concatenate([np.concatenate(rows[i:i + 2], 1) for i in range(0, 6, 2)], 0)
return bytes(imagecodecs.png_encode(u8(grid)))
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]); ap.add_argument('place', nargs='?'); ap.add_argument('--tiles'); ap.add_argument('--out'); ap.add_argument('--work'); ap.add_argument('--sheet'); ap.add_argument('--write-schema', action='store_true')
a = ap.parse_args(argv)
if a.write_schema: (HERE / 'ridge-bake.schema.json').write_text(json.dumps(SCHEMA, indent=1, ensure_ascii=False) + '\n'); return 0
if not (a.place and a.tiles and a.out): ap.error('place, --tiles and --out are required')
os.environ['RIDGE_TILES'] = a.tiles; os.environ['RIDGE_WORK'] = a.work or os.path.join(os.environ.get('TMPDIR', tempfile.gettempdir()), 'ridge-bake-work'); os.makedirs(os.environ['RIDGE_WORK'], exist_ok=True)
place, view, suns, planes = bake_place(a.place)
if a.sheet: Path(a.sheet).write_bytes(sheet(planes))
dest = write_bake(a.out, place, view, suns, {k: {n: encode(planes[k][n]) for n in PLANES} for k in KEYS})
print(dest); return 0
if __name__ == '__main__': sys.exit(main())

View file

@ -0,0 +1,657 @@
{
"_doc": "Place presets for the A+D baker (ridge.py). eye = observer lat/lon; eye_h_m = eye above the local DEM surface (ground_override_m replaces the DEM ground elevation, used for Vienna to keep round 1's 183.3 m); look_at = target lat/lon whose bearing centres the 72 deg window (or az_deg); rmax_km = ring radius (decides the tile list: every 1x1 deg GLO-30 tile whose rectangle intersects the circle); floor_agl_m = authored eye height above the notional valley floor used only for the floor-plane colour gradient; s_ref_deg = front slope that counts as steep for the painter; tz_h = standard-time offset of the local clock on the sun date.",
"date": "2026-09-23",
"presets": [
{
"id": "vienna",
"name": "Vienna, looking SW to Rax/Schneeberg",
"kind": "far range over a flat foreground",
"lat": 48.1929826,
"lon": 16.3534237,
"eye_h_m": 30.0,
"ground_override_m": 183.3,
"az_deg": 214.0,
"rmax_km": 95,
"floor_agl_m": 30,
"s_ref_deg": 8,
"tz_h": 2,
"tz_name": "CEST",
"viewpoint_source": {
"status": "existing observer of the round-1 mockup (the scene camera used by the game), not photo-sourced; eye 30 m above ground as in round 1"
}
},
{
"id": "matterhorn",
"name": "Matterhorn from the Gornergrat (photo cell, 9.7 km)",
"kind": "lone horn, close, 27 deg of relief",
"lat": 45.983139,
"lon": 7.782466,
"eye_h_m": 1.6,
"look_at": [
45.97666,
7.65678
],
"rmax_km": 60,
"floor_agl_m": 992.3298828124998,
"s_ref_deg": 30,
"tz_h": 2,
"tz_name": "CEST",
"initial_guess_not_used": {
"lat": 45.9856,
"lon": 7.7452
},
"viewpoint_source": {
"status": "sourced",
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 3600-12000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
"files_found": 5003,
"in_range": 3241,
"with_exif_heading": 205,
"facing_the_peak": 52,
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
"rule": "densest 1 km cell of photos 3000-12000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
"median_camera_heading_deg": 245.8,
"photos_in_cell": 7,
"cells_tried": [
{
"n_facing": 7,
"lat": 45.98314,
"lon": 7.78247,
"peak_visible": true
}
],
"distance_to_peak_m": 9749,
"bearing_to_peak_deg": 265.8,
"dem_ground_m": 3090.7,
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
"sample_files": [
{
"title": "File:Gornergrat-04.jpg",
"lat": 45.983061,
"lon": 7.782031,
"heading": 256.0,
"url": "https://commons.wikimedia.org/wiki/File%3AGornergrat-04.jpg"
},
{
"title": "File:Gornergrat-03.jpg",
"lat": 45.983058,
"lon": 7.782038,
"heading": 245.8,
"url": "https://commons.wikimedia.org/wiki/File%3AGornergrat-03.jpg"
},
{
"title": "File:Gornergrat-07.jpg",
"lat": 45.982653,
"lon": 7.782466,
"heading": 250.2,
"url": "https://commons.wikimedia.org/wiki/File%3AGornergrat-07.jpg"
},
{
"title": "File:Gornergratsee 2021 10.jpg",
"lat": 45.986958,
"lon": 7.780303,
"heading": 243.4,
"url": "https://commons.wikimedia.org/wiki/File%3AGornergratsee_2021_10.jpg"
},
{
"title": "File:Early morning Eiger.jpg",
"lat": 45.983242,
"lon": 7.783361,
"heading": 254.7,
"url": "https://commons.wikimedia.org/wiki/File%3AEarly_morning_Eiger.jpg"
},
{
"title": "File:Klein Matterhorn mit Wolken an Theodulhorn und Furgghorn \u00fcber dem Oberen Theodulgletscher- a view from Gornergrat 2021 02 (cropped).jpg",
"lat": 45.983139,
"lon": 7.784172,
"heading": 242.1,
"url": "https://commons.wikimedia.org/wiki/File%3AKlein_Matterhorn_mit_Wolken_an_Theodulhorn_und_Furgghorn_%C3%BCber_dem_Oberen_Theodulgletscher-_a_view_from_Gornergrat_2021_02_%28cropped%29.jpg"
},
{
"title": "File:Breithorn and neighbours from Gornergrat 2021 02.jpg",
"lat": 45.983139,
"lon": 7.784172,
"heading": 242.1,
"url": "https://commons.wikimedia.org/wiki/File%3ABreithorn_and_neighbours_from_Gornergrat_2021_02.jpg"
}
],
"exif_headings": [
{
"title": "File:Gornergrat-04.jpg",
"heading": 256.0
},
{
"title": "File:Gornergrat-03.jpg",
"heading": 245.8
},
{
"title": "File:Gornergrat-07.jpg",
"heading": 250.2
},
{
"title": "File:Gornergratsee 2021 10.jpg",
"heading": 243.4
},
{
"title": "File:Early morning Eiger.jpg",
"heading": 254.7
},
{
"title": "File:Klein Matterhorn mit Wolken an Theodulhorn und Furgghorn \u00fcber dem Oberen Theodulgletscher- a view from Gornergrat 2021 02 (cropped).jpg",
"heading": 242.1
},
{
"title": "File:Breithorn and neighbours from Gornergrat 2021 02.jpg",
"heading": 242.1
}
],
"n_with_exif_heading": 7,
"summit_dem_m": 4324.9,
"summit_nominal": [
45.97639,
7.65833
]
}
},
{
"id": "fuji",
"name": "Fuji from the Shiraito Falls side, 13.8 km WSW (photo cell)",
"kind": "lone cone, smooth",
"lat": 35.312467222222494,
"lon": 138.587223194445,
"eye_h_m": 1.6,
"look_at": [
35.36061,
138.72731
],
"rmax_km": 60,
"floor_agl_m": 138.6306213378906,
"s_ref_deg": 20,
"tz_h": 9,
"tz_name": "JST",
"initial_guess_not_used": {
"lat": 35.4994,
"lon": 138.7686
},
"viewpoint_source": {
"status": "sourced",
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 9000-30000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
"files_found": 6494,
"in_range": 4280,
"with_exif_heading": 588,
"facing_the_peak": 132,
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
"rule": "densest 1 km cell of photos 8000-30000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
"median_camera_heading_deg": 64.4,
"photos_in_cell": 8,
"cells_tried": [
{
"n_facing": 15,
"lat": 35.36248,
"lon": 138.86287,
"peak_visible": false
},
{
"n_facing": 8,
"lat": 35.31247,
"lon": 138.58722,
"peak_visible": true
}
],
"distance_to_peak_m": 13808,
"bearing_to_peak_deg": 67.2,
"dem_ground_m": 486.9,
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
"sample_files": [
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 03.JPG",
"lat": 35.31114,
"lon": 138.5886,
"heading": 48.0,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_03.JPG"
},
{
"title": "File:Mount Fuji, view from Shiraito no Taki.jpg",
"lat": 35.313589,
"lon": 138.588394,
"heading": 68.8,
"url": "https://commons.wikimedia.org/wiki/File%3AMount_Fuji%2C_view_from_Shiraito_no_Taki.jpg"
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 02.JPG",
"lat": 35.313248055556,
"lon": 138.58760805556,
"heading": 64.2,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_02.JPG"
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 07.JPG",
"lat": 35.312428055556,
"lon": 138.58723138889,
"heading": 88.8,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_07.JPG"
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 08.JPG",
"lat": 35.312418055556,
"lon": 138.587215,
"heading": 64.0,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_08.JPG"
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 04.JPG",
"lat": 35.312478055556,
"lon": 138.58717805556,
"heading": 62.2,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_04.JPG"
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 06.JPG",
"lat": 35.312456388889,
"lon": 138.58715805556,
"heading": 65.2,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_06.JPG"
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 05.JPG",
"lat": 35.312479722222,
"lon": 138.58714138889,
"heading": 64.5,
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_05.JPG"
}
],
"exif_headings": [
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 03.JPG",
"heading": 48.0
},
{
"title": "File:Mount Fuji, view from Shiraito no Taki.jpg",
"heading": 68.8
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 02.JPG",
"heading": 64.2
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 07.JPG",
"heading": 88.8
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 08.JPG",
"heading": 64.0
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 04.JPG",
"heading": 62.2
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 06.JPG",
"heading": 65.2
},
{
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 05.JPG",
"heading": 64.5
}
],
"n_with_exif_heading": 8,
"summit_dem_m": 3765.5,
"summit_nominal": [
35.36061,
138.72731
]
}
},
{
"id": "halfdome",
"name": "Half Dome from the Yosemite Valley floor (Sentinel Bridge photo cell)",
"kind": "granite wall across a valley",
"lat": 37.745833333333,
"lon": -119.592606,
"eye_h_m": 1.6,
"look_at": [
37.74435,
-119.53426
],
"rmax_km": 40,
"floor_agl_m": 5.5442382812500455,
"s_ref_deg": 30,
"tz_h": -7,
"tz_name": "PDT",
"initial_guess_not_used": {
"lat": 37.7305,
"lon": -119.5734
},
"viewpoint_source": {
"status": "sourced",
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 2700-9000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
"files_found": 2505,
"in_range": 1788,
"with_exif_heading": 181,
"facing_the_peak": 64,
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
"rule": "densest 1 km cell of photos 2000-9000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
"median_camera_heading_deg": 91.6,
"photos_in_cell": 9,
"cells_tried": [
{
"n_facing": 9,
"lat": 37.73853,
"lon": -119.57124,
"peak_visible": false
},
{
"n_facing": 9,
"lat": 37.74583,
"lon": -119.59261,
"peak_visible": true
}
],
"distance_to_peak_m": 5138,
"bearing_to_peak_deg": 91.8,
"dem_ground_m": 1209.2,
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
"sample_files": [
{
"title": "File:Yosemite National Park - 51700634719.jpg",
"lat": 37.746606,
"lon": -119.590317,
"heading": 87.0,
"url": "https://commons.wikimedia.org/wiki/File%3AYosemite_National_Park_-_51700634719.jpg"
},
{
"title": "File:Half Dome, Yosemite National Park - 53324601178.jpg",
"lat": 37.748264,
"lon": -119.592606,
"heading": 98.8,
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome%2C_Yosemite_National_Park_-_53324601178.jpg"
},
{
"title": "File:Half Dome, Yosemite National Park - 53324824955.jpg",
"lat": 37.748322,
"lon": -119.592736,
"heading": 90.1,
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome%2C_Yosemite_National_Park_-_53324824955.jpg"
},
{
"title": "File:2013-09-19 18 35 25 Half Dome and the Merced River in Yosemite National Park.JPG",
"lat": 37.7435,
"lon": -119.58966666667,
"heading": 91.6,
"url": "https://commons.wikimedia.org/wiki/File%3A2013-09-19_18_35_25_Half_Dome_and_the_Merced_River_in_Yosemite_National_Park.JPG"
},
{
"title": "File:Half Dome from the Yosemite Valley floor in mid-Spring.jpeg",
"lat": 37.745833333333,
"lon": -119.59233333333,
"heading": 92.0,
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome_from_the_Yosemite_Valley_floor_in_mid-Spring.jpeg"
},
{
"title": "File:Sentinel Bridge & Half Dome.jpg",
"lat": 37.743419,
"lon": -119.590562,
"heading": 86.8,
"url": "https://commons.wikimedia.org/wiki/File%3ASentinel_Bridge_%26_Half_Dome.jpg"
},
{
"title": "File:Half Dome pre Super moon 2.jpg",
"lat": 37.745769,
"lon": -119.592972,
"heading": 106.5,
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome_pre_Super_moon_2.jpg"
},
{
"title": "File:Half Dome pre Super moon.jpg",
"lat": 37.745747,
"lon": -119.593033,
"heading": 97.1,
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome_pre_Super_moon.jpg"
},
{
"title": "File:Yosemite National Park 003.jpg",
"lat": 37.750814,
"lon": -119.598664,
"heading": 80.1,
"url": "https://commons.wikimedia.org/wiki/File%3AYosemite_National_Park_003.jpg"
}
],
"exif_headings": [
{
"title": "File:Yosemite National Park - 51700634719.jpg",
"heading": 87.0
},
{
"title": "File:Half Dome, Yosemite National Park - 53324601178.jpg",
"heading": 98.8
},
{
"title": "File:Half Dome, Yosemite National Park - 53324824955.jpg",
"heading": 90.1
},
{
"title": "File:2013-09-19 18 35 25 Half Dome and the Merced River in Yosemite National Park.JPG",
"heading": 91.6
},
{
"title": "File:Half Dome from the Yosemite Valley floor in mid-Spring.jpeg",
"heading": 92.0
},
{
"title": "File:Sentinel Bridge & Half Dome.jpg",
"heading": 86.8
},
{
"title": "File:Half Dome pre Super moon 2.jpg",
"heading": 106.5
},
{
"title": "File:Half Dome pre Super moon.jpg",
"heading": 97.1
},
{
"title": "File:Yosemite National Park 003.jpg",
"heading": 80.1
}
],
"n_with_exif_heading": 9,
"summit_dem_m": 2669.1,
"summit_nominal": [
37.74597,
-119.5329
]
}
},
{
"id": "amadablam",
"name": "Ama Dablam and the Everest massif, 4 km WSW of Tengboche near Khumjung (photo cell)",
"kind": "far high range behind a near spire",
"lat": 27.822000000000003,
"lon": 86.722666666667,
"eye_h_m": 1.6,
"look_at": [
27.8617,
86.86079
],
"rmax_km": 60,
"floor_agl_m": 829.8232421875,
"s_ref_deg": 30,
"tz_h": 5.75,
"tz_name": "NPT",
"initial_guess_not_used": {
"lat": 27.8369,
"lon": 86.7643
},
"viewpoint_source": {
"status": "sourced",
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 4800-16000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
"files_found": 1398,
"in_range": 1143,
"with_exif_heading": 54,
"facing_the_peak": 13,
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
"rule": "densest 1 km cell of photos 4000-16000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
"median_camera_heading_deg": 79.6,
"photos_in_cell": 2,
"cells_tried": [
{
"n_facing": 2,
"lat": 27.822,
"lon": 86.72267,
"peak_visible": true
}
],
"distance_to_peak_m": 14299,
"bearing_to_peak_deg": 72.0,
"dem_ground_m": 3782.0,
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
"sample_files": [
{
"title": "File:Thamserku (6).JPG",
"lat": 27.822666666667,
"lon": 86.722666666667,
"heading": 73.6,
"url": "https://commons.wikimedia.org/wiki/File%3AThamserku_%286%29.JPG"
},
{
"title": "File:Thamserku (7).JPG",
"lat": 27.821333333333,
"lon": 86.722666666667,
"heading": 85.7,
"url": "https://commons.wikimedia.org/wiki/File%3AThamserku_%287%29.JPG"
}
],
"exif_headings": [
{
"title": "File:Thamserku (6).JPG",
"heading": 73.6
},
{
"title": "File:Thamserku (7).JPG",
"heading": 85.7
}
],
"n_with_exif_heading": 2,
"summit_dem_m": 6659.6,
"summit_nominal": [
27.8617,
86.8614
]
}
},
{
"id": "trecime",
"name": "Tre Cime di Lavaredo from the Dreizinnenhuette area, 2.4 km N (photo cell)",
"kind": "cirque wall, spires",
"lat": 46.636906,
"lon": 12.310064,
"eye_h_m": 1.6,
"look_at": [
46.61806,
12.29583
],
"rmax_km": 40,
"floor_agl_m": 735.6748046875,
"s_ref_deg": 30,
"tz_h": 2,
"tz_name": "CEST",
"initial_guess_not_used": {
"lat": 46.5833,
"lon": 12.2536
},
"viewpoint_source": {
"status": "sourced",
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 3000-10000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
"files_found": 2028,
"in_range": 763,
"with_exif_heading": 207,
"facing_the_peak": 33,
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
"rule": "densest 1 km cell of photos 1500-10000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
"median_camera_heading_deg": 194.6,
"photos_in_cell": 5,
"cells_tried": [
{
"n_facing": 5,
"lat": 46.63691,
"lon": 12.31006,
"peak_visible": true
}
],
"distance_to_peak_m": 2363,
"bearing_to_peak_deg": 207.4,
"dem_ground_m": 2403.0,
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
"sample_files": [
{
"title": "File:Cime e Nuvole.jpg",
"lat": 46.63605,
"lon": 12.310064,
"heading": 200.3,
"url": "https://commons.wikimedia.org/wiki/File%3ACime_e_Nuvole.jpg"
},
{
"title": "File:DreiZinnenP.jpg",
"lat": 46.636770555556,
"lon": 12.310305555556,
"heading": 180.1,
"url": "https://commons.wikimedia.org/wiki/File%3ADreiZinnenP.jpg"
},
{
"title": "File:Soffio di sguardo.jpg",
"lat": 46.636906,
"lon": 12.311142,
"heading": 194.6,
"url": "https://commons.wikimedia.org/wiki/File%3ASoffio_di_sguardo.jpg"
},
{
"title": "File:2405-DreizinnenH.jpg",
"lat": 46.637782777778,
"lon": 12.309966388889,
"heading": 178.4,
"url": "https://commons.wikimedia.org/wiki/File%3A2405-DreizinnenH.jpg"
},
{
"title": "File:Dolomiten-Fingerkraut in den Sextener Dolomiten - N\u00e4he Drei-Zinnen-H\u00fctte (9801216636).jpg",
"lat": 46.642352,
"lon": 12.30829,
"heading": 200.0,
"url": "https://commons.wikimedia.org/wiki/File%3ADolomiten-Fingerkraut_in_den_Sextener_Dolomiten_-_N%C3%A4he_Drei-Zinnen-H%C3%BCtte_%289801216636%29.jpg"
}
],
"exif_headings": [
{
"title": "File:Cime e Nuvole.jpg",
"heading": 200.3
},
{
"title": "File:DreiZinnenP.jpg",
"heading": 180.1
},
{
"title": "File:Soffio di sguardo.jpg",
"heading": 194.6
},
{
"title": "File:2405-DreizinnenH.jpg",
"heading": 178.4
},
{
"title": "File:Dolomiten-Fingerkraut in den Sextener Dolomiten - N\u00e4he Drei-Zinnen-H\u00fctte (9801216636).jpg",
"heading": 200.0
}
],
"n_with_exif_heading": 5,
"summit_dem_m": 2746.9,
"summit_nominal": [
46.6186,
12.3025
]
}
}
]
}

View file

@ -0,0 +1,313 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ridge-bake manifest v1 and index v1",
"$defs": {
"manifest": {
"type": "object",
"required": [
"schema",
"baker_version",
"bake_id",
"place",
"view",
"frames",
"attribution"
],
"additionalProperties": false,
"properties": {
"schema": {
"const": 1
},
"baker_version": {
"type": "integer"
},
"bake_id": {
"type": "string"
},
"place": {
"type": "object",
"required": [
"id",
"name",
"lat",
"lon",
"altitude_km",
"tz_h",
"tz_name",
"date"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"lat": {
"type": "number"
},
"lon": {
"type": "number"
},
"altitude_km": {
"type": "number"
},
"tz_h": {
"type": "number"
},
"tz_name": {
"type": "string"
},
"date": {
"type": "string"
}
}
},
"view": {
"type": "object",
"required": [
"look_az_deg",
"window_deg",
"plane_w",
"plane_h",
"px_per_deg_h",
"px_per_deg_v",
"horizon_row",
"sky_row",
"near_row"
],
"additionalProperties": false,
"properties": {
"look_az_deg": {
"type": "number"
},
"window_deg": {
"const": 72
},
"plane_w": {
"type": "integer",
"maximum": 4096
},
"plane_h": {
"type": "integer"
},
"px_per_deg_h": {
"type": "number"
},
"px_per_deg_v": {
"type": "number"
},
"horizon_row": {
"type": "number"
},
"sky_row": {
"type": "number"
},
"near_row": {
"type": "number"
}
}
},
"frames": {
"type": "array",
"minItems": 6,
"maxItems": 6,
"items": {
"type": "object",
"required": [
"key",
"sun",
"hour_utc",
"planes",
"bytes",
"luma"
],
"additionalProperties": false,
"properties": {
"key": {
"enum": [
"dawn",
"morning",
"noon",
"golden",
"dusk",
"night"
]
},
"sun": {
"type": "object",
"required": [
"az",
"el"
],
"additionalProperties": false,
"properties": {
"az": {
"type": "number"
},
"el": {
"type": "number"
}
}
},
"hour_utc": {
"type": "number"
},
"planes": {
"type": "object",
"required": [
"far",
"mid",
"near"
],
"additionalProperties": false,
"properties": {
"far": {
"type": "object",
"required": [
"file",
"bytes",
"w",
"h"
],
"additionalProperties": false,
"properties": {
"file": {
"type": "string"
},
"bytes": {
"type": "integer"
},
"w": {
"type": "integer"
},
"h": {
"type": "integer"
}
}
},
"mid": {
"type": "object",
"required": [
"file",
"bytes",
"w",
"h"
],
"additionalProperties": false,
"properties": {
"file": {
"type": "string"
},
"bytes": {
"type": "integer"
},
"w": {
"type": "integer"
},
"h": {
"type": "integer"
}
}
},
"near": {
"type": "object",
"required": [
"file",
"bytes",
"w",
"h"
],
"additionalProperties": false,
"properties": {
"file": {
"type": "string"
},
"bytes": {
"type": "integer"
},
"w": {
"type": "integer"
},
"h": {
"type": "integer"
}
}
}
}
},
"bytes": {
"type": "integer"
},
"luma": {
"type": "object",
"required": [
"min",
"max"
],
"additionalProperties": false,
"properties": {
"min": {
"type": "number"
},
"max": {
"type": "number"
}
},
"description": "min and max WCAG relative luminance of the three planes composited far, mid, near over a flat mid-grey sky, over pixels whose composite coverage 1 - prod(1 - alpha) is at least 0.5"
}
}
}
},
"attribution": {
"type": "object",
"required": [
"credit_line",
"licence",
"dem_source",
"url"
],
"additionalProperties": false,
"properties": {
"credit_line": {
"type": "string"
},
"licence": {
"type": "string"
},
"dem_source": {
"type": "string"
},
"url": {
"type": "string"
}
}
}
}
},
"index": {
"type": "object",
"required": [
"schema",
"default",
"places"
],
"additionalProperties": false,
"properties": {
"schema": {
"const": 1
},
"default": {
"type": "string"
},
"places": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}

552
scripts/ridge-bake/ridge.py Normal file
View file

@ -0,0 +1,552 @@
"""Round-2 baker: A's painter baked from D's sweep, driven by a place preset from places.json (ray sampling, the round-1 recommendation).
Offline baker for the ridge backdrop (scripts/ridge-bake); export.py drives it.
load_preset(id) -> configure() : observer, tiles, mosaic origin, ring radius, window azimuth, painter constants from places.json
stage0(detail) : ray march of all 6400 azimuth columns (curvature+refraction), optional authored sub-DEM foreground detail, cached
sweep() : D's per-column scan -> spans (with own height/run = per-peak slope) -> wrapped chains -> 7-knot layer cap
set_view(slots) : authored Y mapping: angle -> row, ground plane at the bottom (see set_view)
bake(slots) : sun-independent per-layer arrays
relight(layers, az, el) : shading + atmosphere + floor + sky
Vertical exaggeration is AUTHORED and derived per view by set_view (printed on the page)."""
import sys, time, math, json
import os, hashlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import numpy as np, tifffile
def _env_dir(name, what):
v = os.environ.get(name)
if not v: raise RuntimeError(f"{name} is not set: it must name {what} (no default location is assumed)")
return Path(v)
B = _env_dir('RIDGE_WORK', 'the work directory (the mosaic / ray caches)')
COP = _env_dir('RIDGE_TILES', 'the directory of Copernicus GLO-30 tiles (Copernicus_DSM_COG_10_*_DEM.tif)')
W = 6400 # ring columns: 360 deg at 17.78 px/deg
WIN = 1280 # window = 72 deg of the ring
HPX_DEG = W / 360.0
H = 600 # frame rows
COLW = math.radians(360.0 / W)
R_EARTH, KREF = 6371000.0, 0.13
PROM_M, NEAR_CAP, MINW, LINK_D, LINK_A, LOOK = 25.0, 4000.0, 50, 0.45, 0.25, 4
FOREGROUND_M = 5000.0 # a chain nearer than this that is narrower than MINW is kept only where no kept foreground chain covers its columns (the nearest layer must cover every column; a lone stub beside a long near ridge is an arch)
FADE_AT = 0.6 # a layer's body is opaque down to this share of its foot length, then smoothsteps to nothing at the foot
TAPER = 8 # minimum columns over which a chain end dies into its valley (D's 8; round 1 used a fixed 20 AND borrowed the far end's value: the spike)
TAPER_K, TAPER_MAX = 30.0, 72 # a taller end falls over more columns: ceil(30 columns per degree of drop), 8..72: at the authored 1.5-4x exaggeration an end leaves the ridge at about 45 deg or less, not a wall
def taper_len(drop_deg): return np.clip(np.ceil(np.asarray(drop_deg) * TAPER_K), TAPER, TAPER_MAX)
KNOTS = np.array([80, 48, 28, 16, 9, 5, 2.5]) * 1000.0
_r5 = ('#a9b9d3', '#8e9fc2', '#6f86a6', '#556f7d', '#3f5546')
def hx(h): return np.array([int(h[i:i+2], 16) for i in (1, 3, 5)]) / 255.0
def mix(a, b, t): return a + (b - a) * np.asarray(t)[..., None]
RAMP = [mix(hx(_r5[min(int(t), 3)]), hx(_r5[min(int(t), 3) + 1]), t - min(int(t), 3)) for t in np.linspace(0, 4, len(KNOTS))]
GROUND = hx('#4a5f45'); CONTOUR_NICE = (30.0, 50.0, 100.0, 150.0, 200.0, 300.0, 500.0, 1000.0); CONTOUR_MIN_PX = 9.0 # iso-height interval is the smallest nice value that keeps lines >= 9 px apart (p10 of the layer)
ROT = 0 # test hook: frame rotated by ROT columns; noise/azimuth stay tied to the absolute column
P = {} # configured preset
rowang = np.zeros(H); PXDEG_V = 100.0; TOP_DEG = 2.6; BOTTOM_DEG = -3.4; Hc = H
def load_preset(pid, path=Path(__file__).resolve().parent / 'places.json'):
d = json.load(open(path)); p = next(x for x in d['presets'] if x['id'] == pid); p = dict(p); p['date'] = d['date']; return p
# ====================================================================== configuration, DEM
def tiles_for(p):
lat, lon, r = p['lat'], p['lon'], p['rmax_km']; dl = r / 111.32; dn = r / (111.32 * math.cos(math.radians(lat))); out = []
for la in range(math.floor(lat - dl), math.floor(lat + dl) + 1):
for lo in range(math.floor(lon - dn), math.floor(lon + dn) + 1):
cy = min(max(lat, la), la + 1); cx = min(max(lon, lo), lo + 1)
if math.hypot((cy - lat) * 111.32, (cx - lon) * 111.32 * math.cos(math.radians(lat))) <= r: out.append((la, lo))
return out
def tile_path(la, lo): return COP / f"Copernicus_DSM_COG_10_{'N' if la >= 0 else 'S'}{abs(la):02d}_00_{'E' if lo >= 0 else 'W'}{abs(lo):03d}_00_DEM.tif"
def cache_key(*parts):
"""Short digest of everything a cached array was computed from. A cache file is read only under the key of its own inputs, so a changed preset field, tile list or baker constant never meets a stale file."""
h = hashlib.sha256()
for q in parts: h.update(q.tobytes() if isinstance(q, np.ndarray) else repr(q).encode()); h.update(b'|')
return h.hexdigest()[:12]
def mosaic_key(p): return cache_key('mosaic.v1', sorted(tiles_for(p)), 3600)
_mos = {}
def mosaic(p):
"""Tile grid covering the circle (bounding rows/cols), cached as .npy per preset. Missing tiles (open sea has no GLO-30 tile) are zero."""
key = (p['id'], mosaic_key(p))
if key in _mos: return _mos[key]
ts = tiles_for(p); las = [t[0] for t in ts]; los = [t[1] for t in ts]; lat_top = max(las) + 1; lon_left = min(los)
c = B / f"mosaic-{p['id']}-{key[1]}.npy"
if c.exists(): m = np.load(c, mmap_mode='r')
else:
rows = []
for la in range(max(las), min(las) - 1, -1):
rows.append(np.concatenate([tifffile.imread(tile_path(la, lo)) if tile_path(la, lo).exists() else np.zeros((3600, 3600), np.float32) for lo in range(min(los), max(los) + 1)], axis=1))
m = np.concatenate(rows, axis=0).astype(np.float32); np.save(c, m)
_mos[key] = (m, lat_top, lon_left); return _mos[key]
def bil(a, lat_top, lon_left, lat, lon):
y = (lat_top - lat) * 3600 - 0.5; x = (lon - lon_left) * 3600 - 0.5
y0 = np.clip(np.floor(y).astype(int), 0, a.shape[0] - 2); x0 = np.clip(np.floor(x).astype(int), 0, a.shape[1] - 2)
fy = np.clip(y - y0, 0, 1); fx = np.clip(x - x0, 0, 1)
return (a[y0, x0] * (1 - fx) * (1 - fy) + a[y0, x0 + 1] * fx * (1 - fy) + a[y0 + 1, x0] * (1 - fx) * fy + a[y0 + 1, x0 + 1] * fx * fy)
def configure(p):
"""Set the module state for a preset: observer, eye elevation (DEM ground + eye height, or ground_override), window azimuth."""
global P
P = p; m, lt, ll = mosaic(p)
ground = p['ground_override_m'] if 'ground_override_m' in p else float(bil(m, lt, ll, np.array(p['lat']), np.array(p['lon'])))
P['ground_m'] = ground; P['eye_m'] = ground + p['eye_h_m']
if 'look_at' in p:
la, lo = p['look_at']; P['az_center'] = (math.degrees(math.atan2((lo - p['lon']) * math.cos(math.radians(p['lat'])), la - p['lat'])) + 360) % 360
else: P['az_center'] = p['az_deg']
P['win0'] = int(round(P['az_center'] / 360 * W - WIN / 2)) % W
return P
def win(img): return np.take(img, (P['win0'] + np.arange(WIN)) % W, axis=1)
# ====================================================================== authored sub-DEM foreground detail (Aetheria / CultMath ridged multifractal)
def _frac(v): return v - np.floor(v)
def _hash2(x, y): return _frac(np.sin(x * 127.1 + y * 311.7) * 43758.5453) # cultmath_hash(vec2), as Ratatoskr noise.ts
def _vnoise2(x, y): # cultmath_value_noise: 4 hashed corners, smoothstep blend
cx, cy = np.floor(x), np.floor(y); u = x - cx; v = y - cy; u = u * u * (3 - 2 * u); v = v * v * (3 - 2 * v)
a, b, c, d = _hash2(cx, cy), _hash2(cx + 1, cy), _hash2(cx, cy + 1), _hash2(cx + 1, cy + 1)
return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v
LACUN, WEIGHT_GAIN, AMP_GAIN, CRAG = 2.07, 2.0, 0.5, 0.5 # ridgeWorld.ts constants
DETAIL = dict(cell_m=60.0, octaves=4, amp_m=24.0, fade_from_m=500.0, fade_to_m=4000.0, seed=17)
def detail_height(E, N, dist):
"""Ridged multifractal (ridgeWorld.ts `profile`, folded value noise weighted by the previous octave) in WORLD metres (east, north of the observer),
so it is automatically periodic around the ring. First octave wavelength = 60 m = two GLO-30 cells (nothing coarser than the DEM can resolve is
touched), then x2.07. Amplitude fades to zero between fade_from and fade_to metres of distance. Seeded, deterministic, authored."""
cfg = DETAIL; e = 0.03 + (1 - CRAG) * 0.4; top = math.sqrt(1 + e * e); s = 0.0; nrm = 0.0; amp = 1.0; f = 1.0 / cfg['cell_m']; w = 1.0
for k in range(cfg['octaves']):
v = 2 * _vnoise2(E * f + cfg['seed'] * 7.31, N * f + cfg['seed'] * 3.17 + k * 13) - 1
sig = (1 - np.sqrt(v * v + e * e) / top) ** 2 * w; w = np.minimum(1.0, sig * WEIGHT_GAIN); s = s + sig * amp; nrm += amp; amp *= AMP_GAIN; f *= LACUN
t = np.clip((dist - cfg['fade_from_m']) / (cfg['fade_to_m'] - cfg['fade_from_m']), 0, 1); fade = 1 - t * t * (3 - 2 * t)
return cfg['amp_m'] * fade * (s / nrm - 0.3)
# ====================================================================== stage 0: march
def schedule(detail):
d = [500.0]
while d[-1] < P['rmax_km'] * 1000:
d.append(d[-1] + min(250.0, max(10.0 if detail else 40.0, 0.004 * d[-1])))
return np.array(d)
def march(detail=False, curvature=True):
m, lt, ll = mosaic(P); az = np.radians((np.arange(W) + 0.5) / W * 360.0); d = schedule(detail); cosl = math.cos(math.radians(P['lat']))
A = np.zeros((len(d), W), np.float32); Hh = np.zeros_like(A); sa, ca = np.sin(az), np.cos(az)
for k, dk in enumerate(d):
lat = P['lat'] + dk * ca / 111320.0; lon = P['lon'] + dk * sa / (111320.0 * cosl)
h = bil(m, lt, ll, lat, lon)
if detail and dk < DETAIL['fade_to_m']: h = h + detail_height(dk * sa, dk * ca, dk)
drop = dk * dk / (2 * R_EARTH) * (1 - KREF) if curvature else 0.0
A[k] = np.degrees(np.arctan2(h - P['eye_m'] - drop, dk)); Hh[k] = h
return d, A, Hh
def rays_key(detail):
"""Inputs of the ray march: observer, eye elevation, range, the DEM mosaic, the sample schedule (hashed as the array it produces), the Earth / refraction constants, the ring width, and (detail) the foreground detail noise."""
return cache_key('rays.v1', P['lat'], P['lon'], P['rmax_km'], round(float(P['eye_m']), 3), mosaic_key(P), schedule(detail), R_EARTH, KREF, W, bool(detail),
(sorted(DETAIL.items()), LACUN, WEIGHT_GAIN, AMP_GAIN, CRAG) if detail else None)
def stage0(detail=False, force=False):
c = B / f"rays-{P['id']}{'-detail' if detail else ''}-{rays_key(detail)}.npz"
if c.exists() and not force:
z = np.load(c); return z['d'], z['ang'], z['h'], None
t0 = time.time(); mosaic(P); t1 = time.time(); d, A, Hh = march(detail); t2 = time.time(); np.savez(c, d=d, ang=A, h=Hh)
return d, A, Hh, dict(mosaic=t1 - t0, march=t2 - t1, samples=len(d))
# ====================================================================== periodic helpers
def blurw(a, s):
r = int(3 * s) + 1; k = np.exp(-0.5 * (np.arange(-r, r + 1) / s) ** 2); k /= k.sum()
a = np.asarray(a, np.float64); xp = np.concatenate([a[..., -r:], a, a[..., :r]], axis=-1); o = np.zeros_like(a)
for i, kv in enumerate(k): o += kv * xp[..., i:i + a.shape[-1]]
return o
def gradw(a): return (np.roll(a, -1) - np.roll(a, 1)) / 2.0
def _smooth(f): return f * f * (3 - 2 * f)
def pn2(cell, seed):
rng = np.random.default_rng(seed); n = max(int(round(W / cell)), 2); gh = int(H / cell) + 3
g = rng.random((gh, n)).astype(np.float32)
xs = ((np.arange(W) + ROT) % W) * (n / W); x0 = xs.astype(int); fx = _smooth(xs - x0).astype(np.float32); x1 = (x0 + 1) % n
ys = np.arange(H) / cell; y0 = ys.astype(int); fy = _smooth(ys - y0).astype(np.float32)
tp = g[y0][:, x0] * (1 - fx) + g[y0][:, x1] * fx; bt = g[y0 + 1][:, x0] * (1 - fx) + g[y0 + 1][:, x1] * fx
return tp * (1 - fy)[:, None] + bt * fy[:, None]
def pn1(cell, seed): return pn2(cell, seed)[0].astype(np.float64)
def abscol(): return (np.arange(W) + ROT) % W
# ====================================================================== sweep (D), wrapped
NH = 24 # samples of the face history kept per peak (valley -> crest, uniform in elevation angle)
def strip_history(a, dl, hh, k0, k1):
"""The marcher's record of one peak strip: the ray samples from the last local minimum (k0) to the peak (k1), reduced to what the eye sees (a sample hides
behind any earlier, higher one: running maximum of the angle) and resampled at NH uniform steps of elevation angle. Returns (distance[NH], height[NH]), index 0 = valley, NH-1 = crest."""
seg = np.asarray(a[k0:k1 + 1]); runmax = np.maximum.accumulate(seg); vis = np.concatenate([[True], runmax[1:] > runmax[:-1]])
va = runmax[vis]; vd = np.asarray(dl[k0:k1 + 1])[vis]; vh = np.asarray(hh[k0:k1 + 1])[vis]
if len(va) < 2 or va[-1] <= va[0]: return np.full(NH, vd[-1]), np.full(NH, vh[-1])
t = np.linspace(va[0], va[-1], NH); return np.interp(t, va, vd), np.interp(t, va, vh)
def extract(d, ang, hgt, prom=PROM_M):
"""Per column: (peak_ang, valley_ang, peak_d, peak_h, valley_h, valley_d) followed by the face history (NH distances, NH heights) of the strip valley -> peak.
A peak commits when the ray has fallen hy[k] below it and it stands `prom` METRES above the lowest ground the strip has seen (the lowest height, not the height of the lowest-ANGLE sample: that one is often near and high)."""
K, Wc = ang.shape; hy = np.degrees(prom / d).tolist(); dl = d.tolist(); cols = []
for x in range(Wc):
a = ang[:, x].tolist(); hh = hgt[:, x].tolist(); out = []
def span(pk, lo, pd, ph, vh, vd, k0, k1): out.append((pk, lo, pd, ph, vh, vd) + tuple(np.concatenate(strip_history(a, dl, hh, k0, k1))))
runmax = -90.0; mn = a[0]; mnh = hh[0]; mnd = dl[0]; mnk = 0; cand = a[0]; ch = hh[0]; cd = dl[0]; ck = 0; lowh = hh[0]; capped = False # NEAR_CAP is tried once, at the first sample past 4 km
for k in range(1, K):
v = a[k]; dk = dl[k]; hk = hh[k]; lowh = min(lowh, hk)
if dk >= NEAR_CAP and not capped and not out and runmax < -80:
capped = True; j = max(range(k), key=a.__getitem__)
if hh[j] - lowh >= prom: span(a[j], mn, dl[j], hh[j], mnh, mnd, min(mnk, j), j); runmax = a[j]; mn = cand = v; mnh = ch = hk; mnd = cd = dk; mnk = ck = k; lowh = hk
if v < mn: mn = cand = v; mnh = ch = hk; mnd = cd = dk; mnk = ck = k
elif v > cand: cand = v; ch = hk; cd = dk; ck = k
if cand - v >= hy[k] and ch - lowh >= prom:
if cand > runmax: span(cand, mn, cd, ch, mnh, mnd, mnk, ck); runmax = cand
mn = cand = v; mnh = ch = hk; mnd = cd = dk; mnk = ck = k; lowh = hk
if ch - lowh >= prom and cand > runmax: span(cand, mn, cd, ch, mnh, mnd, mnk, ck)
cols.append(out)
return cols
def chains(cols):
Wc = len(cols); parent = []; ids = []
for sp in cols: ids.append(list(range(len(parent), len(parent) + len(sp)))); parent.extend(range(len(parent), len(parent) + len(sp)))
def find(i):
while parent[i] != i: parent[i] = parent[parent[i]]; i = parent[i]
return i
for x, sp in enumerate(cols):
for i, s in zip(ids[x], sp):
p, dd = s[0], s[2]; best = None
for back in range(1, LOOK + 1):
xb = (x - back) % Wc
for j, s2 in zip(ids[xb], cols[xb]):
ld = abs(math.log(dd / s2[2]))
if ld < LINK_D and abs(p - s2[0]) < LINK_A * (1 + 0.5 * (back - 1)):
sc = ld / LINK_D + abs(p - s2[0]) / LINK_A + 0.3 * back
if best is None or sc < best[0]: best = (sc, j)
if best: parent[find(i)] = find(best[1])
out = {}
for x, sp in enumerate(cols):
for i, s in zip(ids[x], sp): out.setdefault(find(i), []).append((x,) + tuple(s))
return list(out.values())
def chain_layer(c):
"""Chain -> dense per-column arrays (deg / m). The chain is unrolled at its largest circular gap into an arc [0, x1] of local columns; every
column outside the arc takes the value of the NEAREST arc end (start end or finish end) and then dies toward that end's valley over TAPER columns."""
a = np.array(c); xs = a[:, 0].astype(int); u = np.unique(xs)
gaps = np.diff(np.concatenate([u, [u[0] + W]])); start = int(u[(gaps.argmax() + 1) % len(u)]); xl = (xs - start) % W
Pk, L, D, PH, VH, VD = (a[:, i] for i in range(1, 7)); HD = a[:, 7:7 + NH]; HH = a[:, 7 + NH:7 + 2 * NH]
run = np.maximum(np.abs(D - VD), 300.0); slope = np.degrees(np.arctan2(np.maximum(PH - VH, 0), run))
x1 = int(xl.max()); ii = np.arange(0, x1 + 1); res = {}; x = np.arange(W)
near_end = np.where(x - x1 <= W - x, x1, 0) # outside the arc: which end is nearer going around the ring
dist = np.where(x <= x1, 0, np.minimum(x - x1, W - x)).astype(float)
def dense(v):
full = np.full(W, np.nan); full[xl] = v; ok = ~np.isnan(full[ii]); full[ii] = np.interp(ii, ii[ok], full[ii][ok]); return np.where(x <= x1, full, full[near_end])
for k, v in dict(top=Pk, lo=L, ph=PH, vh=VH, slope=slope, pd=D, vd=VD).items(): res[k] = dense(v)
res['hd'] = np.stack([dense(HD[:, j]) for j in range(NH)]); res['hh'] = np.stack([dense(HH[:, j]) for j in range(NH)]) # the face history, one (NH, W) array each: column x carries the history of the peak that owns it
drop = res['top'] - np.minimum(res['lo'], res['top'] - 0.4); Tn = taper_len(drop) # end drop in degrees -> taper length at this column
t = np.clip(dist / Tn, 0, 1); t = t * t * (3 - 2 * t)
res['top'] = res['top'] - drop * t
res['wt'] = 1 - np.clip(dist / Tn, 0, 1)
for k in ('top', 'lo', 'ph', 'vh', 'slope', 'wt', 'pd', 'vd', 'hd', 'hh'): res[k] = np.roll(res[k], start, axis=-1)
res['dist'] = float(np.median(D)); res['n'] = len(c); res['x0'] = start; res['x1'] = (start + x1) % W; res['span'] = x1 + 1
res['rise_ang'] = float(np.median(Pk - L)); res['rise_h'] = float(np.median(PH - VH)); res['slope_med'] = float(np.median(slope))
return res
def col_gap(present):
"""Circular distance in columns from each column to the nearest present column (0 where present)."""
x = np.arange(3 * W); p = np.tile(present, 3); last = np.maximum.accumulate(np.where(p, x, -10 * W)); nxt = np.minimum.accumulate(np.where(p, x, 10 * W)[::-1])[::-1]
return np.minimum(x - last, nxt - x)[W:2 * W].astype(float)
def to_slots(layers):
slots = {}
for L in layers: slots.setdefault(int(np.argmin(np.abs(np.log(KNOTS) - math.log(L['dist'])))), []).append(L)
out = []
for s in sorted(slots):
g = slots[s]; top = np.stack([np.where(l['wt'] > 0, l['top'], -1e3 - col_gap(l['wt'] > 0)) for l in g]); j = top.argmax(0) # only chains that are present in a column compete; where none is, the chain whose extent is NEAREST stands (a geometric rule: the list order of chains depends on where column 0 is, and absent-column values feed the blurs at every chain end)
S = dict(slot=s, dist=float(KNOTS[s]), chains=len(g), dists=[l['dist'] for l in g])
for k in ('top', 'lo', 'ph', 'vh', 'slope', 'wt', 'pd', 'vd'): S[k] = np.take_along_axis(np.stack([l[k] for l in g]), j[None], 0)[0]
for k in ('hd', 'hh'): S[k] = np.take_along_axis(np.stack([l[k] for l in g]), j[None, None], 0)[0]
out.append(S)
return out
def sweep(d, ang, hgt, prom=PROM_M):
t0 = time.time(); cols = extract(d, ang, hgt, prom); t1 = time.time()
ch = chains(cols); near = lambda c: np.median([t[3] for t in c]) < FOREGROUND_M; keep = [c for c in ch if len(c) >= MINW]
def arc(c):
u = np.unique([t[0] for t in c]); g = np.diff(np.concatenate([u, [u[0] + W]])); i = g.argmax(); st = u[(i + 1) % len(u)]; return (st + np.arange((u[i] - st) % W + 1)) % W # the columns a chain spans, unrolled at its largest gap
cov = np.zeros(W, bool)
for c in keep:
if near(c): cov[(arc(c)[:, None] + np.arange(-MINW // 4, MINW // 4 + 1)[None, :]).ravel() % W] = True # dilated by MINW/4 columns: a stub that occludes a near ridge for a few columns sits inside it
for c in sorted((c for c in ch if len(c) < MINW and near(c)), key=lambda c: -len(c)): # widest first: a stub is judged against the stubs already kept
xs = arc(c)
if not cov[xs].any(): keep.append(c); cov[xs] = True
lay = [chain_layer(c) for c in keep]; slots = to_slots(lay); t2 = time.time()
return dict(cols=cols, nchains=len(ch), keep=lay, slots=slots, raw=sum(len(c) for c in cols), t_extract=t1 - t0, t_chain=t2 - t1)
# ====================================================================== the authored Y mapping
def set_view(slots, sky_row=0.04, near_row=0.66):
"""AUTHORED vertical mapping, linear in elevation angle: row = (TOP_DEG - angle) * PXDEG_V over the whole 600-row frame (cylindrical, horizontal 17.78 px/deg).
Two anchors: the highest ridge top in the window sits at `sky_row` (4%) of the frame height, the LOWEST crest of the nearest layer in the window sits at `near_row` (66%);
everything below is that layer's body, its foot, and the ground-plane band. PXDEG_V = (near_row - sky_row) * 600 / (highest top - lowest near crest), clamped to
[17.78, 178] px/deg = vertical exaggeration 1x..10x. TOP_DEG = highest top + sky_row*600/PXDEG_V; BOTTOM_DEG = TOP_DEG - 600/PXDEG_V; horizon (angle 0) at row TOP_DEG*PXDEG_V."""
global rowang, PXDEG_V, TOP_DEG, BOTTOM_DEG
cols = (P['win0'] + np.arange(WIN)) % W; sky = max(float(S['top'][cols][S['wt'][cols] > 0].max()) for S in slots if (S['wt'][cols] > 0).any())
near = slots[-1]; act = near['wt'][cols] > 0; c_low = float(blurw(near['top'], 3.0)[cols][act].min()) if act.any() else sky - 2.0
ppd = float(np.clip((near_row - sky_row) * H / max(sky - c_low, 0.05), HPX_DEG, 10 * HPX_DEG))
PXDEG_V = ppd; TOP_DEG = sky + sky_row * H / ppd; BOTTOM_DEG = TOP_DEG - H / ppd; rowang = TOP_DEG - (np.arange(H) + 0.5) / PXDEG_V
return dict(top_deg=TOP_DEG, bottom_deg=BOTTOM_DEG, px_per_deg=PXDEG_V, exaggeration=PXDEG_V / HPX_DEG, horizon_row=TOP_DEG * PXDEG_V, sky_deg=sky, near_crest_low_deg=c_low,
sky_row=sky_row, near_row=near_row)
# ====================================================================== bake (sun-independent), per layer
def azrad(): return (abscol() + 0.5) / W * 2 * math.pi
def face_field(hd, hh, ang, lo, pd, ph, vd, vh):
"""Per-pixel face from the marcher's history (hd, hh: NH distances / heights per column, valley -> crest). A pixel at elevation `rowang` of column x sits at fraction
f = (rowang - lo) / (ang - lo) of the strip; its distance and height are the history interpolated at f. Front slope s = dh/dd along the ray comes from the history over +-2 nodes
(the strip's mean slope where the window has no run); the lateral tilt is the height change between the neighbouring columns at the SAME pixel row, corrected for the radial
step (c - a*s) / (tangential length). n ~ (-s r - la t, 1). Returns Z, D, s, la, f as (H, W) arrays."""
f = np.clip((rowang[:, None] - lo[None, :]) / np.maximum(ang - lo, 1e-3)[None, :], 0, 1); pos = f * (NH - 1); i0 = np.minimum(pos.astype(int), NH - 2); t = pos - i0; xs = np.arange(W)[None, :]
lerp = lambda A: A[i0, xs] * (1 - t) + A[i0 + 1, xs] * t
lo_i, hi_i = np.clip(np.arange(NH) - 2, 0, NH - 1), np.clip(np.arange(NH) + 2, 0, NH - 1)
dd = hd[hi_i] - hd[lo_i]; mean_s = ((ph - vh) / np.maximum(np.abs(pd - vd), 300.0))[None, :]
sn = np.where(dd >= 20.0, (hh[hi_i] - hh[lo_i]) / np.maximum(dd, 20.0), mean_s)
Z, Dp, sl = lerp(hh), lerp(hd), lerp(sn)
a_ = np.roll(Dp, -1, axis=1) - np.roll(Dp, 1, axis=1); c_ = np.roll(Z, -1, axis=1) - np.roll(Z, 1, axis=1)
b_ = np.maximum(2 * COLW * Dp, 1.0); la = np.where(np.abs(a_) > 3 * b_, 0.0, np.clip((c_ - a_ * sl) / b_, -1.5, 1.5)) # neighbours at very different distances are an occlusion edge, not a surface: no tilt
return Z.astype(np.float32), Dp, blurw(sl, 2.0).astype(np.float32), blurw(la, 2.0).astype(np.float32), f
def bake_layer(S, rank, far, near=False):
b = S['slot']; base = RAMP[b]; reg = int(round((rank - 2) * 1.5)); R_ = lambda v: np.roll(v, reg, axis=-1); px = 1.0 / PXDEG_V
s_ref = P.get('s_ref_deg', 8.0)
ang = blurw(S['top'], 2.5) + (pn1(38, 100 + b) - 0.5) * 6 * px + (pn1(7, 200 + b) - 0.5) * 2 * px; ang = R_(ang) # edge wobble authored in pixels
lo = R_(blurw(S['lo'], 2.5)); ph = R_(blurw(S['ph'], 2.0)); vh = R_(blurw(S['vh'], 2.0))
sn = np.clip(R_(blurw(S['slope'], 3.0)) / s_ref, 0, 1); wt = R_(S['wt'])
hd = R_(blurw(S['hd'], 2.0)); hhist = R_(blurw(S['hh'], 2.0)); pd_ = R_(blurw(S['pd'], 2.0)); vd_ = R_(blurw(S['vd'], 2.0))
top = ang[None, :] - rowang[:, None]; toppx = (top * PXDEG_V).astype(np.float32)
m = np.clip(toppx / 1.6 + 0.5, 0, 1) * np.clip(blurw((wt > 0).astype(float), 1.0) * 1.5, 0, 1)[None, :] # a layer is drawn only where a chain exists (or tapers)
rise_px = np.clip((ang - blurw(lo, 12.0)) * PXDEG_V, 1, None) # foot length from a smoothed valley: no per-column curtains
foot = np.clip(0.85 * rise_px, 28 * np.clip(wt, 0, 1), 210).clip(1) # a tapering end carries no 28 px curtain: the body shrinks with the chain's weight, so a chain end leaves no stub
if near: foot = np.maximum(foot, np.clip(ang * PXDEG_V, 0, None) / FADE_AT) # the nearest layer is the ground in front of the eye: its body stays opaque down to the horizon, whatever the strip's own valley says; it dissolves only below that (above the horizon a dissolved body shows the sky under a crest, and the valley angle jumps where the winning chain changes, so the sky showed as a wedge with a hard edge)
depthpx = np.clip(toppx / foot[None], 0, 1)
fade = np.clip((depthpx - FADE_AT) / (1 - FADE_AT), 0, 1); fade = fade * fade * (3 - 2 * fade); alpha = (m * (1 - fade)).astype(np.float32) # the body dissolves at its foot: what is behind (other layers, floor, sky) shows, so a column boundary leaves no wall down the frame
w = (depthpx ** 0.8 * 0.92).astype(np.float32) # weight of the foot colour (floor/haze mix, decided at relight)
face = np.broadcast_to((base * 1.12).astype(np.float32), (H, W, 3)).copy()
Z, Dp, sl, la, f = face_field(hd, hhist, ang, lo, pd_, ph, vd_, vh)
snp = np.clip(np.degrees(np.arctan(sl)) / s_ref, 0, 1) # per-pixel face steepness from the history (the column scalar `sn` stays for stroke weights only)
face *= (1.07 - 0.22 * snp)[..., None]
mot = 0.55 * pn2(22, 300 + b) + 0.45 * pn2(5, 400 + b)
face *= (0.90 + 0.18 * mot[..., None]) * (0.96 + 0.08 * pn2(1, 500 + b)[..., None])
echo = np.clip(((ang - 24 * px * (1 + rank * .3))[None, :] - rowang[:, None]) * PXDEG_V / 1.6 + .5, 0, 1)
face *= (1 - 0.13 * echo[..., None] * (1 - depthpx[..., None] * .5))
ink = base * 0.42 + np.array([0.02, 0.03, 0.08])
ew = (0.7 + 1.8 * snp) * (1 - 0.35 * far)
edge = np.clip(1 - np.abs(toppx - 0.6 - ew * 0.5) / ew, 0, 1) * (toppx > -0.5)
ea = 0.55 * (1 - 0.45 * far) * edge * (0.4 + 0.6 * snp)
dzdpx = np.maximum(np.abs(np.roll(Z, -1, axis=0) - np.roll(Z, 1, axis=0)) / 2.0, 1e-3); dzdpx[0] = dzdpx[1]; dzdpx[-1] = dzdpx[-2] # metres of true height per pixel row, per pixel
faceok = (f > 0.02) & (f < 0.98) & (wt[None, :] > 0.5)
cm = next((c for c in CONTOUR_NICE if c >= CONTOUR_MIN_PX * float(np.percentile(dzdpx[faceok], 75) if faceok.any() else 3.0)), CONTOUR_NICE[-1]) # metres per contour line: lines >= 9 px apart over three quarters of this layer's face
p_ = (Z / cm) % 1.0; dpx = (np.minimum(p_, 1 - p_) * cm / dzdpx).astype(np.float32)
face_mask = ((top > 0.003) & (rowang[:, None] > lo[None, :])).astype(np.float32)
ca = (0.95 * (1 - 0.45 * far) * (1 - depthpx) ** 2.0 * wt[None, :] * (0.6 + 0.4 * snp) * face_mask).astype(np.float32)
hatch = np.zeros((H, W), np.float32)
if far < 0.8:
rng = np.random.default_rng(700 + b); xs = np.cumsum(rng.integers(2, 7, W // 2)); xs = xs[xs < W]
up = gradw(ang) * PXDEG_V
for i, xa in enumerate(xs):
x = (xa - ROT) % W; u1, u2 = rng.random(2)
if u1 > 0.30 + 0.70 * sn[x] or wt[x] < 0.5: continue
Lpx = (8 + 50 * sn[x] ** 0.7) * (0.55 + 0.7 * u2) * (1 - 0.5 * far)
y0 = (rowang[0] - ang[x]) * PXDEG_V; yy = np.arange(H); t = (yy - y0 - 2) / max(Lpx, 1)
ok = (t >= 0) & (t < 1); xx = (x + np.round(-np.clip(up[x] * 0.25, -0.6, 0.6) * (yy - y0)).astype(int)) % W
hatch[yy[ok], xx[ok]] = np.maximum(hatch[yy[ok], xx[ok]], (1 - t[ok]) ** 1.5)
ha = np.clip(ea + hatch * 0.50 * (1 - 0.4 * far), 0, 1).astype(np.float32)
rb = (np.clip(1 - toppx / 7.0, 0, 1) * (toppx > -0.5)).astype(np.float32)
az = azrad(); dd = np.stack([S['vd'], 0.5 * (S['vd'] + S['pd']), S['pd']]); hh = np.stack([S['vh'], 0.5 * (S['vh'] + S['ph']), S['ph']]) + 3.0 # face samples: valley, middle, crest
spts = dict(lat=np.roll(P['lat'] + dd * np.cos(az) / 111320.0, reg, axis=1), lon=np.roll(P['lon'] + dd * np.sin(az) / (111320.0 * math.cos(math.radians(P['lat']))), reg, axis=1), h=np.roll(hh, reg, axis=1))
return dict(m=m.astype(np.float32), a=alpha, face=face, w=w, dpx=dpx, ca=ca, ha=ha, rb=rb, mot=mot.astype(np.float32), sl=sl, la=la, ink=ink, far=far, dist=S['dist'], rank=rank, ang=ang, lo=lo, spts=spts)
def bake(slots):
return [bake_layer(S, rank, float(np.clip((math.log(S['dist']) - math.log(2000)) / (math.log(70000) - math.log(2000)), 0, 1)), near=rank == len(slots) - 1) for rank, S in enumerate(slots)]
# ====================================================================== cast shadows (sun-dependent): DEM march toward the sun from each layer's face samples
SH_STEPS = 40.0 * 1.1 ** np.arange(0, 66) # 40 m .. 20 km, geometric
def cast_shadows(layers, az_s, el_s):
"""Per layer a (3, W) array in [0,1]: how much of the sun is blocked by the real DEM at that layer's valley / middle / crest sample. One vectorised march for all
layers. Soft: the blocked fraction is the terrain excess over the sun ray, over a penumbra of 6 m + 3% of the distance travelled. Sun at or below 0.3 deg: no direct light, no shadow."""
if el_s <= 0.3: return [np.zeros((3, W)) for _ in layers]
m, lt, ll = mosaic(P); lat = np.concatenate([Ly['spts']['lat'].ravel() for Ly in layers]); lon = np.concatenate([Ly['spts']['lon'].ravel() for Ly in layers]); h = np.concatenate([Ly['spts']['h'].ravel() for Ly in layers])
t = math.tan(math.radians(el_s)); cosl = math.cos(math.radians(P['lat'])); ca, sa = math.cos(math.radians(az_s)), math.sin(math.radians(az_s)); sh = np.zeros_like(h)
for s in SH_STEPS:
terr = bil(m, lt, ll, lat + s * ca / 111320.0, lon + s * sa / (111320.0 * cosl)) - s * s / (2 * R_EARTH)
sh = np.maximum(sh, np.clip((terr - (h + s * t)) / (6.0 + 0.03 * s), 0, 1))
return [a.reshape(3, W) for a in np.split(sh, len(layers))]
# ====================================================================== relight (sun-dependent)
TAU_Z = np.array([0.09, 0.14, 0.30]); BETA_R = np.array([0.0058, 0.0135, 0.0331]); BETA_M = 0.023; ATM = 0.15
ATM_FLOOR = 1.2 # authored: valley haze over the ground plane is heavier than the layer extinction ATM, so the floor thins into haze gradually
G_MIE, W_MIE = 0.7, 0.10; REF = dict(az=180.0, el=62.0); REF_AMB = np.array([0.62, 0.74, 1.0])
def airmass(el): e = max(el, 0.3); return 1.0 / (math.sin(math.radians(e)) + 0.50572 * (e + 6.07995) ** -1.6364)
def sun_colour(el): return np.exp(-TAU_Z * airmass(el))
def phase(c):
pr = 3.0 / (16 * math.pi) * (1 + c * c); pm = (1 - G_MIE ** 2) / (4 * math.pi * (1 + G_MIE ** 2 - 2 * G_MIE * c) ** 1.5)
return (1 - W_MIE) * pr + W_MIE * pm
def sun_vec(az, el): a, e = math.radians(az), math.radians(el); return np.array([math.sin(a) * math.cos(e), math.cos(a) * math.cos(e), math.sin(e)])
def EX_REF_NORM(Ec, I, amb_k): return float((0.45 * REF_AMB * amb_k + 0.75 * Ec * I * 0.7).mean()) / 0.87
def sun_state(az, el):
Ec = sun_colour(el) / sun_colour(REF['el']); I = (max(math.sin(math.radians(el)), 0.0) / math.sin(math.radians(REF['el']))) ** 0.5
tw = 0.30 * float(np.clip((el + 6) / 8, 0, 1)) ** 2; amb_k = 0.12 + 0.88 * float(np.clip((el + 3) / 45, 0, 1)) ** 0.7
ex = 0.45 + 0.55 * float(np.clip((el + 4) / 18, 0, 1)) ** 0.6
return dict(az=az, el=el, L=sun_vec(az, el), sun=Ec * I, sky_sun=Ec * (I + tw), amb=REF_AMB * amb_k, amb_k=amb_k, ex=ex / EX_REF_NORM(Ec, I, amb_k), ex_t=ex)
def _g0():
st = sun_state(REF['az'], REF['el']); d = 0.7; g = (0.45 * st['amb'] + 0.75 * st['sun'] * d) * (np.array([0.90, 0.92, 1.04]) + (np.array([1.03, 1.0, 0.95]) - np.array([0.90, 0.92, 1.04])) * d)
return g / g.mean()
G0 = _g0()
def col_scatter(st):
az = azrad(); c = math.cos(math.radians(st['el'])) * np.cos(az - math.radians(st['az']))
pr = np.array([phase(float(x)) for x in c]); g = np.clip((pr / phase(0.0)) ** 0.35, 0.5, 2.4)
rs = sun_state(REF['az'], REF['el']); ref = 0.8 * rs['sky_sun'] + 0.35 * rs['amb']; ref = ref / ref.mean()
col = 0.8 * st['sky_sun'] + 0.35 * st['amb']; col = col / col.mean()
return c, g[:, None] * (col / ref)[None, :]
def hue_gain(st, d):
lit_tint = np.array([1.03, 1.0, 0.95]); shd_tint = np.array([0.90, 0.92, 1.04])
tint = shd_tint + (lit_tint - shd_tint) * d
return (0.45 * st['amb'] + 0.75 * st['sun'] * d) * tint / G0 * st['ex']
def floor_img(st, hazec, fmot):
"""Ground-plane band: below the horizon a flat floor `floor_agl_m` below the eye; plane distance dg = h / tan(depression); colour = ground albedo lit by an
up-facing normal, thinned into haze by the same Beer-Lambert extinction as the layers. Above the horizon the same array is the haze (sky covers it)."""
a = rowang; dg = P['floor_agl_m'] / np.tan(np.radians(np.clip(-a, 0.002, 89.0)))
T = np.exp(-(BETA_R + BETA_M)[None, :] * (dg / 1000.0)[:, None] * ATM_FLOOR); T[a >= 0] = 0.0
ground = (GROUND * hue_gain(st, float(np.clip((math.sin(math.radians(st['el'])) + 0.2) / 1.2, 0, 1))))
return ground[None, None, :] * fmot[..., None] * T[:, None, :] + hazec[None, :, :] * (1 - T)[:, None, :]
def ndl_field(Ly, L):
"""n . L per pixel from the baked face: n ~ (-sl r - la t, 1), r / t the column's radial / tangential directions. Returns (ndl, |n|-normaliser)."""
az = azrad(); sa, ca_ = np.sin(az), np.cos(az); sl, la = Ly['sl'], Ly['la']
Lr = (L[0] * sa + L[1] * ca_)[None, :]; Lt = (L[0] * ca_ - L[1] * sa)[None, :]; nrm = np.sqrt(1 + sl * sl + la * la)
return (L[2] - sl * Lr - la * Lt) / nrm, nrm
RELIEF_OFF_DEG, RELIEF_EL = 45.0, 32.0 # authored relief light: RELIEF_OFF_DEG off the view axis, RELIEF_EL up
def win_centre_az(): return (P['win0'] + WIN / 2) / W * 360.0
def relief_vec(sun_az):
"""The authored relief light direction (toward the light). The real sun lights a face the viewer looks at only when it is off to one side; with the sun behind the viewer every face is
head-on and the mountain goes flat. Shading n.L (ndl_field, rim, spec) therefore uses this fixed direction: azimuth = the view centre az +/- RELIEF_OFF_DEG, on the left/right side of the view axis
that the real sun is on (so the two agree where they can), RELIEF_EL up. Sun colour/strength, haze, colour temperature and cast shadows keep the real sun:
the relief only decides where on the form the sun's light falls."""
ctr = win_centre_az(); side = 1.0 if math.sin(math.radians(sun_az - ctr)) >= 0 else -1.0
return sun_vec(ctr + side * RELIEF_OFF_DEG, RELIEF_EL)
def fresnel(sl, nrm): return np.clip(1 - sl / nrm, 0, 1) ** 3 # (1 - n . V)^3, V = -r: strongest on faces that lie away from the viewer
def spec_field(L, sl, la, nrm, ndl):
"""GGX-like highlight (alpha 0.3) of the face (sl, la, nrm as in ndl_field) for the light L: half vector between L and the direction back to the viewer (-r) in every column."""
az = azrad(); sa, ca_ = np.sin(az), np.cos(az)
Hv = L[None, :] + np.stack([-sa, -ca_, np.zeros(W)], 1); Hv /= np.linalg.norm(Hv, axis=1, keepdims=True)
Hr = (Hv[:, 0] * sa + Hv[:, 1] * ca_)[None, :]; Ht = (Hv[:, 0] * ca_ - Hv[:, 1] * sa)[None, :]
nh = np.clip((Hv[:, 2][None, :] - sl * Hr - la * Ht) / nrm, 0, 1); al = 0.30
D = (al * al) / (1 + nh * nh * (al * al - 1)) ** 2 * al * al
return 0.30 * D * np.clip(ndl, 0, 1)
def relight_layer(Ly, st, hazec, F, sh3=None):
"""Shading per PIXEL (ndl_field), not per column. n.L uses the relief light; the sun's colour, strength (st['sun'] scales every direct term, so night and dusk stay dark) and shadows are the real sun's."""
L = relief_vec(st['az']); az = azrad(); sa, ca_ = np.sin(az), np.cos(az); sl, la = Ly['sl'], Ly['la']
ndl, nrm = ndl_field(Ly, L); d = np.clip((ndl + 0.2) / 1.2, 0, 1)
gain = hue_gain(st, d[..., None])
rim_dir = (np.stack([sa, ca_], 1) * L[None, :2]).sum(1)[None, :]
fres = fresnel(sl, nrm)
rim = (0.12 + 1.0 * np.clip(rim_dir, 0, 1)) * fres
spec = spec_field(L, sl, la, nrm, ndl)
if sh3 is None: sp = np.zeros((1, 1), np.float32); face = Ly['face'] * gain.astype(np.float32)
else:
sh3 = blurw(sh3, 2.0); f = np.clip((rowang[:, None] - Ly['lo'][None, :]) / np.maximum(Ly['ang'] - Ly['lo'], 1e-3)[None, :], 0, 1) # position on the face: 0 valley .. 1 crest
sp = np.where(f < 0.5, sh3[0] + (sh3[1] - sh3[0]) * 2 * f, sh3[1] + (sh3[2] - sh3[1]) * 2 * (f - 0.5)).astype(np.float32)
g_amb = hue_gain(st, 0.0)[None, None, :]; face = Ly['face'] * (g_amb + (gain - g_amb) * (1 - sp)[..., None]).astype(np.float32) # shadow removes the direct share only
fa = 1 - math.exp(-Ly['dist'] / 20000.0) # valley mist grows with the layer's distance
foot = F * (1 - fa) + hazec[None] * fa
wf = Ly['w'] * (1 - 0.6 * fa) # far layers keep 60% of their own body colour at the foot
wf = 1 - (1 - wf) * np.clip(1 + rowang * PXDEG_V / 40.0, 0, 1)[:, None] # below the horizon every layer's foot has merged into the ground plane within 40 px: no per-slot colour seams down the floor
face = face * (1 - wf[..., None]) + foot * wf[..., None]
face += (Ly['rb'] * (rim * 0.55) * (1 - sp))[..., None] * (st['sun'] * 1.1 + 0.25)[None, None, :]
face += ((spec * (0.5 + Ly['mot'])) * (Ly['m'] > 0.5) * (1 - sp))[..., None] * st['sun'][None, None, :] * 0.5
lw = (0.70 + 0.90 * (1 - d)) * (1 - 0.30 * Ly['far'])
line = np.clip(1 - Ly['dpx'] / lw, 0, 1) * Ly['ca']
ia = np.clip(line + Ly['ha'], 0, 1)
ink = Ly['ink'][None, None, :] * (0.6 + 0.4 * gain.mean(-1))[..., None]
face = face * (1 - ia[..., None]) + ink * ia[..., None]
T = np.exp(-(BETA_R + BETA_M) * Ly['dist'] / 1000.0 * ATM)
return face * T[None, None, :] + hazec[None, :, :] * (1 - T)[None, None, :]
def sky_hz(st, paperf):
az = azrad(); c, hg = col_scatter(st)
hz = hx('#f5e2c8')[None, :] * hg * st['ex_t']
tint = (st['amb'] / st['amb'].mean()) / (REF_AMB / REF_AMB.mean()); zen = hx('#a9bcd6')[None, :] * tint[None, :] * st['ex_t']
u = np.clip(rowang / max(TOP_DEG, 2.5), 0, 1)[:, None] ** 0.7
img = zen[None] * u[..., None] + hz[None] * (1 - u)[..., None]
ev = np.radians(rowang)[:, None]; cs = np.cos(ev) * math.cos(math.radians(st['el'])) * np.cos(az - math.radians(st['az']))[None, :] + np.sin(ev) * math.sin(math.radians(st['el']))
ang = np.degrees(np.arccos(np.clip(cs, -1, 1)))
glow = 0.95 * np.exp(-(ang / 4.5) ** 2) + 0.55 * np.exp(-(ang / 22.0) ** 2)
img = img + glow[..., None] * (st['sky_sun'] * 0.8 * st['ex_t'])[None, None, :]
return img * paperf[..., None] / 0.93, hz
def haze_colour(hz): return hz * (hx('#cdd7e7') / hx('#f5e2c8'))[None, :]
def relight(layers, az, el, paperf, grain, fmot, shadows=True, flat=None):
"""Opaque frame with the sky. `flat` (an RGB triple) replaces the sky and the haze colour by that one colour: the stand-in the planes are compared against."""
st = sun_state(az, el); shs = cast_shadows(layers, az, el) if shadows else [None] * len(layers); sky, hz = sky_hz(st, paperf)
hazec = haze_colour(hz)
if flat is not None: hazec = np.broadcast_to(np.asarray(flat, float), (W, 3)); sky = np.broadcast_to(np.asarray(flat, float), sky.shape)
F = floor_img(st, hazec, fmot) * (paperf[..., None] / 0.93)
img = np.where((rowang >= 0)[:, None, None], sky, F).astype(np.float32)
for Ly, sh3 in zip(layers, shs):
face = relight_layer(Ly, st, hazec.astype(np.float32), F.astype(np.float32), sh3); face *= (paperf[..., None] / 0.93)
img = img * (1 - Ly['a'][..., None]) + face * Ly['a'][..., None]
return img * grain[..., None]
def group_slots(n):
"""Plane grouping by slot index: far = slot 0 (+ the floor), near = the last slot, mid = every slot between. Fewer than 3 slots cannot fill three planes."""
if n < 3: raise ValueError(f"a place needs at least 3 layer slots for the far/mid/near planes, this one has {n}")
return dict(far=[0], mid=list(range(1, n - 1)), near=[n - 1])
OCCLUDE_MARGIN_PX = 48 # the runtime slides the planes against each other by up to this much; a farther plane is hidden only where the nearer layers cover it at every offset within it, so a slide never uncovers a hole
def most_open(seen):
"""The most light any offset within OCCLUDE_MARGIN_PX lets through `seen` (the nearer layers' transmittance), eased over the same distance so the knock-out has no edge of its own: a max filter along the columns, then a Gaussian of sigma OCCLUDE_MARGIN_PX / 2; the window's ends repeat their edge column."""
m = OCCLUDE_MARGIN_PX; r = 2 * m; pad = np.pad(seen, ((0, 0), (r, r)), mode='edge')
return np.clip(blurw(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)[:, m:-m], 0, 1)
def relight_planes(layers, az, el, paperf, grain, fmot, shadows=True, ref=None):
"""The frame as three RGBA planes (window columns, float 0..1, straight alpha) with no sky drawn. Every pixel of relight is affine in the haze colour c: out = X + c Y
(the haze enters only as the foot mix and the final Beer-Lambert mix). Render each layer and the floor with c = 0 (X) and c = 1 (X + Y), composite each group over
nothing, and let the haze share become transparency: alpha = coverage - mean(Y), colour = X / alpha. Whatever sky the runtime draws behind then supplies the haze.
The split is exact for one group over a flat sky; stacked planes put a nearer group's haze over the farther ridge instead of over the sky (see the recomposite test for the measured loss)."""
st = sun_state(az, el); shs = cast_shadows(layers, az, el) if shadows else [None] * len(layers); out = {}
below = (rowang < 0)[:, None]; k = (paperf[..., None] / 0.93); runs = []
ref = haze_colour(sky_hz(st, paperf)[1]) if ref is None else np.broadcast_to(np.asarray(ref, float), (W, 3)); ref_w = win(ref[None].astype(np.float32))[0]
for c in (0.0, 1.0):
hc = np.full((W, 3), c, np.float32); F = (floor_img(st, hc, fmot) * k).astype(np.float32)
runs.append((F, [relight_layer(Ly, st, hc, F, sh3) * k for Ly, sh3 in zip(layers, shs)]))
groups = group_slots(len(layers)); names = list(groups); seen = {}
for name, idx in groups.items():
img = [np.where(below[..., None], r[0], 0.0) if name == 'far' else np.zeros_like(r[0]) for r in runs]; cov = np.broadcast_to(below, (H, W)).astype(np.float32) if name == 'far' else np.zeros((H, W), np.float32)
for i in idx:
a = layers[i]['a'][..., None]
for j in (0, 1): img[j] = img[j] * (1 - a) + runs[j][1][i] * a
cov = cov * (1 - a[..., 0]) + a[..., 0]
X = win(img[0]); Yc = win(img[1] - img[0]); Y = Yc.mean(-1); alpha = np.clip(win(cov) - Y, 0, 1)
P = X + ref_w[None] * (Yc - Y[..., None]) # premultiplied colour: exact over the reference haze, per channel
col = np.where(alpha[..., None] > 1e-4, P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None]
seen[name] = win(np.prod([1 - layers[i]['a'] for i in idx], axis=0))
out[name] = np.concatenate([np.clip(col, 0, 1), alpha[..., None]], -1).astype(np.float32)
for i in range(len(names) - 2, -1, -1): # near to far: the opaque frame lets a farther plane through only what the nearer layers leave open (their transmittance `seen`), but the stacked planes let it through by their alpha, which also lost the haze share to transparency; the farther plane's alpha is brought to the opaque frame's
nearer = names[i + 1:]; left = most_open(np.prod([seen[n] for n in nearer], axis=0)); got = np.prod([1 - out[n][..., 3] for n in nearer], axis=0)
out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)
return out
def statics():
paperf = 0.93 + 0.09 * (0.5 * pn2(2, 11) + 0.3 * pn2(9, 12) + 0.2 * pn2(40, 13)) + 0.03 * (pn2(1, 14) - .5)
return paperf, 0.95 + 0.07 * pn2(1, 900), 0.88 + 0.2 * (0.6 * pn2(30, 810) + 0.4 * pn2(7, 811))
def debug_spans(cols, win0=None, w=WIN):
win0 = P['win0'] if win0 is None else win0
img = np.ones((H, w, 3)) * 0.97; ramp = [hx(c) for c in ('#d03030', '#e8a020', '#60b040', '#2090b0', '#6030c0')]
order = sorted(((s[2], i, s[0], s[1]) for i in range(w) for s in cols[(win0 + i) % W]), key=lambda t: -t[0])
for dd, x, p, l in order:
t = np.clip((math.log(dd) - math.log(500)) / (math.log(95000) - math.log(500)), 0, 1) * 4
i = min(int(t), 3); col = mix(ramp[i], ramp[i + 1], t - i)
y0 = int(np.clip((TOP_DEG - p) * PXDEG_V, 0, H - 1)); y1 = int(np.clip((TOP_DEG - max(l, p - 3.0)) * PXDEG_V, 0, H - 1))
img[y0:y1 + 1, x] = col
return img
def apply_view(v):
"""re-apply a view returned by set_view (used by the rotation test and the before/after comparison so that frames line up exactly)"""
global rowang, PXDEG_V, TOP_DEG, BOTTOM_DEG
PXDEG_V = v['px_per_deg']; TOP_DEG = v['top_deg']; BOTTOM_DEG = v['bottom_deg']; rowang = TOP_DEG - (np.arange(H) + 0.5) / PXDEG_V

29
scripts/ridge-bake/sun.py Normal file
View file

@ -0,0 +1,29 @@
"""Solar position: NOAA general solar position formulas (Spencer/fractional-year low precision: equation of time and declination Fourier series,
hour angle, zenith/azimuth), https://gml.noaa.gov/grad/solcalc/solareqns.PDF . Accuracy ~0.1 deg, plenty for a relight. Apparent elevation adds
Saemundsson refraction (Meeus ch.16). UTC in, degrees out. Azimuth clockwise from north."""
import math
from datetime import datetime, timezone, timedelta
def solar(dt_utc, lat, lon):
doy = dt_utc.timetuple().tm_yday; hr = dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600
g = 2 * math.pi / 365 * (doy - 1 + (hr - 12) / 24)
eq = 229.18 * (0.000075 + 0.001868 * math.cos(g) - 0.032077 * math.sin(g) - 0.014615 * math.cos(2 * g) - 0.040849 * math.sin(2 * g))
de = (0.006918 - 0.399912 * math.cos(g) + 0.070257 * math.sin(g) - 0.006758 * math.cos(2 * g) + 0.000907 * math.sin(2 * g)
- 0.002697 * math.cos(3 * g) + 0.00148 * math.sin(3 * g))
tst = hr * 60 + eq + 4 * lon # true solar time, minutes
ha = math.radians(tst / 4 - 180); la = math.radians(lat)
sin_el = math.sin(la) * math.sin(de) + math.cos(la) * math.cos(de) * math.cos(ha)
el = math.degrees(math.asin(sin_el))
az = (math.degrees(math.atan2(math.sin(ha), math.cos(ha) * math.sin(la) - math.tan(de) * math.cos(la))) + 180) % 360
ref = 1.02 / math.tan(math.radians(el + 10.3 / (el + 5.11))) / 60 if el > -5 else 0.0
return az, el + ref, el, math.degrees(de), eq
def find_el(day, lat, lon, target, lo_h, hi_h):
"""bisect the UTC hour in [lo_h, hi_h] where apparent elevation crosses `target`."""
f = lambda h: solar(day + timedelta(hours=h), lat, lon)[1] - target
a, b = lo_h, hi_h; fa = f(a)
for _ in range(50):
m = (a + b) / 2; fm = f(m)
if (fa < 0) == (fm < 0): a, fa = m, fm
else: b = m
return (a + b) / 2

View file

@ -0,0 +1,66 @@
"""Mutation check: re-introduce a cause into a COPY of the baker, run the fixtures against it, and require that at least one fails.
venv/bin/python tests/mutate.py (prints one line per mutant; exit 1 if a mutant survives)"""
import os, shutil, subprocess, sys, tempfile
HERE = os.path.dirname(os.path.abspath(__file__)); SRC = os.path.join(HERE, '..')
MUTANTS = {
'round-1 bug: both chain sides take the END value (near_end always x1)': ("near_end = np.where(x - x1 <= W - x, x1, 0)", "near_end = np.where(x - x1 <= W - x, x1, x1)"),
'linking does not wrap (look-back columns clipped at 0)': ("xb = (x - back) % Wc", "xb = max(x - back, 0)"),
'a chain absent from a column still competes in the slot union (held valley value wins)': ("np.where(l['wt'] > 0, l['top'], -1e3 - col_gap(l['wt'] > 0))", "l['top']"),
'shadow march goes AWAY from the sun': ("lat + s * ca / 111320.0, lon + s * sa", "lat - s * ca / 111320.0, lon - s * sa"),
'shadow ignores the sun elevation (ray stays at the sample height)': ("(h + s * t)", "(h + 0 * t)"),
'taper length ignores the drop (always the 8-column minimum)': ("Tn = taper_len(drop)", "Tn = np.full_like(drop, 8.0)"),
'shadow march mirrors east/west (longitude sign)': ("lon + s * sa / (111320.0 * cosl)", "lon - s * sa / (111320.0 * cosl)"),
'shadow longitude scale ignores cos(lat)': ("lon + s * sa / (111320.0 * cosl)", "lon + s * sa / 111320.0"),
'shadow lightens instead of darkens the face': ("(gain - g_amb) * (1 - sp)[..., None]", "(gain - g_amb) * sp[..., None]"),
'taper cap lowered to 8 columns': ("TAPER_K, TAPER_MAX = 30.0, 72", "TAPER_K, TAPER_MAX = 30.0, 8"),
'taper per degree lowered to 2': ("TAPER_K, TAPER_MAX = 30.0, 72", "TAPER_K, TAPER_MAX = 2.0, 72"),
'taper floor ignores the chain end (top held, no fall)': ("res['top'] = res['top'] - drop * t", "res['top'] = res['top']"),
'painter shades one normal per column (the column mean of the per-pixel face)': ("sl, la = Ly['sl'], Ly['la']\n Lr", "sl, la = np.broadcast_to(Ly['sl'].mean(0), Ly['sl'].shape), np.broadcast_to(Ly['la'].mean(0), Ly['la'].shape)\n Lr"),
'history collapsed to ONE slope per peak (the strip mean)': ("Z, Dp, sl = lerp(hh), lerp(hd), lerp(sn)", "Z, Dp, sl = lerp(hh), lerp(hd), np.broadcast_to(mean_s, Z.shape)"),
'history not recorded: only valley and peak samples kept': ("return np.interp(t, va, vd), np.interp(t, va, vh)", "return np.interp(t, va[[0, -1]], vd[[0, -1]]), np.interp(t, va[[0, -1]], vh[[0, -1]])"),
'no lateral tilt from the neighbouring columns': ("np.clip((c_ - a_ * sl) / b_, -1.5, 1.5))", "0 * np.clip((c_ - a_ * sl) / b_, -1.5, 1.5))"),
'NEAR_CAP shoulder committed without the prominence test': ("if hh[j] - lowh >= prom: span(", "if True: span("),
'prominence measured as an angle at the CURRENT sample (round 2)': ("if cand - v >= hy[k] and ch - lowh >= prom:", "if cand - v >= hy[k] and cand - mn >= hy[k]:"),
'contour interval chosen from the shallowest quarter of the face (banding)': ("np.percentile(dzdpx[faceok], 75)", "np.percentile(dzdpx[faceok], 10)"),
'a tapering end keeps the 28 px minimum body (stub / curtain at every chain end)': ("28 * np.clip(wt, 0, 1)", "28"),
'absent columns of a slot take the first chain in list order (rotation-dependent)': ("np.where(l['wt'] > 0, l['top'], -1e3 - col_gap(l['wt'] > 0))", "np.where(l['wt'] > 0, l['top'], -1e9)"),
'history keeps samples the eye cannot see (no running maximum)': ("runmax = np.maximum.accumulate(seg)", "runmax = seg"),
'lateral tilt scale doubled': ("/ b_, -1.5, 1.5))", "/ b_ * 2, -1.5, 1.5))"),
'lateral tilt kept across occlusion edges': ("np.abs(a_) > 3 * b_", "np.abs(a_) > 3e9 * b_"),
'every foreground stub kept (round 2 arches)': ("if not cov[xs].any(): keep.append(c)", "if True: keep.append(c)"),
'no foreground stub kept (holes in the nearest layer)': ("if not cov[xs].any(): keep.append(c)", "if False: keep.append(c)"),
'prominence from the height of the lowest-angle sample (near, high valley)': ("if cand - v >= hy[k] and ch - lowh >= prom:", "if cand - v >= hy[k] and ch - mnh >= prom:"),
'layer body painted to the bottom of the frame (no foot fade: walls)': ("alpha = (m * (1 - fade)).astype(np.float32)", "alpha = m.astype(np.float32)"),
'relief: shading uses the real sun again (face flat with the sun behind the viewer)': ("L = relief_vec(st['az']); az", "L = st['L']; az"),
'relief: 45 deg off the viewer-ward axis (the previous reading) instead of the view axis': ("ctr = win_centre_az(); side", "ctr = (win_centre_az() + 180.0) % 360.0; side"),
'relief: always the + side, whatever side the real sun is on': ("side = 1.0 if math.sin(math.radians(sun_az - ctr)) >= 0 else -1.0", "side = 1.0"),
'relief: colour / strength / haze frozen at a 40 deg sun (night does not go dark)': ("st = sun_state(az, el); shs", "st = sun_state(az, 40.0); shs"),
'relief: cast shadows march toward the relief light instead of the real sun': ("cast_shadows(layers, az, el) if shadows", "cast_shadows(layers, math.degrees(math.atan2(relief_vec(az)[0], relief_vec(az)[1])) % 360.0, RELIEF_EL) if shadows"),
'compositor (relight) reads the layer mask m instead of its alpha a (walls return)': ("img = img * (1 - Ly['a'][..., None]) + face * Ly['a'][..., None]", "img = img * (1 - Ly['m'][..., None]) + face * Ly['m'][..., None]"),
'foot fade starts at 30% of the foot instead of 60%': ("(depthpx - 0.6) / 0.4", "(depthpx - 0.3) / 0.7"),
'foot fade starts at 90% of the foot instead of 60%': ("(depthpx - 0.6) / 0.4", "(depthpx - 0.9) / 0.1"),
'TAPER_MAX 72 -> 40': ("TAPER_K, TAPER_MAX = 30.0, 72", "TAPER_K, TAPER_MAX = 30.0, 40"),
'RELIEF_EL 32 -> 3 (grazing relief light)': ("RELIEF_OFF_DEG, RELIEF_EL = 45.0, 32.0", "RELIEF_OFF_DEG, RELIEF_EL = 45.0, 3.0"),
'spec half vector ignores the light': ("Hv = L[None, :] + np.stack(", "Hv = 0 * L[None, :] + np.stack("),
'lateral tilt not blurred across columns': ("blurw(la, 2.0).astype", "la.astype"),
'contour interval percentile includes the tapering columns (weight condition removed)': ("& (wt[None, :] > 0.5)", ""),
'Fresnel sign flipped (rim strongest on faces standing at the viewer)': ("np.clip(1 - sl / nrm, 0, 1) ** 3", "np.clip(1 + sl / nrm, 0, 1) ** 3"),
'rays cache key ignores the refraction constant': ("R_EARTH, KREF, W, bool(detail)", "R_EARTH, W, bool(detail)"),
'rays cache key ignores the eye elevation': ("round(float(P['eye_m']), 3), ", ""),
'mosaic cache key ignores the tile list': ("sorted(tiles_for(p)), 3600", "3600"),
}
EQUIVALENT = {} # mutant name -> one line on why it is behaviourally equivalent (reported, not run)
def run(item):
name, (a, b) = item; d = tempfile.mkdtemp(); shutil.copytree(SRC, os.path.join(d, 'src'), ignore=shutil.ignore_patterns('tests', '__pycache__')); p = os.path.join(d, 'src', 'ridge.py'); s = open(p).read(); assert a in s, name
open(p, 'w').write(s.replace(a, b))
r = subprocess.run([sys.executable, os.path.join(HERE, 'test_ridge.py')], env=dict(os.environ, RIDGE_SRC=os.path.join(d, 'src')), capture_output=True, text=True); shutil.rmtree(d)
fails = [l.split('|')[0].strip() for l in r.stdout.splitlines() if l.startswith('FAIL')]
line = ('KILLED ' if r.returncode else 'SURVIVED ') + name + ' -> ' + (', '.join(fails) or 'no fixture failed'); print(line, file=sys.stderr, flush=True) # progress: a killed run keeps what finished
return line, r.returncode == 0
if __name__ == '__main__':
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(int(os.environ.get('MUTATE_JOBS', '6'))) as ex: res = list(ex.map(run, [(n, v) for n, v in MUTANTS.items() if n not in EQUIVALENT]))
for line, _ in res: print(line, flush=True)
for n, why in EQUIVALENT.items(): print('EQUIVALENT ' + n + ' -> ' + why)
sys.exit(1 if any(sv for _, sv in res) else 0)

View file

@ -0,0 +1,111 @@
"""Mutation check of the planes path: re-introduce a cause into a COPY of export.py / ridge.py (relight_planes) and run the planes/export tests of test_ridge.py (PLANES_TESTS) against it; a mutant must fail at least one.
RIDGE_TILES=<tiles> RIDGE_WORK=<dir> venv/bin/python -I tests/mutate_export.py [JOBS=4 and ONLY=<name substring>[|<another>] in the environment] (prints one line per mutant as it lands; exit 1 if one survives)
Each mutant runs under a 12 GB address-space cap and a 20 minute limit. Equivalent mutants are named in EQUIVALENT with the argument; they are reported but do not fail the run."""
import os, sys, shutil, subprocess, tempfile, resource
from concurrent.futures import ThreadPoolExecutor
HERE = os.path.dirname(os.path.abspath(__file__)); SRC = os.path.join(HERE, '..')
EQUIVALENT = {'decode budget cap is exclusive': 'decoded = 2 * 3 * w * h * 4 is a multiple of 24 and 20_000_000 is not (20_000_000 / 24 = 833333.33), so decoded == DECODE_BYTES_MAX never holds for integer w and h and > equals >='}
M = {
'no per-channel ref correction (P = X)': ('ridge.py', 'P = X + ref_w[None] * (Yc - Y[..., None])', 'P = X'),
'alpha ignores haze (alpha = cov)': ('ridge.py', 'alpha = np.clip(win(cov) - Y, 0, 1)', 'alpha = np.clip(win(cov), 0, 1)'),
'alpha from the max haze channel': ('ridge.py', 'Y = Yc.mean(-1); alpha', 'Y = Yc.max(-1); alpha'),
'haze share halved in alpha': ('ridge.py', 'alpha = np.clip(win(cov) - Y, 0, 1)', 'alpha = np.clip(win(cov) - 0.5 * Y, 0, 1)'),
'default ref = the sky horizon colour, not the haze colour': ('ridge.py', 'ref = haze_colour(sky_hz(st, paperf)[1]) if ref is None', 'ref = sky_hz(st, paperf)[1] if ref is None'),
'default ref = mid grey': ('ridge.py', 'ref = haze_colour(sky_hz(st, paperf)[1]) if ref is None', 'ref = np.full((W, 3), 0.5) if ref is None'),
'grain dropped from the planes': ('ridge.py', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None]', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0)'),
'grain squared': ('ridge.py', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None]', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None] ** 2'),
'floor dropped from far': ('ridge.py', "if name == 'far' else np.zeros_like(r[0]) for r in runs]", "if False else np.zeros_like(r[0]) for r in runs]"),
'mid = only the first middle slot': ('ridge.py', 'mid=list(range(1, n - 1))', 'mid=[1]'),
'cast shadows off in planes': ('ridge.py', 'shs = cast_shadows(layers, az, el) if shadows else [None] * len(layers); out = {}', 'shs = [None] * len(layers); out = {}'),
'encode premultiplies colour': ('export.py', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(np.concatenate([plane[..., :3] * plane[..., 3:], plane[..., 3:]], -1))'),
'encode drops alpha': ('export.py', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)[..., :3]'),
'encode quality 5': ('export.py', 'WEBP_QUALITY = 80', 'WEBP_QUALITY = 5'),
'encode flips rows': ('export.py', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)[::-1].copy()'),
'night at -6 deg': ('export.py', 'NIGHT_EL = -12.0', 'NIGHT_EL = -6.0'),
'golden at 10 deg': ('export.py', 'golden=find(6.0, noon, noon + 9)', 'golden=find(10.0, noon, noon + 9)'),
'altitude includes eye height': ('export.py', "altitude_km=p['ground_m'] / 1000.0", "altitude_km=(p['ground_m'] + p['eye_m']) / 1000.0"),
'horizon_row in the wrong unit (degrees)': ('export.py', "horizon_row=v['horizon_row']", "horizon_row=v['top_deg']"),
'manifest plane h lies': ('export.py', 'w=w, h=h) for n in PLANES}', 'w=w, h=h + 1) for n in PLANES}'),
'manifest plane w from the constant': ('export.py', 'w=w, h=h) for n in PLANES}', 'w=PLANE_W, h=h) for n in PLANES}'),
'manifest view plane_w from the constant': ('export.py', 'window_deg=WINDOW_DEG, plane_w=w)', 'window_deg=WINDOW_DEG, plane_w=PLANE_W)'),
'luma sky 0.8 grey': ('export.py', 'LUMA_SKY = 0.5', 'LUMA_SKY = 0.8'),
'luma coverage threshold 0.3': ('export.py', 'LUMA_COVER = 0.5', 'LUMA_COVER = 0.3'),
'luma coverage threshold 0.8': ('export.py', 'LUMA_COVER = 0.5', 'LUMA_COVER = 0.8'),
'luma composites near to far': ('export.py', 'for a in decoded:', 'for a in decoded[::-1]:'),
'luma coverage = strongest plane alpha': ('export.py', 'clear = clear * (1 - al[..., 0])', 'clear = np.minimum(clear, 1 - al[..., 0])'),
'luma coverage = last plane alpha': ('export.py', 'clear = clear * (1 - al[..., 0])', 'clear = 1 - al[..., 0]'),
'luma never refuses an empty frame': ('export.py', 'if not m.any(): raise ValueError', 'if False: raise ValueError'),
'planes of mixed size accepted': ('export.py', 'if len(shapes) != 1:', 'if False:'),
'plane rows not checked against the view': ('export.py', 'if h != view', 'if False and h != view'),
'budget checked against the constant width': ('export.py', 'check_budget([sum(sizes[k].values()) for k in KEYS], w, h)', 'check_budget([sum(sizes[k].values()) for k in KEYS], PLANE_W, h)'),
'budget checked against the view height only': ('export.py', 'check_budget([sum(sizes[k].values()) for k in KEYS], w, h)', 'check_budget([sum(sizes[k].values()) for k in KEYS], w, 1)'),
'width cap is exclusive of 4096': ('export.py', 'if plane_w > PLANE_W_MAX:', 'if plane_w >= PLANE_W_MAX:'),
'width cap 8192': ('export.py', 'PLANE_W_MAX = 4096', 'PLANE_W_MAX = 8192'),
'pair cap is exclusive of the cap': ('export.py', 'if frame_bytes[i] + frame_bytes[j] > PAIR_BYTES_MAX:', 'if frame_bytes[i] + frame_bytes[j] >= PAIR_BYTES_MAX:'),
'pair check does not wrap night to dawn': ('export.py', 'for i in range(len(frame_bytes)):', 'for i in range(len(frame_bytes) - 1):'),
'decode budget counts one frame, not a pair': ('export.py', 'decoded = 2 * len(PLANES)', 'decoded = len(PLANES)'),
'decode budget cap doubled': ('export.py', 'DECODE_BYTES_MAX = 20_000_000', 'DECODE_BYTES_MAX = 40_000_000'),
'decode budget cap is exclusive': ('export.py', 'if decoded > DECODE_BYTES_MAX:', 'if decoded >= DECODE_BYTES_MAX:'),
'planes order mid far near': ('export.py', "PLANES = ('far', 'mid', 'near')", "PLANES = ('mid', 'far', 'near')"),
'morning ignores the time zone': ('export.py', 'morning=9.5 - tz', 'morning=9.5'),
'dawn three hours after sunrise': ('export.py', 'dawn=rise + 0.25', 'dawn=rise + 3'),
'dusk at -4 deg': ('export.py', 'dusk=find(-2.5, noon, noon + 9)', 'dusk=find(-4.0, noon, noon + 9)'),
'noon is solar noon of the meridian, not the sun maximum': ('export.py', 'noon = max((12 - lon / 15.0 - 1 + x / 600 for x in range(0, 1200)), key=lambda t: sun.solar(day + timedelta(hours=t), lat, lon)[1])', 'noon = 12 - lon / 15.0 + 1.5'),
'sun key hour_utc off by one hour': ('export.py', "hour_utc=h) for k, h in hours.items()}", "hour_utc=h + 1) for k, h in hours.items()}"),
'altitude from the eye, not the ground': ('export.py', "altitude_km=p['ground_m'] / 1000.0", "altitude_km=p['eye_m'] / 1000.0"),
'look azimuth from the wrong field': ('export.py', "look_az_deg=p['az_center']", "look_az_deg=p['lat']"),
'view px_per_deg_v is the horizontal one': ('export.py', "px_per_deg_v=v['px_per_deg']", "px_per_deg_v=ridge.HPX_DEG"),
'sky_row left as a fraction': ('export.py', "sky_row=v['sky_row'] * ridge.H", "sky_row=v['sky_row']"),
'near_row left as a fraction': ('export.py', "near_row=v['near_row'] * ridge.H", "near_row=v['near_row']"),
'index written without replacing a rebaked place': ('export.py', "idx['places'][place['id']] = name;", "idx['places'].setdefault(place['id'], name);"),
'plane files written under the wrong key': ('export.py', "(tmp / f'{k}-{n}.webp').write_bytes(frames_webp[k][n])", "(tmp / f'{k}-{n}.webp').write_bytes(frames_webp['noon'][n])"),
'a refused bake still creates the out dir': ('export.py', "sizes = {k: {n: len(frames_webp[k][n])", "Path(out).mkdir(parents=True, exist_ok=True); sizes = {k: {n: len(frames_webp[k][n])"),
'luma in sRGB, not linear': ('export.py', 'lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)', 'lin = c'),
'pair check skips the night-dawn pair and the last day pair': ('export.py', 'for i in range(len(frame_bytes)):', 'for i in range(len(frame_bytes) - 2):'),
'decode budget never checked': ('export.py', 'if decoded > DECODE_BYTES_MAX:', 'if False:'),
'old dir of the place kept': ('export.py', 'if old and old != name: shutil.rmtree(out / old, ignore_errors=True)', 'pass'),
'tmp dir left in out on success': ('export.py', 'os.rename(tmp, dest)', 'shutil.copytree(tmp, dest)'),
'index default always the baked place': ('export.py', "idx['default'] = default or (idx['default'] if idx['default'] in idx['places'] else place['id'])", "idx['default'] = place['id']"),
'attribution credit_line blanked': ('export.py', "credit_line='Contains modified", "credit_line='' and 'Contains modified"),
'attribution credit_line loses the Airbus line': ('export.py', ' and © Airbus Defence and Space GmbH 2014-2018', ''),
'attribution licence blanked': ('export.py', "licence='Copernicus DEM licence", "licence='' and 'Copernicus DEM licence"),
'attribution dem_source names the 90 m product': ('export.py', "dem_source='Copernicus DEM GLO-30 (30 m)'", "dem_source='Copernicus DEM GLO-90 (90 m)'"),
'attribution url over plain http': ('export.py', "url='https://spacedata", "url='http://spacedata"),
'attribution url blanked': ('export.py', "url='https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model')", "url='')"),
'knock-out removed: the farther planes show through the nearer': ('ridge.py', "out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)", "out[names[i]][..., 3] *= 1.0"),
'knock-out ignores the nearer planes\' own alpha': ('ridge.py', "out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)", "out[names[i]][..., 3] *= np.clip(left, 0, 1)"),
'knock-out margin filter is a min (hides what the slide uncovers)': ('ridge.py', 'sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)', 'sliding_window_view(pad, 2 * m + 1, axis=1).min(-1), m / 2.0)'),
'knock-out has a hard edge (no easing)': ('ridge.py', 'return np.clip(blurw(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)[:, m:-m], 0, 1)', 'return np.clip(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1)[:, m:-m], 0, 1)'),
'knock-out margin of one column': ('ridge.py', 'OCCLUDE_MARGIN_PX = 48 ', 'OCCLUDE_MARGIN_PX = 1 '),
'knock-out hides the nearer plane from the farther one, not the reverse': ('ridge.py', 'nearer = names[i + 1:]; left', 'nearer = names[:i]; left'),
'near foot floor removed: the near body dissolves above the horizon': ('ridge.py', 'if near: foot = np.maximum(foot,', 'if False: foot = np.maximum(foot,'),
'near foot floor starts the fade at the foot, not at the horizon': ('ridge.py', 'np.clip(ang * PXDEG_V, 0, None) / FADE_AT)', 'np.clip(ang * PXDEG_V, 0, None) / 1.0)'),
'near flag passed to every layer': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=True)'),
'near flag passed to no layer': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=False)'),
'near flag on the first layer, not the last': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=rank == 0)'),
'near cover never checked at the writer': ('export.py', "for k in KEYS: check_near_cover(dec[k][-1], view['horizon_row'])", "pass"),
'near cover checked on the mid plane': ('export.py', "check_near_cover(dec[k][-1], view['horizon_row'])", "check_near_cover(dec[k][1], view['horizon_row'])"),
'near cover checked on noon only': ('export.py', "for k in KEYS: check_near_cover(", "for k in ('noon',): check_near_cover("),
'near cover hole threshold of zero': ('export.py', 'NEAR_HOLE_ALPHA = 24 ', 'NEAR_HOLE_ALPHA = 0 '),
'near cover looks below the horizon too': ('export.py', '& (rows < int(horizon_row))', '& (rows < h)'),
'near cover crest threshold above any alpha': ('export.py', 'NEAR_CREST_ALPHA = 64 ', 'NEAR_CREST_ALPHA = 256 '),
'near cover lets a transparent column through': ('export.py', 'if none.any(): raise ValueError', 'if False: raise ValueError'),
'near cover looks only above the crest row': ('export.py', '(rows >= crest[None, :]) & (rows < int(horizon_row))', '(rows >= crest[None, :] + 400) & (rows < int(horizon_row))'),
'relight is not deterministic (noise in the planes)': ('ridge.py', 'P = X + ref_w[None] * (Yc - Y[..., None])', 'P = X + ref_w[None] * (Yc - Y[..., None]) + np.random.random() * 0.05'),
}
def _cap(): resource.setrlimit(resource.RLIMIT_AS, (12 << 30, 12 << 30))
def run(item):
name, (f, a, b) = item; d = tempfile.mkdtemp(dir=os.environ.get('TMPDIR')); s = os.path.join(d, 'src'); shutil.copytree(SRC, s, ignore=shutil.ignore_patterns('__pycache__'))
p = os.path.join(s, f); t = open(p).read(); assert a in t, name; open(p, 'w').write(t.replace(a, b, 1))
code = ("import sys; sys.path.insert(0, %r); import test_ridge as t\nfor f in sorted(t.PLANES_TESTS, key=lambda f: ('vienna' in f.__name__, f.__name__)):\n"
" try: f()\n except BaseException as e: print(f.__name__[5:50] + ': ' + repr(e)[:120]); break\n") % os.path.join(s, 'tests')
try: r = subprocess.run([sys.executable, '-I', '-B', '-c', code], env=dict(os.environ, RIDGE_SRC=s), capture_output=True, text=True, preexec_fn=_cap, timeout=1200); fails = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else ('CRASH ' + r.stderr[-300:] if r.returncode else '')
except subprocess.TimeoutExpired: fails = 'TIMEOUT'
shutil.rmtree(d); line = ('KILLED ' if fails else 'SURVIVED ') + name + ' -> ' + (fails or '-'); print(line, flush=True); return line
if __name__ == '__main__':
only = os.environ.get('ONLY'); items = [kv for kv in M.items() if not only or any(o in kv[0] for o in only.split('|'))]
with ThreadPoolExecutor(int(os.environ.get('JOBS', '4'))) as ex: lines = list(ex.map(run, items))
live = [l for l in lines if l.startswith('SURVIVED') and l[9:].split(' -> ')[0] not in EQUIVALENT]
print('%d mutants, %d killed, %d survived (%d named equivalent)' % (len(lines), sum(l.startswith('KILLED') for l in lines), sum(l.startswith('SURVIVED') for l in lines), len(EQUIVALENT))); sys.exit(1 if live else 0)

View file

@ -0,0 +1,7 @@
#!/bin/sh
# Usage: RIDGE_PY=<venv python> RIDGE_TILES=<tiles dir> tests/run.sh [test_ridge.py|mutate.py]
# RIDGE_WORK (cache dir) defaults to a fresh directory under $TMPDIR.
: "${RIDGE_PY:?RIDGE_PY must name the python of the baker venv}"
export RIDGE_WORK="${RIDGE_WORK:-${TMPDIR:-/tmp}/ridge-bake-work}"; mkdir -p "$RIDGE_WORK"
HERE=$(cd "$(dirname "$0")" && pwd)
exec "$RIDGE_PY" -I "$HERE/${1:-test_ridge.py}"

View file

@ -0,0 +1,670 @@
"""Fixtures for the A+D sweep/chain/taper. Plain asserts, no pytest needed: venv/bin/python tests/test_ridge.py (exit code 1 on failure).
RIDGE_SRC selects the source directory (default ..) (the mutation runner points it at a patched copy).
Synthetic terrain has a known right answer; the Vienna test pins the skyline of the real bake (its rays come from stage0 for the vienna preset: RIDGE_TILES needs the tiles, or a cached rays file must exist)."""
import os, sys, math, traceback
SRC = os.environ.get('RIDGE_SRC') or os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'); sys.path.insert(0, SRC) # RIDGE_WORK and RIDGE_TILES must be set (tests/run.sh sets them)
import numpy as np, ridge
from ridge import W, TAPER, TAPER_MAX, taper_len
EYE = 100.0; GROUND = 50.0 # ground below the eye: every ray climbs, so only the placed blocks make ridges
ridge.P = dict(id='synthetic', rmax_km=60)
D = ridge.schedule(False)
def az_of(x): return (x + 0.5) / W * 360.0
def col_of(az): return int(round(az / 360.0 * W - 0.5)) % W
def rays(hfun):
az = (np.arange(W) + 0.5) / W * 360.0; A = np.zeros((len(D), W), np.float32); Hh = np.zeros_like(A)
for k, dk in enumerate(D):
h = hfun(dk, az); Hh[k] = h; A[k] = np.degrees(np.arctan2(h - EYE - dk * dk / (2 * ridge.R_EARTH) * (1 - ridge.KREF), dk))
return A, Hh
def theta(h, d): return math.degrees(math.atan2(h - EYE - d * d / (2 * ridge.R_EARTH) * (1 - ridge.KREF), d))
def in_arc(az, a0, a1): return ((az - a0) % 360.0) <= ((a1 - a0) % 360.0)
def block(a0, a1, d0, d1, h): return lambda dk, az: np.where(in_arc(az, a0, a1) & (dk >= d0) & (dk < d1), h, 0.0)
def terrain(*blocks): return lambda dk, az: np.maximum.reduce([np.full_like(az, GROUND)] + [np.asarray(b(dk, az), float) for b in blocks])
def slot_at(r, col, ang):
"""the slot whose top at `col` is closest to `ang` (the layer that owns the block)"""
return min(r['slots'], key=lambda S: abs(S['top'][col] - ang))
def jump_bound(S, x, w=26):
"""steepest step allowed inside a taper: smoothstep slope 1.5 x the end drop / taper_len(drop) (+ margin), the end drop taken as max((highest top within w columns) - lo, 0.4)"""
seg = S['top'][[(x + k) % W for k in range(-w, w + 1)]]; drop = max(seg.max() - S['lo'][x], 0.4); return 1.6 * drop / float(taper_len(drop)) + 0.02 # the taper always falls at least 0.4 deg (top - 0.4 when the valley is shallower)
def active_pairs(S): return [x for x in range(W) if S['wt'][x] > 0 and S['wt'][(x + 1) % W] > 0]
def test_plateau_has_no_spike_and_no_cliff_beyond_taper():
"""Flat-topped plateau, az 100-140, 20-24 km, 900 m. Right answer: top = theta(900, 20 km) inside, nothing above it anywhere, outside the plateau the top only falls
(monotonically, within the taper), and no single-column step exceeds the taper bound."""
A, Hh = rays(terrain(block(100, 140, 20000, 24000, 900))); r = ridge.sweep(D, A, Hh); th = theta(900, 20000)
S = slot_at(r, col_of(120), th); top = S['top']
inside = np.arange(col_of(101), col_of(139)); assert np.abs(top[inside] - th).max() < 0.03, ('plateau top not flat/right', np.abs(top[inside] - th).max())
assert top.max() <= th + 0.03, ('spike above the plateau', top.max() - th)
for side in (-1, 1):
x0 = col_of(100) if side < 0 else col_of(140)
n = int(taper_len(max(th - S['lo'][col_of(120)], 0.4))); seg = np.array([top[(x0 + side * j) % W] for j in range(1, n)])
assert (np.diff(seg) <= 1e-9).all(), ('taper is not monotone away from the plateau', side, np.diff(seg).max())
assert seg[-1] < seg[0] - 0.3, 'taper does not die away'
assert S['wt'][(x0 + side * (n + 3)) % W] == 0, 'the layer is still drawn far outside the plateau'
j = np.abs(np.diff(top, append=top[0])); bad = [(x, round(float(j[x]), 3)) for x in active_pairs(S) if j[x] > jump_bound(S, x)]; assert not bad, ('column jump above the bound', bad[:5])
drop = th - S['lo'][col_of(120)]; width = int(((S['wt'] > 0) & (S['wt'] < 1))[col_of(140):col_of(140) + 90].sum()); assert drop > 1.5 and width >= 0.9 * min(72, 30 * drop), ('a tall end must fall over ~30 columns per degree of drop up to 72 (absolute, not the constant) (its drop is %.2f deg)' % drop, width)
def test_ridge_whose_two_ends_differ_does_not_borrow_the_far_end():
"""Plateau az 100-140 whose height climbs 600 -> 1200 m with azimuth. Right answer: left of the left end nothing is higher than the left end's own top, right of the right end
nothing is higher than the right end's top (the round-1 bug held the END value on both sides: a 0.9 deg step on the low side)."""
hfun = lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 600.0 + 600.0 * np.clip((az - 100.0) / 40.0, 0, 1), 0.0)
A, Hh = rays(terrain(hfun)); r = ridge.sweep(D, A, Hh); tl, tr = theta(600, 20000), theta(1200, 20000)
S = slot_at(r, col_of(120), (tl + tr) / 2); top = S['top']; xl, xr = col_of(100), col_of(140)
left = top[[(xl - j) % W for j in range(0, TAPER_MAX + 3)]]; right = top[[(xr + j) % W for j in range(0, TAPER_MAX + 3)]]
assert left.max() <= tl + 0.05, ('left of the low end rises above it', left.max() - tl)
assert right.max() <= tr + 0.05 and right[0] >= tr - 0.1, ('right end value wrong', right[:3])
assert (np.diff(left) <= 1e-9).all() and (np.diff(right) <= 1e-9).all(), 'tapers are not monotone'
def test_slot_union_only_lets_present_chains_compete():
"""Two chains in one slot: L1 present on cols 0-99 (top 0.2), L2 present on cols 200-299 (top 2.0) but its HELD value elsewhere is 0.5 > 0.2. Right answer: on cols 0-99
the slot is L1 (top 0.2, wt 1); a chain that is absent there must not win with its held value."""
def lay(c0, c1, top, held):
wt = np.zeros(W); wt[c0:c1] = 1.0; tp = np.full(W, held); tp[c0:c1] = top
return dict(top=tp, lo=np.full(W, -1.0), ph=np.full(W, 100.0), vh=np.full(W, 0.0), slope=np.full(W, 5.0), wt=wt, pd=np.full(W, 20000.0), vd=np.full(W, 19000.0), hd=np.full((ridge.NH, W), 19500.0), hh=np.full((ridge.NH, W), 50.0), dist=20000.0)
S = ridge.to_slots([lay(0, 100, 0.2, -1.0), lay(200, 300, 2.0, 0.5)])
assert len(S) == 1 and (S[0]['wt'][0:100] == 1).all() and np.allclose(S[0]['top'][0:100], 0.2), ('absent chain won the slot union', S[0]['top'][:3], S[0]['wt'][:3])
assert (S[0]['wt'][200:300] == 1).all() and np.allclose(S[0]['top'][200:300], 2.0)
def S_ok(S, i): return S['wt'][i] > 0 and S['wt'][(i + 1) % W] > 0
def test_step_cliff_is_one_step_and_levels_are_right():
"""Two plateaus side by side, 700 m (az 60-180) then 1300 m (180-300). Right answer: level theta_A left, theta_B right, one step at az 180 (col 3200)."""
A, Hh = rays(terrain(block(60, 180, 20000, 24000, 700), block(180, 300, 20000, 24000, 1300))); r = ridge.sweep(D, A, Hh)
tA, tB = theta(700, 20000), theta(1300, 20000); x = col_of(180)
SA = slot_at(r, col_of(120), tA); top = SA['top']
assert np.abs(top[col_of(62):x - TAPER_MAX - 2] - tA).max() < 0.03, 'level A wrong'
assert np.abs(top[x + 2:col_of(298)] - tB).max() < 0.03, 'level B wrong'
assert top.max() <= tB + 0.03, 'spike above the higher plateau'
j = np.abs(np.diff(top)); ends = (col_of(60), col_of(300)); big = [i for i in np.where(j > 0.2)[0] if S_ok(SA, i) and not (x - TAPER_MAX - 2 <= i <= x + 2) and min(abs(i - e) for e in ends) > TAPER_MAX + 2]
assert not big, ('a jump away from the step and the two outer ends', big[:6])
assert (top[x - TAPER_MAX - 2:x + 3].max() - tA) <= (tB - tA) + 0.03
def test_single_peak_has_one_maximum():
"""Gaussian peak 1800 m at az 90, 30 km, sigma 1.5 km. Right answer: one maximum at az 90 +-0.2, monotone either side inside the chain, no secondary bump."""
def peak(dk, az):
lat = dk * np.sin(np.radians(az - 90.0)); return 1800.0 * np.exp(-(lat ** 2 + (dk - 30000.0) ** 2) / (2 * 1500.0 ** 2))
A, Hh = rays(terrain(peak)); r = ridge.sweep(D, A, Hh); top_peak = theta(1800, 30000)
S = max(r['slots'], key=lambda S: S['top'][col_of(90)]); top = S['top']
xm = int(np.argmax(top[col_of(60):col_of(120)])) + col_of(60); assert abs(az_of(xm) - 90.0) < 0.25, ('maximum at', az_of(xm))
assert abs(top[xm] - top_peak) < 0.1, (top[xm], top_peak)
seg = top[col_of(60):col_of(120)]; peaks = [i for i in range(1, len(seg) - 1) if seg[i] >= seg[i - 1] and seg[i] > seg[i + 1] and (seg[i] - min(seg[max(0, i - 30):i + 30])) > 0.01]
assert len(peaks) == 1, ('secondary local maxima', len(peaks))
def test_wraparound_ridge_across_north_is_seamless_and_rotation_invariant():
"""Plateau az 350 -> 10 (across 0/360). Right answer: flat top, no step across the seam, one chain crossing it, and the result does not depend on where column 0 is."""
A, Hh = rays(terrain(block(350, 10, 20000, 24000, 900))); r = ridge.sweep(D, A, Hh); th = theta(900, 20000)
S = slot_at(r, 0, th); top = S['top']
assert abs(top[W - 1] - top[0]) < 0.003, ('seam step', top[W - 1] - top[0])
assert np.abs(top[[col_of(351), col_of(355), W - 1, 0, col_of(5), col_of(9)]] - th).max() < 0.03
near20 = [l for l in r['keep'] if abs(math.log(l['dist'] / 20000.0)) < 0.3]
assert len(near20) == 1 and near20[0]['x0'] + near20[0]['span'] > W, ('the 20 km plateau must be ONE chain crossing the seam', [(l['x0'], l['span']) for l in near20])
R = 1600; r2 = ridge.sweep(D, np.roll(A, -R, axis=1), np.roll(Hh, -R, axis=1))
for S1 in r['slots']:
S2 = next(s for s in r2['slots'] if s['slot'] == S1['slot']); d = np.abs(np.roll(S2['top'], R) - S1['top']).max(); assert d < 1e-9, ('not rotation invariant', S1['slot'], d)
def test_vienna_skyline_has_no_invented_jump_and_seam_is_seamless():
"""Real bake: no per-column step above the stated bound. In a chain (wt == 1) a step above 0.12 deg is allowed only if both columns carry the raw span peak that wins there (a real step of the DEM, e.g. a roof edge); in a taper the
steepest allowed step is 1.6 x (top-lo)/TAPER + 0.02. The envelope across the seam must not jump."""
saved = ridge.P
ridge.configure(ridge.load_preset('vienna')); d, A, Hh, _ = ridge.stage0(False) # cached under the key of its own inputs (computed once if absent)
ridge.P = dict(id='vienna', rmax_km=95); r = ridge.sweep(d, A, Hh); ridge.P = saved
worst = []; cols = r['cols']
real = lambda x, top: any(abs(s[0] - top) < 1e-4 for s in cols[x % W]) # the top at x is an actual raw span peak: a step between two such columns is a step of the DEM
for S in r['slots']:
j = np.abs(np.diff(S['top'], append=S['top'][0]))
for x in active_pairs(S):
bound = jump_bound(S, x) if min(S['wt'][x], S['wt'][(x + 1) % W]) < 1 else 0.12
if j[x] > bound and not (S['wt'][x] == 1 and S['wt'][(x + 1) % W] == 1 and real(x, S['top'][x]) and real(x + 1, S['top'][(x + 1) % W])):
worst.append((S['slot'], x, round(float(j[x]), 3), round(float(bound), 3), round(float(S['wt'][x]), 2)))
assert not worst, ('Vienna jumps above the bound', worst[:6])
sky = np.max([S['top'] for S in r['slots']], axis=0); assert abs(sky[W - 1] - sky[0]) < 0.05, ('envelope jumps across the seam', sky[W - 1] - sky[0])
for S in r['slots']:
if S['wt'][W - 1] > 0 and S['wt'][0] > 0: assert abs(S['top'][W - 1] - S['top'][0]) < 0.05, ('layer jumps across the seam', S['slot'])
def _shadow_fixture():
"""flat 100 m terrain with one 1000 m wall at lat 48.45-48.46 (5.6-6.7 km north of the sample point), sample point lat 48.40 lon 16.5 at 103 m"""
m = np.full((3600, 3600), 100.0, np.float32); y0 = int((49 - 48.46) * 3600); y1 = int((49 - 48.45) * 3600); m[y0:y1, :] = 1000.0
ridge.P = dict(id='syn', lat=48.40, lon=16.5, rmax_km=60); ridge._mos[('syn', ridge.mosaic_key(ridge.P))] = (m, 49, 16)
return [dict(spts=dict(lat=np.full((3, W), 48.40), lon=np.full((3, W), 16.5), h=np.full((3, W), 103.0)))]
def test_cast_shadow_follows_the_sun_ray_over_the_dem():
"""Right answer: sun due north at 5 deg: the ray is 580 m at the wall, the wall is 1000 m => fully shadowed (1.0). Sun due north at 30 deg clears the wall (0.0). Sun due south: the wall is behind the sun (0.0).
Sun at 0.2 deg: no direct light, no shadow (0.0)."""
L = _shadow_fixture(); f = lambda az, el: float(ridge.cast_shadows(L, az, el)[0].mean())
assert f(0.0, 5.0) > 0.99, ('wall does not shadow a low sun behind it', f(0.0, 5.0))
assert f(0.0, 30.0) < 0.01, ('high sun clears the wall', f(0.0, 30.0))
assert f(180.0, 5.0) < 0.01, ('wall casts a shadow toward the sun', f(180.0, 5.0))
assert f(0.0, 0.2) == 0.0
def test_cast_shadow_east_west_and_longitude_scale():
"""Wall of 1000 m at lon 16.575-16.585 (5.5-6.2 km EAST of the sample point at lat 48.4, cos(lat) matters), flat 100 m elsewhere. Sun due east at 5 deg: shadowed (580 m ray vs 1000 m).
Sun due west: clear. Without the cos(lat) factor the wall would sit at 8.3 km and a 6 deg ray (980 m) would barely touch it, so the fully-shadowed assertion at 6 deg pins the scale."""
m = np.full((3600, 3600), 100.0, np.float32); x0 = int((16.575 - 16) * 3600); x1 = int((16.585 - 16) * 3600); m[:, x0:x1] = 1000.0
ridge.P = dict(id='syn2', lat=48.40, lon=16.5, rmax_km=60); ridge._mos[('syn2', ridge.mosaic_key(ridge.P))] = (m, 49, 16)
L = [dict(spts=dict(lat=np.full((3, W), 48.40), lon=np.full((3, W), 16.5), h=np.full((3, W), 103.0)))]; f = lambda az, el: float(ridge.cast_shadows(L, az, el)[0].mean())
assert f(90.0, 5.0) > 0.99, ('east wall does not shadow a low east sun', f(90.0, 5.0))
assert f(270.0, 5.0) < 0.01, ('east wall shadows a west sun', f(270.0, 5.0))
assert f(90.0, 6.0) > 0.99 and f(90.0, 12.0) < 0.01, ('east-wall shadow edge is at the wrong elevation (wall is 5.5 km east of the point: the sun ray at 6 deg is 660 m there, at 12 deg 1.2 km)', f(90.0, 6.0), f(90.0, 12.0))
def test_shadow_darkens_the_face_it_covers():
"""Whole pipeline on a synthetic ramp (slope 0.35, lit by the relief light): with the shadow march forced to 1 every face pixel is darker than with it forced to 0 (the shadow removes direct sun); forced 0 equals shadows off."""
A, Hh = rays(terrain(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000), 0.0))); ridge.P = dict(id='synthetic', rmax_km=60, lat=48.0, lon=16.0, floor_agl_m=30.0, s_ref_deg=8.0, win0=(col_of(120) - 640) % W)
r = ridge.sweep(D, A, Hh); ridge.set_view(r['slots']); L = ridge.bake(r['slots']); pf = ridge.statics(); real = ridge.cast_shadows
try:
ridge.cast_shadows = lambda layers, az, el: [np.ones((3, W)) for _ in layers]; dark = ridge.relight(L, 300.0, 30.0, *pf)
ridge.cast_shadows = lambda layers, az, el: [np.zeros((3, W)) for _ in layers]; lit = ridge.relight(L, 300.0, 30.0, *pf)
finally: ridge.cast_shadows = real
off = ridge.relight(L, 300.0, 30.0, *pf, shadows=False); c = slice(col_of(105), col_of(135)); rows = slice(0, ridge.H); Ly = max(L, key=lambda l: ((l['m'][rows, c] > 0.99) & (l['w'][rows, c] < 0.05)).sum()); m = (Ly['m'][rows, c] > 0.99) & (Ly['w'][rows, c] < 0.05) # body pixels (not the mist foot) of the layer that owns the plateau
assert m.any(), 'no solid face pixels to compare'
assert dark[rows, c][m].mean() < lit[rows, c][m].mean() - 0.02, ('shadow does not darken the face', dark[rows, c][m].mean(), lit[rows, c][m].mean())
assert np.abs(lit - off).max() < 1e-4, 'a zero shadow differs from shadows off'
# ---------------------------------------------------------------------------------------- shading fixtures (round 3): the face history -> per-pixel normal
def _bake_view(hfun, az0):
A, Hh = rays(terrain(hfun)); ridge.P = dict(id='synthetic', rmax_km=60, lat=48.0, lon=16.0, floor_agl_m=30.0, s_ref_deg=8.0, win0=(col_of(az0) - 640) % W)
r = ridge.sweep(D, A, Hh); ridge.set_view(r['slots']); return r, ridge.bake(r['slots'])
def _owner(r, L, col, ang):
S = slot_at(r, col, ang); return S, next(Ly for Ly in L if Ly['dist'] == S['dist'])
CONE = dict(Dc=20000.0, Rb=6000.0, Hc=1800.0, az0=90.0)
def _cone(dk, az):
E = dk * np.sin(np.radians(az)) - CONE['Dc'] * math.sin(math.radians(CONE['az0'])); N = dk * np.cos(np.radians(az)) - CONE['Dc'] * math.cos(math.radians(CONE['az0']))
return np.where(np.hypot(E, N) < CONE['Rb'], CONE['Hc'] * (1 - np.hypot(E, N) / CONE['Rb']), 0.0)
def _cone_pixels(sun):
"""(analytic n.L, baked n.L) at pixels of the cone layer: the analytic one marches the ray finely to the first sample whose running-max angle reaches the pixel's elevation and takes the cone's own normal there."""
r, L = _bake_view(_cone, CONE['az0']); S, Ly = _owner(r, L, col_of(CONE['az0']), theta(CONE['Hc'], CONE['Dc'])); nd, _ = ridge.ndl_field(Ly, sun); dd = np.arange(500.0, 60000.0, 4.0); m = CONE['Hc'] / CONE['Rb']; out = []
for x in range(300, 1000, 7):
c = (ridge.P['win0'] + x) % W
if S['wt'][c] < 1: continue
az = (c + 0.5) / W * 360.0; h = _cone(dd, np.full_like(dd, az)); ang = np.degrees(np.arctan2(h - EYE - dd * dd / (2 * ridge.R_EARTH) * (1 - ridge.KREF), dd)); rm = np.maximum.accumulate(ang)
for f in (0.6, 0.7, 0.8, 0.9, 0.95):
row = int(round((ridge.TOP_DEG - (Ly['lo'][c] + f * (Ly['ang'][c] - Ly['lo'][c]))) * ridge.PXDEG_V - 0.5)); d = dd[min(np.searchsorted(rm, ridge.rowang[row]), len(dd) - 1)]
E = d * math.sin(math.radians(az)) - CONE['Dc'] * math.sin(math.radians(CONE['az0'])); N = d * math.cos(math.radians(az)) - CONE['Dc'] * math.cos(math.radians(CONE['az0'])); rr = math.hypot(E, N)
if rr >= 0.85 * CONE['Rb']: continue # on the cone proper, away from the foot kink (the +-2 node slope window rounds the kink)
n = np.array([m * E / rr, m * N / rr, 1.0]); n /= np.linalg.norm(n); out.append((float(n @ sun), float(nd[row, c])))
return np.array(out), Ly, nd
def test_cone_is_shaded_per_pixel_with_the_analytic_normal():
"""Cone (apex 1800 m, base radius 6 km, 20 km away), sun from the west at 20 deg. Right answer: the shading of each pixel is the cone's own n.L at the point that pixel sees, so it changes down every
column (the radial direction turns as the ray climbs) and across the face; the round-2 baker stored ONE slope per peak and gave every pixel of a column the same value (within-column std 0)."""
sun = ridge.sun_vec(250.0, 20.0); v, Ly, nd = _cone_pixels(sun); assert len(v) > 100, len(v)
err = np.abs(v[:, 0] - v[:, 1]); assert np.median(err) < 0.05 and err.mean() < 0.07, ('baked n.L is not the analytic one', np.median(err), err.mean())
assert np.ptp(v[:, 1]) > 0.8 * np.ptp(v[:, 0]), ('baked shading range is compressed', np.ptp(v[:, 1]), np.ptp(v[:, 0]))
cs = [nd[int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 6:int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 6, c].std() for c in (ridge.P['win0'] + np.arange(400, 900, 20)) % W]
assert np.mean(cs) > 0.03, ('shading is constant down a column', np.mean(cs))
def test_plane_face_has_the_planes_slope_and_no_lateral_tilt():
"""Planar ramp facing the viewer, az 60-120, rising 0.35 per metre of distance from 20 km to 24 km, then a drop. Right answer: every face pixel's front slope is 0.35 (19.3 deg) and its lateral tilt is 0,
whatever its row (one slope per peak could not tell a plane from a curved face, only the history can)."""
ramp = lambda dk, az: np.where(in_arc(az, 60, 120) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000), 0.0)
r, L = _bake_view(ramp, 90.0); S, Ly = _owner(r, L, col_of(90), theta(50.0 + 0.35 * 4000, 24000)); cols = (ridge.P['win0'] + np.arange(560, 720)) % W
rows = np.arange(int((ridge.TOP_DEG - Ly['ang'][col_of(90)]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V)); sl = Ly['sl'][np.ix_(rows, cols)]; la = Ly['la'][np.ix_(rows, cols)]
assert len(rows) > 20 and abs(np.median(sl) - 0.35) < 0.03 and np.percentile(np.abs(sl - 0.35), 90) < 0.08, ('front slope is not the plane\'s', np.median(sl), np.percentile(np.abs(sl - 0.35), 90))
assert np.abs(la).max() < 0.08, ('lateral tilt on a plane', np.abs(la).max())
def test_ridge_step_tilts_the_face_only_at_the_step():
"""Two plateaus (700 m / 1300 m) meeting at az 180. Right answer: the face is level laterally (la ~ 0) away from the step, and strongly tilted at it (the height changes by 600 m between two neighbouring columns)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 180) & (dk >= 20000) & (dk < 24000), 700.0, np.where(in_arc(az, 180, 300) & (dk >= 20000) & (dk < 24000), 1300.0, 0.0)), 180.0)
S, Ly = _owner(r, L, col_of(120), theta(700, 20000)); cols = lambda a, b: (ridge.P['win0'] + np.arange(a, b)) % W; rows = slice(int((ridge.TOP_DEG - theta(1300, 20000)) * ridge.PXDEG_V), int((ridge.TOP_DEG - 0.3) * ridge.PXDEG_V))
far = np.abs(Ly['la'][rows, :][:, cols(200, 500)]); assert far.max() < 0.05, ('lateral tilt away from the step', far.max())
near = Ly['la'][rows, :][:, cols(620, 660)]; assert np.abs(near).max() > 0.2, ('no tilt at the step', np.abs(near).max())
def test_roof_height_bump_in_the_foreground_is_not_a_ridge():
"""Flat ground with a 19 m x 40 m block 700 m away (a roof / tree in a DSM). Right answer: no layer: a peak needs PROM_M (25 m) of height over its valley, and the 4 km NEAR_CAP shoulder rule obeys the same
rule (round 2 committed the shoulder with no prominence test: an arch 66 columns wide stood in the foreground). A 60 m block IS a ridge."""
bump = lambda h: (lambda dk, az: np.where(in_arc(az, 88, 92) & (dk >= 700) & (dk < 740), GROUND + h, 0.0))
A, Hh = rays(terrain(bump(19.0))); r = ridge.sweep(D, A, Hh); assert not [l for l in r['keep'] if l['dist'] < 4000], ('a 19 m bump became a ridge', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']])
A, Hh = rays(terrain(bump(60.0))); r = ridge.sweep(D, A, Hh); assert [l for l in r['keep'] if l['dist'] < 1000], 'a 60 m block must be a ridge'
def test_contour_lines_are_never_denser_than_9_px_over_most_of_the_face():
"""Right answer (cone): the iso-height lines drawn on a layer are at least CONTOUR_MIN_PX apart over three quarters of its face (round 2 picked the interval from the SHALLOWEST columns, so the steep centre
of the mountain carried a line every 2-3 px: the fine horizontal banding)."""
r, L = _bake_view(_cone, CONE['az0']); S, Ly = _owner(r, L, col_of(CONE['az0']), theta(CONE['Hc'], CONE['Dc'])); sp = []
for c in (ridge.P['win0'] + np.arange(450, 850, 10)) % W:
r0, r1 = int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 4, int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 4; line = np.where(Ly['dpx'][r0:r1, c] < 0.5)[0]
if len(line) > 1: g = np.diff(line); sp += list(g[g > 1])
assert len(sp) > 20 and np.percentile(sp, 25) >= 8, ('contour lines too dense', np.percentile(sp, 25), len(sp))
def test_no_layer_paints_a_wall_down_the_frame():
"""Right answer: a layer's alpha is 0 further below its own crest than its foot allows (<= 210 px): its column boundary leaves no vertical edge through the floor (round 2 painted the body to the bottom of the
frame wherever the chain existed: hard walls below every chain end)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0)
for Ly in L: below = np.arange(ridge.H)[:, None] - (ridge.TOP_DEG - Ly['ang'])[None, :] * ridge.PXDEG_V; assert Ly['a'][below > 215].max() == 0, ('layer painted below its foot', Ly['rank'])
S = max(r['slots'], key=lambda S: S['top'][col_of(120)])
for Ly in L:
if Ly['dist'] != S['dist']: continue
end = [c for c in range(W) if 0 < S['wt'][c] < 0.12]; assert end, 'fixture has no taper end'
bad = [(c, int((Ly['a'][:, c] > 0.1).sum())) for c in end if (Ly['a'][:, c] > 0.1).sum() > 0.85 * max(Ly['ang'][c] - Ly['lo'][c], 0) * ridge.PXDEG_V + 4]; assert not bad, ('a chain end hangs a curtain thicker than the ridge is tall', bad[:4])
def test_slot_is_rotation_invariant_in_columns_where_no_chain_is_present():
"""Two plateaus in one distance slot (az 100-110 at 700 m, az 200-215 at 1100 m, both 20-24 km). Right answer: rotating the ring by 1600 columns changes nothing, in EVERY column of the slot including those where no chain is present
(round 3 first picked the first chain in list order there; list order depends on where column 0 is, and those values feed the blurs at every chain end: Half Dome's rotation test went from 0 to 97 px)."""
A, Hh = rays(terrain(block(100, 110, 20000, 24000, 700), block(200, 215, 20000, 24000, 1100))); r = ridge.sweep(D, A, Hh); R = 2800; r2 = ridge.sweep(D, np.roll(A, -R, axis=1), np.roll(Hh, -R, axis=1)) # 2800 columns carries the az 200-215 block through column 0: the chains swap places in list order
for S1 in r['slots']:
S2 = next(s for s in r2['slots'] if s['slot'] == S1['slot'])
for k in ('top', 'lo', 'wt', 'pd', 'ph', 'vh', 'slope'): assert np.abs(np.roll(S2[k], R) - S1[k]).max() < 1e-9, ('slot not rotation invariant', S1['slot'], k, np.abs(np.roll(S2[k], R) - S1[k]).max())
for k in ('hd', 'hh'): assert np.abs(np.roll(S2[k], R, axis=1) - S1[k]).max() < 1e-6, ('slot history not rotation invariant', S1['slot'], k)
def test_prominent_far_ridge_behind_a_basin_is_found():
"""Terrace (50 m, eye 100 m) ending at 10 km, a 0 m basin, a 60 m ridge at 30 km (az 80-100). Right answer: the ridge is a layer at 30 km: it stands 0.5 deg above its visible valley, which is 4 times the 25 m / 30 km bar of 0.048 deg.
(Round 3's first cut compared raw heights, ridge 60 m vs the valley's 0 m... and lost ridges whose valley sample is near and high.)"""
hf = lambda dk, az: np.where(dk < 10000, 50.0, np.where(in_arc(az, 80, 100) & (dk >= 30000) & (dk < 30500), 60.0, np.where(in_arc(az, 80, 100) & (dk >= 50000) & (dk < 55000), 300.0, 0.0))) # the 300 m wall at 50 km keeps the ridge from being committed as the ray's last peak
A, Hh = rays(hf); r = ridge.sweep(D, A, Hh); far = [l for l in r['keep'] if abs(l['dist'] - 30000) < 1500]; assert far and far[0]['span'] > 200, ('the 30 km ridge is missing', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']])
def test_lone_foreground_stub_is_kept_but_a_stub_inside_a_near_ridge_is_not():
"""(a) a 0.5 deg wide, 60 m block at 1.7 km with nothing else near: the nearest layer must cover it (a hole would show the far ridge's body). (b) the same stub in front of a long near ridge (az 100-160, 150 m, 1.5 km):
its columns are covered by that ridge, so it is dropped (round 2 kept every foreground stub: lone arches)."""
A, Hh = rays(terrain(block(90, 90.5, 1700, 1740, 110))); r = ridge.sweep(D, A, Hh); assert [l for l in r['keep'] if l['dist'] < 2500], 'lone foreground stub dropped: hole in the nearest layer'
A, Hh = rays(terrain(block(100, 160, 1500, 1600, 150), block(129, 129.5, 600, 640, 160))); r = ridge.sweep(D, A, Hh); assert not [l for l in r['keep'] if l['dist'] < 1000], ('a stub inside a near ridge became an arch', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']])
def test_tilted_plane_has_the_planes_front_slope_and_lateral_tilt():
"""Plane h = 50 + 0.3 (E - 20000) - 0.2 N over az 85-95, 20-24 km, then a drop. At az 90 the radial is +E and the tangent is -N, so n ~ (-0.3 r - la t, 1) with la = -(dh/dN) = +0.2. Right answer: front slope 0.3, lateral tilt 0.2, each within 0.04
(a lateral scale of 2x, or no tilt, fails)."""
def plane(dk, az):
E = dk * np.sin(np.radians(az)); N = dk * np.cos(np.radians(az)); return np.where(in_arc(az, 85, 95) & (dk >= 20000) & (dk < 24000), 50.0 + 0.3 * (E - 20000.0) - 0.2 * N, 0.0)
r, L = _bake_view(plane, 90.0); S, Ly = _owner(r, L, col_of(90), theta(50.0 + 0.3 * 4000, 24000)); cols = (ridge.P['win0'] + np.arange(600, 680)) % W
rows = np.arange(int((ridge.TOP_DEG - Ly['ang'][col_of(90)]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V)); sl = Ly['sl'][np.ix_(rows, cols)]; la = Ly['la'][np.ix_(rows, cols)]
assert len(rows) > 15 and abs(np.median(sl) - 0.3) < 0.04 and abs(np.median(la) - 0.2) < 0.04, ('plane slope / tilt wrong', np.median(sl), np.median(la))
def test_history_keeps_only_what_the_eye_sees():
"""Samples (angle, distance, height): (0, 100, 0) (2, 200, 10) (1, 300, 5) (1.5, 350, 8) (3, 400, 30). The third and fourth are hidden behind the second (the fourth RISES from the third, so only a running maximum drops it). Right answer: at elevation 2.5 the face is half way between the second and the fourth sample: distance 300, height 20
(without the running maximum the non-monotone angles make the interpolation garbage)."""
d, h = ridge.strip_history([0.0, 2.0, 1.0, 1.5, 3.0], [100.0, 200.0, 300.0, 350.0, 400.0], [0.0, 10.0, 5.0, 8.0, 30.0], 0, 4); t = np.linspace(0, 3.0, ridge.NH); k = int(np.argmin(np.abs(t - 2.5)))
assert abs(d[k] - (200 + 200 * (t[k] - 2) / 1.0)) < 1e-6 and abs(h[k] - (10 + 20 * (t[k] - 2) / 1.0)) < 1e-6 and d[0] == 100 and d[-1] == 400, (d[k], h[k])
def test_no_lateral_tilt_across_an_occlusion_edge():
"""Block A (az 60-130, 15 km out, 700 m) and block B (az 110-180, 21 km out, 940 m): the same top angle, the same distance slot, so the slot owner switches where their tapers cross. Right answer: at every column where the owner's
distance jumps by more than 2 km the two sides are not one surface, so the lateral tilt there is 0 (it saturated at the 1.5 clamp over ~8 columns on Matterhorn)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 130) & (dk >= 15000) & (dk < 19000), 700.0, np.where(in_arc(az, 110, 180) & (dk >= 21000) & (dk < 25000), 940.0, 0.0)), 120.0)
S, Ly = _owner(r, L, col_of(80), theta(700, 15000)); assert S['dist'] == ridge.KNOTS[3]
sw = [x for x in range(W - 1) if S['wt'][x] > 0 and S['wt'][x + 1] > 0 and abs(S['pd'][x + 1] - S['pd'][x]) > 2000]; assert sw, 'fixture has no owner switch'
for x in sw:
rows = np.arange(max(int((ridge.TOP_DEG - Ly['ang'][x]) * ridge.PXDEG_V) + 4, 0), min(int((ridge.TOP_DEG - Ly['lo'][x]) * ridge.PXDEG_V) - 4, ridge.H)); la = Ly['la'][np.ix_(rows, [x - 1, x, x + 1, x + 2])]
assert np.abs(la).max() < 0.5, ('tilt across an occlusion edge at column', x, np.abs(la).max())
# ---------------------------------------------------------------------------------------- relief light (round 3): n.L uses an authored light, colour / haze / shadows keep the real sun
def _gully(dk, az):
"""A ramp facing the viewer (0.35 slope, az 50-130, 20-24 km) cut by gullies that run down the slope: height +300 m sin(2 pi N / 3 km), so the lateral tilt swings between -0.63 and +0.63 across the face."""
N = dk * np.cos(np.radians(az)); return np.where(in_arc(az, 50, 130) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000) + 300.0 * np.sin(2 * np.pi * N / 3000.0), 0.0)
def _relief_probe(az_s, el_s):
"""Relight the gullied ramp seen from az 90 with spies on ndl_field (the L it shades with and the n.L it returns for the ramp's layer) and on cast_shadows (the sun it marches toward). Returns (image, Ls, n.L of the ramp layer, shadow suns, layer)."""
r, L = _bake_view(_gully, 90.0); pf = ridge.statics(); S = max(r['slots'], key=lambda S: S['top'][col_of(90)]); Ly = next(l for l in L if l['dist'] == S['dist']); real_nd, real_cs = ridge.ndl_field, ridge.cast_shadows; Ls, nds, suns = [], [], []
def nd_spy(layer, Lv): out = real_nd(layer, Lv); Ls.append(np.array(Lv)); nds.append((layer is Ly, out[0])); return out
def cs_spy(layers, az, el): suns.append((az, el)); return [np.zeros((3, W)) for _ in layers]
ridge.ndl_field, ridge.cast_shadows = nd_spy, cs_spy
try: img = ridge.relight(L, az_s, el_s, *pf)
finally: ridge.ndl_field, ridge.cast_shadows = real_nd, real_cs
return img, Ls, next(n for own, n in nds if own), suns, Ly
def _face_std(nd, Ly):
"""n.L std over the ramp's face pixels (6 px under the crest down to 1 deg elevation), columns 500-780 of the window"""
return float(np.concatenate([nd[int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 6:int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V), c] for c in (ridge.P['win0'] + np.arange(500, 780, 5)) % W]).std())
def test_sun_behind_the_viewer_still_models_the_face():
"""Gullied ramp seen from az 90, sun directly behind the viewer (az 270, 20 deg up). Lit head-on, a gully's two flanks shade alike (n.L std over the face 0.034 with the real sun as the shading light);
the relief light 45 deg off the axis lights one flank and leaves the other dim (0.23). Right answer: std over the face pixels > 0.12."""
img, Ls, nd, suns, Ly = _relief_probe(270.0, 20.0); b = _face_std(nd, Ly); assert b > 0.12, ('face is flat with the sun behind the viewer', b)
def test_relief_light_is_45_deg_off_the_view_axis_on_the_suns_side():
"""View centre az 90 (the viewer looks east). The relief light is the view centre +/- 45 deg on the side of the view axis the real sun is on. Sun at az 300 (north of the axis, the viewer's left) -> relief az 90-45 = 45;
sun at az 240 (south, the viewer's right) -> relief az 90+45 = 135. Elevation is 25..40 deg (an absolute range, not the constant) both times, whatever the sun's elevation."""
for sun_az, want in ((300.0, 45.0), (240.0, 135.0)):
_, Ls, _, _, _ = _relief_probe(sun_az, 25.0); assert Ls, 'no shading call'; L = Ls[0]; az = math.degrees(math.atan2(L[0], L[1])) % 360.0; el = math.degrees(math.asin(L[2]))
assert abs(az - want) < 0.5 and 25.0 <= el <= 40.0, ('relief direction wrong', sun_az, az, el)
def test_sun_elevation_changes_colour_but_not_the_relief_direction():
"""Same sun azimuth, elevation 10 deg vs 40 deg: the relief direction is identical, the picture is not (sun colour / haze / strength change), and at -10 deg (night) the face is darker than at 40."""
i1, L1, nd1, _, Ly = _relief_probe(300.0, 10.0); i2, L2, nd2, _, _ = _relief_probe(300.0, 40.0); i3, _, _, _, _ = _relief_probe(300.0, -10.0)
assert np.abs(L1[0] - L2[0]).max() < 1e-12 and np.abs(nd1 - nd2).max() < 1e-12, 'the relief direction follows the sun elevation'
c = (ridge.P['win0'] + 640) % W; rows = slice(int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 40)
assert np.abs(i1[rows, 640] - i2[rows, 640]).mean() > 0.01, 'colour does not follow the real sun'; assert i3[rows, 640].mean() < 0.6 * i2[rows, 640].mean(), ('night is not dark', i3[rows, 640].mean(), i2[rows, 640].mean())
def test_cast_shadows_march_toward_the_real_sun_not_the_relief_light():
"""relight must hand cast_shadows the real sun's azimuth and elevation, not the relief light's."""
_, _, _, suns, _ = _relief_probe(300.0, 17.0); assert suns == [(300.0, 17.0)], suns
# ---------------------------------------------------------------------------------------- Soul round-3 survivors
def test_floor_band_has_no_column_wise_luminance_step():
"""Plateau az 100-140 (20-24 km) seen from az 120. Right answer: in the floor band (the frame's bottom 80 rows) no column differs from its neighbour by more than the grain (baseline 0.009, a painted wall 0.25): the compositor must use the layer's
alpha (body dissolved at the foot), not its mask (body to the bottom of the frame, a wall at every chain end)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0); img = ridge.relight(L, 200.0, 20.0, *ridge.statics(), shadows=False)
lum = img @ np.array([0.2126, 0.7152, 0.0722]); st = np.abs(np.diff(lum[ridge.H - 80:], axis=1)).mean(0); assert st.max() < 0.03, ('wall: column-wise luminance step in the floor band', st.max(), int(np.argmax(st)))
def test_foot_fade_starts_at_60_percent_of_the_foot_and_the_body_is_opaque_above_it():
"""A layer's alpha is 1 down to 60% of its foot length, then smoothsteps to 0 at the foot. Right answer on a plateau column: rows with alpha > 0.99 / rows with alpha > 0.001 = 0.6 (+-0.04), and it has the full 210 px foot at most
(a fade that starts early makes the face translucent, one that starts late leaves a wall)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0); S, Ly = _owner(r, L, col_of(120), theta(900, 20000)); a = Ly['a'][:, col_of(120)]
full, nz = int((a > 0.99).sum()), int((a > 0.001).sum()); assert nz > 100, nz; assert abs(full / nz - 0.6) < 0.04, ('fade does not start at 60% of the foot', full, nz)
def test_lateral_tilt_is_smoothed_across_columns():
"""Two plateaus (700 / 1300 m) meeting at az 180: the raw lateral tilt is a spike at the step. Right answer: the baked tilt is blurred over a few columns: no column-to-column change above 0.5 (unblurred: 0.9), and the tilt is still > 0.2 at the step."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 180) & (dk >= 20000) & (dk < 24000), 700.0, np.where(in_arc(az, 180, 300) & (dk >= 20000) & (dk < 24000), 1300.0, 0.0)), 180.0)
S, Ly = _owner(r, L, col_of(120), theta(700, 20000)); rows = slice(int((ridge.TOP_DEG - theta(1300, 20000)) * ridge.PXDEG_V), int((ridge.TOP_DEG - 0.3) * ridge.PXDEG_V)); la = np.abs(Ly['la'][rows, :][:, (ridge.P['win0'] + np.arange(600, 680)) % W]).max(0)
assert la.max() > 0.2 and np.abs(np.diff(la)).max() < 0.5, ('tilt not smoothed', la.max(), np.abs(np.diff(la)).max())
def test_contour_interval_is_chosen_from_full_weight_columns_only():
"""Ramp (slope 1.0, az 110-130, 20-22 km) whose tapering ends carry coarser metres-per-pixel. Right answer: the contour spacing in the core is 9-10 px (interval from the chain's own face), not 13-14 (the taper columns pulled the percentile up). median <= 12 and p25 >= 8."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 110, 130) & (dk >= 20000) & (dk < 22000), 50.0 + 1.0 * (dk - 20000), 0.0), 120.0); S, Ly = _owner(r, L, col_of(120), theta(2050, 22000)); sp = []
for c in range(col_of(120) - 60, col_of(120) + 61, 6):
r0, r1 = int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 4, int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 4; line = np.where(Ly['dpx'][r0:r1, c] < 0.5)[0]
if len(line) > 1: g = np.diff(line); sp += list(g[g > 1])
assert len(sp) > 100 and np.percentile(sp, 25) >= 8 and np.median(sp) <= 12, ('contour spacing', len(sp), np.percentile(sp, 25), np.median(sp))
def test_spec_highlight_follows_the_half_vector_of_light_and_viewer():
"""spec_field against an independent evaluation: half vector H = normalise(L + V), V the unit vector back to the viewer (-r) in every column, n = (-sl r - la t + z)/|.|, GGX alpha 0.3. Two different lights, random faces: the field must match to 1e-9
(a half vector that ignores the light, or any change of the lobe, shows)."""
rng = np.random.default_rng(5); sl = rng.uniform(-0.3, 1.2, (7, W)); la = rng.uniform(-0.6, 0.6, (7, W)); nrm = np.sqrt(1 + sl * sl + la * la); az = ridge.azrad(); r = np.stack([np.sin(az), np.cos(az), 0 * az], 1); t = np.stack([np.cos(az), -np.sin(az), 0 * az], 1)
for laz, lel in ((45.0, 32.0), (200.0, 15.0)):
Lv = ridge.sun_vec(laz, lel); ndl = (Lv[2] - sl * (r @ Lv)[None] - la * (t @ Lv)[None]) / nrm; got = ridge.spec_field(Lv, sl, la, nrm, ndl)
H = Lv[None, :] - r; H /= np.linalg.norm(H, axis=1, keepdims=True); nh = np.clip((H[:, 2][None] - sl * (r * H).sum(1)[None] - la * (t * H).sum(1)[None]) / nrm, 0, 1)
want = 0.30 * 0.0081 / (1 + nh * nh * (0.09 - 1)) ** 2 * np.clip(ndl, 0, 1)
assert np.abs(got - want).max() < 1e-9, ('spec does not match the half-vector model', laz, np.abs(got - want).max())
def test_fresnel_rim_is_strongest_on_faces_that_lie_away_from_the_viewer():
"""fresnel(sl, nrm) = (1 - n.V)^3 with n.V = sl / |n|: 1 for a flat or back-leaning face (sl <= 0), falling monotonically to ~0 for a face that stands up at the viewer (sl = 5: 0.00001)."""
s = np.array([-1.0, 0.0, 0.2, 0.5, 1.0, 2.0, 5.0]); f = ridge.fresnel(s, np.sqrt(1 + s * s))
assert f[0] == 1.0 and f[1] == 1.0 and (np.diff(f[1:]) < 0).all() and f[-1] < 1e-3, ('fresnel shape', f)
def test_cache_keys_follow_every_input_of_the_cached_array():
"""mosaic-/rays- cache files are keyed by a digest of their inputs. Right answer: identical inputs -> identical key (the preset id does not enter), a changed preset field (eye height, range, position), tile list or baker constant (refraction, detail noise) -> a different key."""
saved = ridge.P
try:
base = dict(id='k1', lat=47.0, lon=11.0, rmax_km=40, eye_m=2000.0); ridge.P = dict(base); k0 = (ridge.rays_key(False), ridge.rays_key(True), ridge.mosaic_key(ridge.P))
ridge.P = dict(base, id='other'); assert (ridge.rays_key(False), ridge.rays_key(True), ridge.mosaic_key(ridge.P)) == k0, 'the preset id changed the key'
for fld, v in (('eye_m', 2010.0), ('rmax_km', 41), ('lon', 11.001), ('lat', 47.001)):
ridge.P = dict(base, **{fld: v}); assert ridge.rays_key(False) != k0[0], ('rays key ignores', fld)
ridge.P = dict(base, lat=47.0, lon=13.5); assert ridge.mosaic_key(ridge.P) != k0[2], 'mosaic key ignores the tile list'
ridge.P = dict(base); old = ridge.KREF; ridge.KREF = 0.2
try: assert ridge.rays_key(False) != k0[0], 'rays key ignores the refraction constant'
finally: ridge.KREF = old
old = ridge.DETAIL; ridge.DETAIL = dict(old, amp_m=30.0)
try: assert ridge.rays_key(True) != k0[1] and ridge.rays_key(False) == k0[0], 'detail key ignores the noise settings (or the plain key follows them)'
finally: ridge.DETAIL = old
finally: ridge.P = saved
def test_paths_come_from_the_environment_with_no_default():
"""Importing the baker without RIDGE_WORK / RIDGE_TILES fails with a message naming the variable (no location is assumed)."""
import subprocess; env = {k: v for k, v in os.environ.items() if not k.startswith('RIDGE_')}
for var in ('RIDGE_WORK', 'RIDGE_TILES'):
e = dict(env); e.update({'RIDGE_WORK': '.', 'RIDGE_TILES': '.'}); del e[var]
r = subprocess.run([sys.executable, '-I', '-c', 'import sys; sys.path.insert(0, %r); import ridge' % SRC], env=e, capture_output=True, text=True); assert r.returncode and var in r.stderr and 'not set' in r.stderr, (var, r.stderr[-200:])
# ---------------------------------------------------------------------------------------- planes export (export.py, ridge.relight_planes)
_before = {k for k in globals() if k.startswith('test_')} # the tests below are the planes/export tests tests/mutate_export.py runs against each mutant
import export, tempfile, json, shutil
def _three_slot_layers():
"""three blocks at 6, 20 and 50 km in the same azimuth range, each standing above the one before it in angle: three slots"""
blocks = [block(100, 140, 6000, 9000, 300), block(100, 140, 20000, 23000, 900), block(100, 140, 50000, 53000, 2500)]
r, L = _bake_view(terrain(*blocks), 120.0); ridge.P['floor_agl_m'] = 2.0; return r, L # a low floor plane: the nearest rows are solid ground
def _flat_layers(n):
"""n slots, plane-grouping only needs the list length: the real layers of the 3-slot fixture, truncated / repeated"""
r, L = _three_slot_layers(); return [L[i % len(L)] for i in range(n)]
def _statics_flat_grain(): paperf, grain, fmot = ridge.statics(); return paperf, np.ones_like(grain), fmot
def _over(planes, sky):
img = np.broadcast_to(np.array(sky), planes[export.PLANES[0]].shape[:2] + (3,)).copy()
for n in export.PLANES: a = planes[n]; img = img * (1 - a[..., 3:]) + a[..., :3] * a[..., 3:]
return img
def _open_to(L, first):
"""Pixels (window columns) that the layers from index `first` on leave open: no coverage within OCCLUDE_MARGIN_PX columns, so the plane's knock-out does not touch them."""
if first >= len(L): return np.ones((ridge.H, ridge.WIN), bool)
cov = ridge.win(1 - np.prod([1 - Ly['a'] for Ly in L[first:]], axis=0)); m = ridge.OCCLUDE_MARGIN_PX; pad = np.pad(cov, ((0, 0), (m, m)), mode='edge')
return np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1) < 1e-6
def test_the_far_plane_over_a_flat_sky_equals_the_opaque_relight_of_its_group():
"""The affine haze split is exact for one group: the far plane (floor + slot 0) over one flat colour equals relight of slot 0 alone with that colour as sky and haze (grain off, no shadows) within 1/255 mean and 4/255 max.
The planes carry no sky: above the ridge their alpha is 0."""
r, L = _three_slot_layers(); f = _statics_flat_grain(); sun = (200.0, 40.0); sky = export.SHEET_SKY
planes = ridge.relight_planes(L, *sun, *f, shadows=False, ref=sky); img = _over(dict(far=planes['far'], mid=0 * planes['mid'], near=0 * planes['near']), sky)
want = ridge.win(ridge.relight(L[:1], *sun, *f, shadows=False, flat=sky)); d = np.abs(img - want) * 255; d = d[_open_to(L, 1)] # a nearer layer hides the far plane where it covers (test_a_farther_plane_is_hidden_behind_the_nearer_layers_that_cover_it)
assert d.mean() < 1.0 and d.max() < 4.0, ('the far plane over a flat sky differs from its opaque relight', d.mean(), d.max())
assert planes['far'][0, :, 3].max() < 0.01 and planes['near'][0, :, 3].max() < 0.01, 'the top row (sky) is not transparent'
_VIENNA = {}
def _vienna_noon():
"""Real Vienna bake at noon: (planes over a flat sky as an error map in 1/255 per pixel, planes), cached for the tests that read it."""
if not _VIENNA:
saved = ridge.P; ridge.configure(ridge.load_preset('vienna')); d, A, Hh, _ = ridge.stage0(False); r = ridge.sweep(d, A, Hh); ridge.set_view(r['slots']); L = ridge.bake(r['slots']); f = _statics_flat_grain()
sun = export.sun_set(ridge.P, ridge.P['date'])['noon']; sky = export.SHEET_SKY; planes = ridge.relight_planes(L, sun['az'], sun['el'], *f, shadows=False, ref=sky)
_VIENNA['err'] = np.abs(_over(planes, sky) - ridge.win(ridge.relight(L, sun['az'], sun['el'], *f, shadows=False, flat=sky))).mean(-1) * 255; _VIENNA['planes'] = planes; ridge.P = saved
return _VIENNA['err'], _VIENNA['planes']
def test_stacked_planes_stay_close_to_the_opaque_relight_on_vienna_noon():
"""Real Vienna bake at noon, three planes over a flat sky vs relight(flat=that sky): the stacking loss (a nearer group's haze veils the farther plane instead of the sky) measured 4.9/255 mean over the frame before the farther planes were knocked out
behind the nearer layers, 1.8 after; the bound is 3, so a split that loses colour or alpha, or a knock-out that stops working, shows. The spec's 2/255 pin is met with 0.2 to spare; the loss is concentrated where planes overlap, see the next test."""
err, _ = _vienna_noon(); assert err.mean() < 3.0, ('stacked planes drift from the opaque relight', err.mean())
def test_stacked_planes_error_over_the_overlap_region_is_the_measured_leak():
"""Where a nearer plane covers a farther one (two or more planes with alpha above 1/100) the rest of the leak sits: measured 11.2/255 mean over 16.4% of the frame on Vienna noon (21.8 over 22.6% before the knock-out), bound 12.5. It is what the 48 px knock-out margin
and the easing leave visible (a soft darker blob beside the near plane's wedge), and the dissolving feet where a nearer layer is only partly opaque. Outside the overlap the planes are exact (0.005/255)."""
err, planes = _vienna_noon(); ov = sum((planes[n][..., 3] > 0.01).astype(int) for n in export.PLANES) >= 2
assert 0.10 < ov.mean() < 0.25, ('the overlap region is not the 16-ish percent the bound was measured on', ov.mean())
assert err[ov].mean() < 12.5, ('overlap-region error', err[ov].mean()); assert err[~ov].mean() < 0.1, ('error outside the overlap', err[~ov].mean())
def test_a_farther_plane_is_hidden_behind_the_nearer_layers_that_cover_it():
"""The opaque frame never shows a farther ridge through a nearer layer, but the stacked planes did (the nearer plane's haze share became transparency and the farther ridge showed in it: a slanted seam through the near plane on Vienna).
Right answer on the 3-slot fixture: where the nearer layers are opaque for OCCLUDE_MARGIN_PX columns around a pixel, the mid and far planes are transparent there; where nothing nearer is within the margin, the planes are exactly what they were without the knock-out;
and the knock-out eases in (a pixel just inside the covered region keeps most of its alpha), so no edge of its own is drawn."""
r, L = _three_slot_layers(); f = _statics_flat_grain(); on = ridge.relight_planes(L, 200.0, 40.0, *f, shadows=False); keep = ridge.most_open; ridge.most_open = lambda seen: np.ones_like(seen)
try: off = ridge.relight_planes(L, 200.0, 40.0, *f, shadows=False)
finally: ridge.most_open = keep
m = ridge.OCCLUDE_MARGIN_PX
for name, first in (('mid', 2), ('far', 1)):
cov = ridge.win(1 - np.prod([1 - Ly['a'] for Ly in L[first:]], axis=0)); reach = lambda w: np.lib.stride_tricks.sliding_window_view(np.pad(cov, ((0, 0), (w, w)), mode='edge'), 2 * w + 1, axis=1).min(-1)
deep = reach(3 * m) > 0.999 # the max filter looks m columns out and the easing another 1.5 m
assert deep.sum() > 2000 and (off[name][..., 3][deep] > 0.1).sum() > 500, (name, 'the fixture has no farther body under a nearer layer to hide')
assert on[name][..., 3][deep].max() < 0.03, (name, 'a farther plane shows through the layer in front of it', float(on[name][..., 3][deep].max()))
free = _open_to(L, first); assert np.array_equal(on[name][..., 3][free], off[name][..., 3][free]) and free.sum() > 2000, (name, 'the knock-out reached pixels no nearer layer covers')
edge = (cov > 0.999) & (reach(2) < 0.5) & (off[name][..., 3] > 0.1); assert edge.sum() > 50 and (on[name][..., 3][edge] / off[name][..., 3][edge]).mean() > 0.4, (name, 'the knock-out has a hard edge at the margin')
def test_the_nearest_layer_is_opaque_down_to_the_horizon_and_the_others_keep_their_dissolve():
"""Right answer: the nearest layer's alpha is 1 from its crest to the horizon row, whatever its strip's valley angle says (above the horizon a dissolved body shows the sky under a crest: the wedge of Vienna's near plane, whose valley angle jumps where the winning chain changes);
a farther layer keeps its dissolve (the far blue ridges fade into the haze above the horizon by design). Fixture: a 400 m block at 3 km (crest 5.7 deg, foot 210 px but 380 px to the horizon) and a 4000 m block at 20 km (a crest above the near one, so it is a ridge of its own)."""
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 3000) & (dk < 3600), 400.0, np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 4000.0, 0.0)), 120.0)
c = col_of(120); hz = int(np.argmax(ridge.rowang < 0)); assert len(L) == 2, [Ly['dist'] for Ly in L]
for Ly, solid in ((L[-1], True), (L[0], False)):
a = Ly['a'][:, c]; crest = int(np.argmax(a > 0.5)); body = a[crest + 3:hz]; assert body.size > 20, ('fixture crest is not above the horizon', crest, hz)
assert (body.min() > 0.999) if solid else (body.min() < 0.5), ('nearest' if solid else 'farther', 'layer alpha between crest and horizon', float(body.min()))
def test_near_plane_must_cover_every_column_and_leave_no_hole_under_its_crest():
"""check_near_cover: a near plane passes when every column has a crest and nothing between the crest and the horizon row is a hole; it refuses a fully transparent column, and a hole under a crest (the wedge: alpha 0 at the plane's right edge, rows 230-480 with a hard edge, in the committed Vienna set).
Holes below the horizon are the foot dissolving into the floor and pass."""
ok = np.zeros((60, 40, 4), np.uint8); ok[10:, :, 3] = 200; export.check_near_cover(ok, 30.0); export.check_near_cover(ok, 59.0)
cols = ok.copy(); cols[:, 33:, 3] = 0; wedge = ok.copy(); wedge[20:50, 30:, 3] = 0; below = ok.copy(); below[35:, 30:, 3] = 0
export.check_near_cover(below, 30.0)
for bad, why in ((cols, 'transparent'), (wedge, 'hole')):
try: export.check_near_cover(bad, 40.0)
except ValueError as e: assert why in str(e), (why, str(e))
else: assert False, ('a near plane with a ' + why + ' was accepted')
export.check_near_cover(ok, 30.0)
def test_write_bake_refuses_a_near_plane_with_a_wedge_and_lets_the_other_planes_have_empty_columns():
"""The refusal is at the writer, on the decoded near plane of every frame; the far and mid planes may have transparent columns (a place with no ridge at that distance in part of the window)."""
d = tempfile.mkdtemp()
try:
place, view, suns, planes = _fixture_bake()
for k in export.KEYS: planes[k]['mid'][:, 200:260, 3] = 0
export.write_bake(d + '/ok', place, view, suns, _encode_all(planes))
for k, ys, xs in (('dawn', slice(20, 80), slice(1200, 1280)), ('night', slice(0, 600), slice(600, 700))):
bad = {kk: {n: np.array(v) for n, v in vv.items()} for kk, vv in planes.items()}; bad[k]['near'][ys, xs, 3] = 0
_refuses(d + '/bad', place, view, suns, _encode_all(bad), 'a near plane with a transparent wedge in frame %s was written' % k)
finally: shutil.rmtree(d)
def test_plane_grouping_far_mid_near_and_refuses_fewer_than_three_slots():
"""far = slot 0, near = the last slot, mid = every slot between (the union); a 3-slot place fills all three planes; fewer than 3 slots are refused."""
assert ridge.group_slots(3) == dict(far=[0], mid=[1], near=[2]) and ridge.group_slots(6) == dict(far=[0], mid=[1, 2, 3, 4], near=[5])
for n in (0, 1, 2):
try: ridge.group_slots(n)
except ValueError: continue
assert False, ('a place with %d slots was accepted' % n)
r, L = _three_slot_layers(); f = _statics_flat_grain(); pl = ridge.relight_planes(L[:3], 200.0, 40.0, *f, shadows=False)
assert all(pl[n][..., 3].max() > 0.5 for n in export.PLANES), {n: float(pl[n][..., 3].max()) for n in export.PLANES}
try: ridge.relight_planes(L[:2], 200.0, 40.0, *f, shadows=False)
except ValueError: pass
else: assert False, 'a 2-slot place was relit into planes'
def test_straight_alpha_planes_unpremultiply_the_composite():
"""Planes carry straight (un-premultiplied) colour: the mid plane (slot 1, 16 km) and the near plane (slot 2, 5 km) each alone over a flat sky equal the opaque relight of that one slot over the same sky, on the rows
above the horizon (the floor belongs to the far plane). Their bodies are translucent (the haze share is alpha's deficit), so premultiplied colour would be darker by alpha and fail by tens of 1/255. u8() keeps colour and alpha apart."""
a = np.zeros((2, 2, 4), np.float32); a[0, 0] = (0.5, 0.25, 0.75, 0.5); b = export.u8(a); assert tuple(b[0, 0]) == (128, 64, 191, 128) and tuple(b[1, 1]) == (0, 0, 0, 0)
r, L = _three_slot_layers(); f = _statics_flat_grain(); sun = (200.0, 40.0); sky = export.SHEET_SKY; pl = ridge.relight_planes(L, *sun, *f, shadows=False, ref=sky); top = int((ridge.rowang >= 0).sum())
for name, i in (('mid', 1), ('near', 2)):
got = _over({n: (pl[n] if n == name else 0 * pl[n]) for n in export.PLANES}, sky)[:top]; want = ridge.win(ridge.relight(L[i:i + 1], *sun, *f, shadows=False, flat=sky))[:top]
body = pl[name][:top, :, 3] > 0.3; assert body.sum() > 1000 and pl[name][:top, :, 3][body].mean() < 0.97, (name, 'the fixture has no translucent body to test')
d = (np.abs(got - want) * 255)[_open_to(L, i + 1)[:top]]; assert d.mean() < 0.5 and d.max() < 4.0, (name, 'plane over a flat sky differs from its slot relight: colour is premultiplied or the haze split is wrong', d.mean(), d.max())
def _encode_all(planes): return {k: {n: export.encode(planes[k][n]) for n in export.PLANES} for k in export.KEYS}
def _fixture_bake():
r, L = _three_slot_layers(); f = _statics_flat_grain(); suns = {k: dict(az=100.0 + 20 * i, el=-10.0 + 15 * i, hour_utc=float(i)) for i, k in enumerate(export.KEYS)}
planes = {k: ridge.relight_planes(L, suns[k]['az'], max(suns[k]['el'], 5.0) if k != 'night' else 5.0, *f, shadows=False) for k in export.KEYS}
for k in export.KEYS: planes[k]['near'][..., 3] = np.maximum(planes[k]['near'][..., 3], 0.6) # the writer refuses a near plane with an empty column: the fixture's nearest ridge covers only 40 degrees, so its plane is lifted to cover the window
place = dict(id='synthetic', name='Synthetic', lat=48.0, lon=16.0, altitude_km=0.1, tz_h=2, tz_name='CEST', date='2026-09-23')
view = dict(look_az_deg=120.0, plane_h=ridge.H, px_per_deg_h=ridge.HPX_DEG, px_per_deg_v=ridge.PXDEG_V, horizon_row=100.0, sky_row=24.0, near_row=396.0)
return place, view, suns, planes
def _sized(h, w, seed=0, noise=False):
"""the same plane for every key and name, h rows by w columns: a soft mid-grey ramp, or white noise when noise (a plane that does not compress)"""
rng = np.random.default_rng(seed); a = rng.random((h, w, 4)).astype(np.float32) if noise else np.dstack([np.full((h, w), 0.5), np.linspace(0, 1, w)[None].repeat(h, 0), np.full((h, w), 0.3), np.full((h, w), 0.4)]).astype(np.float32)
return {k: {n: export.encode(a) for n in export.PLANES} for k in export.KEYS}
def _refuses(out, place, view, suns, webp, why):
try: export.write_bake(out, place, view, suns, webp)
except ValueError: assert not os.path.exists(out), ('a refused bake left files', why)
else: assert False, why
def test_manifest_validates_bake_id_is_deterministic_and_luma_is_recorded():
"""Two independent bakes (the relight and the encode each run twice, not one plane set encoded twice) give the same bytes and bake_id; the manifest validates against the schema, plane w/h are what the written files decode to, frame pairs fit the budget, luma min <= max in 0..1 per frame,
the index points at the dir, a rebake replaces the place's old dir, and nothing but the dir and index.json is left in the output."""
d = tempfile.mkdtemp()
try:
place, view, suns, planes = _fixture_bake(); webp = _encode_all(planes); again = _encode_all(_fixture_bake()[3]); assert webp == again, 'two independent bakes (relight and encode run twice) gave different plane bytes'
a = export.write_bake(d + '/a', place, view, suns, webp); b = export.write_bake(d + '/b', place, view, suns, again)
assert a.name == b.name and a.name == 'synthetic-' + export.bake_id(webp), (a.name, b.name); assert sorted(os.listdir(d + '/a')) == sorted(['index.json', a.name]), os.listdir(d + '/a')
m = json.load(open(a / 'manifest.json')); export.validate(m, export.SCHEMA['$defs']['manifest']); assert m['bake_id'] == a.name.split('-')[1] and [f['key'] for f in m['frames']] == list(export.KEYS)
assert m['view']['plane_w'] <= export.PLANE_W_MAX and m['view']['window_deg'] == 72 and m['view']['plane_w'] == ridge.WIN and m['view']['plane_h'] == ridge.H
for f in m['frames']:
assert 0 <= f['luma']['min'] <= f['luma']['max'] <= 1, f['luma']; assert f['bytes'] == sum(p['bytes'] for p in f['planes'].values())
for p in f['planes'].values():
assert (a / p['file']).stat().st_size == p['bytes'] and export.decode((a / p['file']).read_bytes()).shape[:2] == (p['h'], p['w']) == (ridge.H, ridge.WIN), p
assert max(m['frames'][i]['bytes'] + m['frames'][i + 1]['bytes'] for i in range(5)) <= export.PAIR_BYTES_MAX
idx = json.load(open(d + '/a/index.json')); export.validate(idx, export.SCHEMA['$defs']['index']); assert idx == dict(schema=1, default='synthetic', places={'synthetic': a.name})
webp2 = {k: dict(v) for k, v in webp.items()}; webp2['noon']['far'] = export.encode(np.full((ridge.H, ridge.WIN, 4), 0.5, np.float32) * np.array([1, 1, 1, 2], np.float32)); c = export.write_bake(d + '/a', place, view, suns, webp2)
assert c.name != a.name and not a.exists() and json.load(open(d + '/a/index.json'))['places']['synthetic'] == c.name, 'the old dir of the place survives a rebake'; assert sorted(os.listdir(d + '/a')) == sorted(['index.json', c.name])
bad = json.loads(json.dumps(m)); bad['frames'][0]['extra'] = 1
try: export.validate(bad, export.SCHEMA['$defs']['manifest'])
except ValueError: pass
else: assert False, 'an extra key passed the schema'
committed = json.load(open(os.path.join(SRC, 'ridge-bake.schema.json'))); assert committed == json.loads(json.dumps(export.SCHEMA)), 'ridge-bake.schema.json is stale (export.py --write-schema)'
finally: shutil.rmtree(d)
def test_index_default_keeps_the_first_place_and_follows_an_explicit_default():
"""A second place leaves the index default at the first; an explicit default names the default; a default naming no baked place falls back to the place just baked."""
d = tempfile.mkdtemp()
try:
place, view, suns, planes = _fixture_bake(); webp = _encode_all(planes); other = dict(place, id='other')
export.write_bake(d, place, view, suns, webp); export.write_bake(d, other, view, suns, webp); idx = json.load(open(d + '/index.json')); assert idx['default'] == 'synthetic' and sorted(idx['places']) == ['other', 'synthetic'], idx
export.write_bake(d, other, view, suns, webp, default='other'); assert json.load(open(d + '/index.json'))['default'] == 'other'
idx = json.load(open(d + '/index.json')); idx['default'] = 'gone'; open(d + '/index.json', 'w').write(json.dumps(idx)); export.write_bake(d, other, view, suns, webp); assert json.load(open(d + '/index.json'))['default'] == 'other'
finally: shutil.rmtree(d)
def test_bake_id_follows_the_plane_bytes_in_order():
"""bake_id = first 12 hex of sha256 over the plane bytes in key order, then far, mid, near: one changed byte in any plane changes it, swapping two planes changes it, and the value equals an independent hash."""
import hashlib
w = {k: {n: bytes([i, j]) * 5 for j, n in enumerate(export.PLANES)} for i, k in enumerate(export.KEYS)}; want = hashlib.sha256(b''.join(w[k][n] for k in export.KEYS for n in ('far', 'mid', 'near'))).hexdigest()[:12]
assert export.bake_id(w) == want
for k in export.KEYS:
v = {kk: dict(vv) for kk, vv in w.items()}; v[k]['near'] = v[k]['near'] + b'x'; assert export.bake_id(v) != want, k
v = {kk: dict(vv) for kk, vv in w.items()}; v[k]['far'], v[k]['mid'] = v[k]['mid'], v[k]['far']; assert export.bake_id(v) != want, k
def test_budget_refuses_a_pair_above_the_cap_and_a_plane_above_the_width():
"""Any two ADJACENT frames above PAIR_BYTES_MAX are refused (a heavy frame next to a light one at the cap passes), as is a plane wider than the cap, and nothing is written."""
cap = export.PAIR_BYTES_MAX; ok = [cap // 2] * 6; export.check_budget(ok, export.PLANE_W, ridge.H)
for i in range(6):
bad = [1000] * 6; bad[i] = cap // 2 + 1; bad[(i + 1) % 6] = cap // 2 + 1
try: export.check_budget(bad, export.PLANE_W, ridge.H)
except ValueError: pass
else: assert False, ('adjacent pair over the cap accepted', i)
try: export.check_budget([400_001, 1, 1, 1, 1, 400_000], export.PLANE_W, ridge.H)
except ValueError as e: assert 'night + dawn' in str(e), str(e)
else: assert False, 'night + dawn summing 800001 were accepted'
export.check_budget([400_000, 1, 1, 1, 1, 400_000], export.PLANE_W, ridge.H)
export.check_budget([cap - 1000, 1000, cap - 1000, 1000, cap - 1000, 1000], export.PLANE_W, ridge.H) # non-adjacent heavy frames are fine
export.check_budget(ok, export.PLANE_W_MAX, 100) # exactly the cap is fine
try: export.check_budget(ok, export.PLANE_W_MAX + 1, ridge.H)
except ValueError: pass
else: assert False, 'a plane wider than the cap was accepted'
d = tempfile.mkdtemp()
try:
place, view, suns, planes = _fixture_bake(); webp = _encode_all(planes); fat = {k: dict(v) for k, v in webp.items()}
fat['dusk']['mid'] = export.encode(np.random.default_rng(1).random((ridge.H, ridge.WIN, 4)).astype(np.float32)); assert len(fat['dusk']['mid']) > cap // 2, 'the noise plane does not weigh enough'
_refuses(d + '/o', place, view, suns, fat, 'an over-budget bake was written')
finally: shutil.rmtree(d)
def test_size_refusals_read_the_written_planes_not_constants():
"""write_bake measures the planes it was handed: a 5000 px plane is refused (cap 4096) and so are planes that disagree on size or with the view's plane_h; a frame pair decoding above 20 MB is refused (700 rows) and at 650 rows is written;
the manifest carries the measured w and h (1000 px planes say w=1000), never a constant."""
d = tempfile.mkdtemp()
try:
place, view, suns, _ = _fixture_bake(); v = lambda h: dict(view, plane_h=h)
_refuses(d + '/wide', place, v(100), suns, _sized(100, 5000), 'planes 5000 px wide were accepted')
mixed = _sized(100, 1000); mixed['noon']['mid'] = _sized(100, 900)['noon']['mid']; _refuses(d + '/mixed', place, v(100), suns, mixed, 'planes of two widths were accepted')
_refuses(d + '/rows', place, v(101), suns, _sized(100, 1000), 'planes whose height is not the view plane_h were accepted')
_refuses(d + '/heavy', place, v(700), suns, _sized(700, 1280), 'a frame pair decoding to 21.5 MB was accepted')
a = export.write_bake(d + '/ok', place, v(650), suns, _sized(650, 1280)); m = json.load(open(a / 'manifest.json')); assert m['view']['plane_w'] == 1280 and m['view']['plane_h'] == 650
b = export.write_bake(d + '/narrow', place, v(100), suns, _sized(100, 1000)); m = json.load(open(b / 'manifest.json'))
assert m['view']['plane_w'] == 1000 and all(p['w'] == 1000 and p['h'] == 100 for f in m['frames'] for p in f['planes'].values()), m['view']
finally: shutil.rmtree(d)
def test_the_shipped_manifest_carries_the_attribution_verbatim():
"""The Copernicus DEM licence obliges the credit line to ship with the planes: write_bake of the fixture bake (the shipped planes path) writes a manifest whose attribution is exactly the four pinned strings, each its own field:
credit_line, licence, dem_source and url (a blanked, shortened or swapped field fails by name)."""
d = tempfile.mkdtemp()
try:
place, view, suns, planes = _fixture_bake(); dest = export.write_bake(d, place, view, suns, _encode_all(planes)); a = json.load(open(dest / 'manifest.json'))['attribution']
want = dict(credit_line='Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.',
licence='Copernicus DEM licence: free, full and open access, with the credit line shown', dem_source='Copernicus DEM GLO-30 (30 m)',
url='https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model')
assert sorted(a) == sorted(want), sorted(a)
for k in want: assert a[k] == want[k], (k, a[k])
finally: shutil.rmtree(d)
def _srgb_y(c): c = np.asarray(c, float); lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4); return float(lin @ np.array([0.2126, 0.7152, 0.0722]))
def test_luma_range_is_the_far_mid_near_composite_over_a_grey_sky_where_coverage_is_at_least_half():
"""Hand-built 1x5 planes, none opaque anywhere (haze-as-alpha): px0 far white alpha 200 then near black alpha 128 (order matters, coverage .89); px1 far grey 128 alpha 128 (coverage .50, measured); px2 far white alpha 120
(coverage .47, NOT measured: it would be the brightest); px3 far and mid black alpha 100 each (coverage .63, measured); px4 nothing. min and max equal the arithmetic composite over the 0.5 grey sky to 1e-6."""
z = lambda: np.zeros((1, 5, 4), np.uint8); far, mid, near = z(), z(), z()
far[0, 0] = (255, 255, 255, 200); near[0, 0] = (0, 0, 0, 128); far[0, 1] = (128, 128, 128, 128); far[0, 2] = (255, 255, 255, 120); far[0, 3] = (0, 0, 0, 100); mid[0, 3] = (0, 0, 0, 100)
sky = np.full(3, 0.5); A, a, b = 200 / 255, 128 / 255, 100 / 255; p0 = (sky * (1 - A) + A) * (1 - a); p1 = sky * (1 - a) + 128 / 255 * a; p3 = sky * (1 - b) ** 2
want = sorted(_srgb_y(p) for p in (p0, p1, p3)); lo, hi = export.luma_range([far, mid, near]); assert abs(lo - want[0]) < 1e-6 and abs(hi - want[-1]) < 1e-6, ((lo, hi), want)
try: export.luma_range([z(), z(), z()])
except ValueError as e: assert 'coverage' in str(e), ('the refusal does not name the coverage', str(e))
else: assert False, 'a frame with no covered pixel was measured'
z2 = export.decode(export.encode(np.full((8, 8, 4), 1.0, np.float32))); assert z2.shape == (8, 8, 4) and (z2[..., 3] == 255).all(), 'an everywhere-opaque plane must read back as RGBA'
def _rt_error(planes, back):
sky = export.SHEET_SKY; return np.abs(_over({n: planes[n] for n in export.PLANES}, sky) - _over({n: back[n].astype(np.float32) / 255 for n in export.PLANES}, sky)) * 255
def test_encode_round_trips_the_shipped_planes_with_real_grain_and_the_default_reference():
"""The planes as shipped (default reference haze, the real grain, WebP quality 80) decode to the float planes: composited over a flat sky the round trip costs under 1.7/255 mean and 32/255 max (measured 1.47 and 28.6 on this hard-edged fixture; quality 50 measures 1.64 and 34, quality 5 measures 2.16 and 73), straight
colour (not premultiplied), alpha kept, rows not flipped; the real grain multiplies the colour exactly (the ratio to a grain-free bake is win(grain))."""
r, L = _three_slot_layers(); paperf, grain, fmot = ridge.statics(); sun = (200.0, 40.0)
shipped = ridge.relight_planes(L, *sun, paperf, grain, fmot, shadows=False); flat = ridge.relight_planes(L, *sun, paperf, np.ones_like(grain), fmot, shadows=False)
back = {n: export.decode(export.encode(shipped[n])) for n in export.PLANES}; assert all(back[n].shape == shipped[n].shape for n in export.PLANES), 'decode changed the plane shape'
e = _rt_error(shipped, back); assert e.mean() < 1.7 and e.max() < 32.0, ('encode/decode round trip', e.mean(), e.max())
for n in export.PLANES: assert abs(back[n][..., 3].astype(float) / 255 - shipped[n][..., 3]).max() < 0.1, ('alpha lost in the round trip', n)
g = ridge.win(grain); ok = (flat['near'][..., 3] > 0.2) & (flat['near'][..., :3].max(-1) * 1.03 < 1)
assert ok.sum() > 1000 and np.abs(shipped['near'][..., :3][ok] / flat['near'][..., :3][ok] - np.broadcast_to(g, ok.shape)[ok][:, None]).max() < 1e-3, 'the grain does not multiply the plane colour'
def test_default_reference_is_the_frames_own_haze_colour_and_shadows_darken_the_planes():
"""With no reference given the far plane over the frame's own haze colour equals the opaque relight of its group over that colour (<1 mean, 4 max in 1/255), not over the sky's horizon colour or a grey; cast shadows reach the planes:
a forced shadow of 1 darkens the far-group colour, a forced 0 equals shadows off."""
r, L = _three_slot_layers(); paperf, grain, fmot = ridge.statics(); f1 = (paperf, np.ones_like(grain), fmot); sun = (200.0, 40.0); st = ridge.sun_state(*sun); hz = ridge.haze_colour(ridge.sky_hz(st, paperf)[1])
far = ridge.relight_planes(L, *sun, *f1, shadows=False)['far']; sky = ridge.win(hz[None].astype(np.float32))[0]
img = _over(dict(far=far, mid=0 * far, near=0 * far), sky); want = ridge.win(ridge.relight(L[:1], *sun, *f1, shadows=False, flat=hz)); d = np.abs(img - want) * 255
assert d.mean() < 1.0 and d.max() < 4.0, ('the default reference is not the frame haze colour', d.mean(), d.max())
real = ridge.cast_shadows
try:
ridge.cast_shadows = lambda layers, az, el: [np.ones((3, W)) for _ in layers]; dark = ridge.relight_planes(L, *sun, *f1)
ridge.cast_shadows = lambda layers, az, el: [np.zeros((3, W)) for _ in layers]; lit = ridge.relight_planes(L, *sun, *f1)
finally: ridge.cast_shadows = real
off = ridge.relight_planes(L, *sun, *f1, shadows=False); body = off['near'][..., 3] > 0.5
assert body.sum() > 1000 and dark['near'][..., :3][body].mean() < off['near'][..., :3][body].mean() - 0.02, 'cast shadows do not reach the planes'
assert all(np.abs(lit[n] - off[n]).max() < 1e-4 for n in export.PLANES), 'a zero shadow differs from shadows off'
def test_sun_set_keys_sit_at_their_elevations_and_hours():
"""The six keys on Vienna's preset date: noon is the day's highest sun, golden at a geometric elevation of 6.0 deg after it, dusk -2.5 deg, night -12.0 deg (the schema's night frame), dawn just above the horizon a quarter hour after sunrise, morning at 9.5 h local;
hours increase dawn < morning < noon < golden < dusk < night. Elevations are re-read from sun.solar, not from the keys: the keys are geometric (solar()[2]) and the manifest's el is the apparent one (solar()[1], refraction added)."""
import sun; from datetime import datetime, timedelta, timezone
p = ridge.load_preset('vienna'); s = export.sun_set(p, p['date']); assert list(s) == list(export.KEYS)
day = datetime.fromisoformat(p['date']).replace(tzinfo=timezone.utc); el = lambda h: sun.solar(day + timedelta(hours=h), p['lat'], p['lon'])[1]
geo = lambda h: sun.solar(day + timedelta(hours=h), p['lat'], p['lon'])[2]
for k in s: assert abs(el(s[k]['hour_utc']) - s[k]['el']) < 1e-9, k
assert abs(geo(s['golden']['hour_utc']) - 6.0) < 0.01 and abs(geo(s['dusk']['hour_utc']) + 2.5) < 0.01 and abs(geo(s['night']['hour_utc']) + 12.0) < 0.01 and abs(s['night']['el'] + 12.0) < 0.01, {k: v['el'] for k, v in s.items()}
assert max(el(h / 100) for h in range(0, 2400, 5)) - s['noon']['el'] < 0.01 and abs(s['morning']['hour_utc'] - (9.5 - p['tz_h'])) < 1e-9
assert -0.833 < s['dawn']['el'] < 3.0, s['dawn']; hs = [s[k]['hour_utc'] for k in export.KEYS]; assert hs == sorted(hs), hs
def test_bake_place_manifest_fields_on_vienna():
"""bake_place on the real Vienna preset: altitude is the ground (km) without the eye height, horizon_row is the row where the elevation angle is 0 (inside the frame, between sky_row and near_row), px_per_deg_h is the ring's;
write_bake of its encoded planes gives finite luma for every frame, index default 'vienna', the full Copernicus credit line, and no temp dir left behind."""
saved = ridge.P
try:
place, view, suns, planes = export.bake_place('vienna', relight_keys=export.KEYS); P = ridge.P; rowang = ridge.rowang; pxv = ridge.PXDEG_V
finally: ridge.P = saved
assert abs(place['altitude_km'] * 1000 - P['ground_m']) < 1e-6 and P['eye_m'] > 0 and place['id'] == 'vienna' and place['date'] == P['date'] and place['lat'] == P['lat'] and place['lon'] == P['lon']
assert abs(view['sky_row'] - 0.04 * ridge.H) < 1e-9 and abs(view['near_row'] - 0.66 * ridge.H) < 1e-9, 'sky_row / near_row are rows (set_view anchors 4% and 66% of the frame)'
assert 0 < view['sky_row'] < view['horizon_row'] < view['near_row'] < ridge.H and abs(rowang[int(view['horizon_row'])]) <= 1.0 / pxv, (view, rowang[int(view['horizon_row'])])
assert view['look_az_deg'] == P['az_center'] and view['plane_h'] == ridge.H and view['px_per_deg_h'] == ridge.HPX_DEG and view['px_per_deg_v'] == pxv
d = tempfile.mkdtemp()
try:
dest = export.write_bake(d, place, view, suns, _encode_all(planes)); m = json.load(open(dest / 'manifest.json'))
assert all(math.isfinite(f['luma'][k]) and 0 <= f['luma']['min'] < f['luma']['max'] <= 1 for f in m['frames'] for k in ('min', 'max')), [f['luma'] for f in m['frames']]
assert json.load(open(d + '/index.json'))['default'] == 'vienna' and sorted(os.listdir(d)) == sorted(['index.json', dest.name]), os.listdir(d)
c = m['attribution']['credit_line']; assert c.startswith('Contains modified Copernicus data') and 'Airbus Defence and Space' in c and 'ESA' in c and m['attribution']['licence'] and m['attribution']['url'].startswith('https://'), m['attribution']
finally: shutil.rmtree(d)
PLANES_TESTS = [v for k, v in sorted(globals().items()) if k.startswith('test_') and k not in _before]
TESTS = [v for k, v in sorted(globals().items()) if k.startswith('test_')]
if __name__ == '__main__':
fails = 0
for t in TESTS:
try: t(); print('PASS', t.__name__)
except Exception as e: fails += 1; print('FAIL', t.__name__, '|', repr(e)[:300])
print('failures:', fails); sys.exit(1 if fails else 0)

View file

@ -16,6 +16,7 @@ import {
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
import { PhoneHit } from '../styles/PhoneHit.css';
import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { Page, PageHeader } from './page';
@ -294,7 +295,7 @@ export function AccountDataEditor({
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -30,6 +30,7 @@ import {
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
import { PhoneHit } from '../styles/PhoneHit.css';
type BackupStatusProps = {
enabled: boolean;
@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
<BackupSyncing count={remainingSession} />
)}
<IconButton
className={PhoneHit}
aria-pressed={!!menuCords}
size="300"
variant="Surface"

View file

@ -30,6 +30,7 @@ import {
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { PhoneHit } from '../styles/PhoneHit.css';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@ -261,7 +262,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={handleCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider';
import { PhoneHit } from '../styles/PhoneHit.css';
type UIACallback<T> = (
authDict: AuthDict | null
@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes">
<Text size="H4">Setup Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes">
<Text size="H4">Reset Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -27,6 +27,7 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type PdfViewerProps = {
name: string;
@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
className={PhoneHitSpread24}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
@ -130,6 +132,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
className={PhoneHitSpread24}
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"

View file

@ -13,6 +13,7 @@ import {
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { PhoneHit } from '../styles/PhoneHit.css';
export type UIAFlowOverlayProps = {
currentStep: number;
@ -53,6 +54,7 @@ export function UIAFlowOverlay({
>
{(anchorRef) => (
<IconButton
className={PhoneHit}
ref={anchorRef}
variant="Critical"
size="300"

View file

@ -1,84 +0,0 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
config,
Icon,
IconButton,
Icons,
Line,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar';
import { stopPropagation } from '../../utils/keyboard';
export function EditorPreview() {
const [open, setOpen] = useState(false);
const editor = useEditor();
const [toolbar, setToolbar] = useState(false);
return (
<>
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500">
<div style={{ padding: config.space.S400 }}>
<CustomEditor
editor={editor}
placeholder="Send a message..."
before={
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
aria-pressed={toolbar}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
}
/>
</div>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}

View file

@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return (
@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread36}
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
@ -104,6 +106,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread36}
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
@ -163,6 +166,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitRoom36}
ref={triggerRef}
onClick={() => handleMenuSelect(1)}
size="400"
@ -178,6 +182,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitRoom36}
ref={triggerRef}
onClick={() => handleMenuSelect(2)}
size="400"
@ -193,6 +198,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitRoom36}
ref={triggerRef}
onClick={() => handleMenuSelect(3)}
size="400"
@ -208,6 +214,7 @@ export function HeadingBlockButton() {
}
>
<IconButton
className={PhoneHit}
style={{ width: 'unset' }}
variant="SurfaceVariant"
onClick={handleMenuOpen}
@ -239,6 +246,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
@ -344,6 +352,7 @@ export function Toolbar() {
>
{(triggerRef) => (
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="SurfaceVariant"
onClick={() => setIsMarkdown(!isMarkdown)}

View file

@ -14,6 +14,7 @@ import {
} from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function Sidebar({ children }: { children: ReactNode }) {
return (
@ -68,6 +69,7 @@ function SidebarBtn<T extends string>({
>
{(ref) => (
<IconButton
className={PhoneHit}
aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref}

View file

@ -24,6 +24,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type EventReadersProps = {
room: Room;
@ -52,7 +53,7 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes">
<Text size="H3">Seen by</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -2,6 +2,7 @@ import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageEditor.css';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type ImageEditorProps = {
name: string;
@ -24,7 +25,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
>
<Header className={css.ImageEditorHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type ImageViewerProps = {
alt: string;
@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
className={PhoneHitSpread24}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
@ -55,6 +57,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
className={PhoneHitSpread24}
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"

View file

@ -42,6 +42,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive';
import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -20,6 +20,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
<Box grow="Yes">
<Text size="H4">Join with Address</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveRoomPromptProps = {
roomId: string;
@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
<Box grow="Yes">
<Text size="H4">Leave Room</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveSpacePromptProps = {
roomId: string;
@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
<Box grow="Yes">
<Text size="H4">Leave Space</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -2,6 +2,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
className={PhoneHit}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />

View file

@ -1,6 +1,9 @@
import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
import { PHONE_TAP } from '../../styles/PhoneHit.css';
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const Container = createVar();
const ContainerHover = createVar();
const ContainerActive = createVar();
@ -25,6 +28,14 @@ export const Reaction = style([
selectors: {
'button&': {
cursor: 'pointer',
// Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
// same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
'@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
},
'button&::after': {
'@media': {
[PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
},
},
'&[aria-pressed=true]': {
vars: {

View file

@ -3,6 +3,7 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
@ -159,6 +160,7 @@ export function AudioContent({
variant="SurfaceVariant"
size="300"
radii="Pill"
className={PhoneHitSpread24}
onClick={() => setMute(!mute)}
aria-pressed={mute}
>

View file

@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(own && css.BubbleColumnOwn)}
className={classNames(css.BubbleColumn, own && css.BubbleColumnOwn)}
>
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{hideBubble ? (
children
) : (
<Box>
<Box className={css.BubbleContentRow}>
<Box
className={classNames(
css.BubbleContent,

View file

@ -0,0 +1,48 @@
import { readFileSync } from 'fs';
import { describe, expect, it } from 'vitest';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
// A long reply quote (nowrap) sized the own-message row to 782px on a 390px phone. The row, the column and the bubble
// must be able to shrink to their parent, so pin the emitted declarations (as phoneTapTargets.test.ts does).
const emitted = async () => {
const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`;
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
const bytes = Buffer.from(
(code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1],
'base64'
);
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
};
const declarations = (css: string, className: string): string[] => {
const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css);
expect(rule, `no .layout_${className} rule emitted`).toBeTruthy();
return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, ''));
};
describe('bubble rows shrink to their column', () => {
it.each([
['BubbleColumn', ['min-width: 0']],
['BubbleContentRow', ['min-width: 0', 'max-width: 100%']],
['BubbleContent', ['min-width: 0']],
])('%s', async (name, expected) => {
const css = await emitted();
expect(declarations(css, name)).toEqual(expect.arrayContaining(expected));
});
it('BubbleContent keeps its 800px cap inside the column width', async () => {
const css = await emitted();
expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/);
});
it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => {
const tsx = readFileSync(
`${process.cwd()}/src/app/components/message/layout/Bubble.tsx`,
'utf8'
);
expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/);
expect(tsx).toMatch(/<Box className=\{css\.BubbleContentRow\}>/);
});
});

View file

@ -139,8 +139,22 @@ export const BubbleBefore = style({
minWidth: toRem(36),
});
// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text),
// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge.
export const BubbleColumn = style({
minWidth: 0,
});
// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text
// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it.
export const BubbleContentRow = style({
minWidth: 0,
maxWidth: '100%',
});
export const BubbleContent = style({
maxWidth: toRem(800),
minWidth: 0,
maxWidth: `min(100%, ${toRem(800)})`,
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
@ -285,7 +299,10 @@ export const BubbleLeftArrow = style({
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
// No background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
@ -295,7 +312,7 @@ export const Username = style({
'button&': {
cursor: 'pointer',
'@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
},
},
'button&:hover, button&:focus-visible': {

View file

@ -53,6 +53,10 @@ export const NavCategoryHeader = style({
'&&': { paddingLeft: 0 },
},
'@media': {
// A phone's section header is as tall as its 44 px button, so it does not overlap the next row.
'screen and (max-width: 750px)': {
selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } },
},
// 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)': {
@ -117,6 +121,8 @@ const NavItemBase = style({
[PHONE]: {
backgroundColor: 'transparent',
borderRadius: 0,
// The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap.
minHeight: toRem(44),
},
[WIDE]: {
selectors: {

View file

@ -1,6 +1,7 @@
import React, { ComponentProps, forwardRef } from 'react';
import { Icon, IconButton, Input, config, Icons } from 'folds';
import { UseStateProvider } from '../UseStateProvider';
import { PhoneHit } from '../../styles/PhoneHit.css';
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
size: '400' | '500';
@ -23,6 +24,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
<>
{after}
<IconButton
className={PhoneHit}
onClick={() => setVisible(!visible)}
type="button"
variant={visible ? 'Warning' : variant}

View file

@ -4,6 +4,7 @@ import classNames from 'classnames';
import Linkify from 'linkify-react';
import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { PhoneHit } from '../../styles/PhoneHit.css';
export const RoomTopicViewer = as<
'div',
@ -26,7 +27,7 @@ export const RoomTopicViewer = as<
{name}
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300">
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -5,6 +5,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
import { ErrorBoundary } from 'react-error-boundary';
import * as css from './TextViewer.css';
import { copyToClipboard } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>(
>
<Header className={css.TextViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>

View file

@ -4,6 +4,7 @@ import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function PasswordStage({
stageData,
@ -45,7 +46,7 @@ export function PasswordStage({
<Box grow="Yes">
<Text size="H4">Account Password</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -1,6 +1,7 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { StageComponentProps } from './types';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function SSOStage({
ssoRedirectURL,
@ -56,7 +57,7 @@ export function SSOStage({
<Box grow="Yes">
<Text size="H4">SSO Login</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -6,6 +6,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { UploadFailureNote } from './UploadFailureNote';
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
@ -63,6 +65,7 @@ export function CompactUploadCardRenderer({
</Chip>
)}
<IconButton
className={PhoneHitSpread28}
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"
@ -91,7 +94,7 @@ export function CompactUploadCardRenderer({
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
<UploadFailureNote error={upload.error} />
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (

View file

@ -12,6 +12,8 @@ import {
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { UploadFailureNote } from './UploadFailureNote';
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type PreviewImageProps = {
fileItem: TUploadItem;
@ -160,6 +162,7 @@ export function UploadCardRenderer({
</Chip>
)}
<IconButton
className={PhoneHitSpread28}
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"
@ -190,7 +193,7 @@ export function UploadCardRenderer({
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
<UploadFailureNote error={upload.error} />
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (

View file

@ -0,0 +1,33 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { uploadFailure } from '../../utils/matrix';
import { UploadFailureNote } from './UploadFailureNote';
const text = (html: string) =>
html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
describe('the failed upload card', () => {
it('leads with the reason and the next step, and keeps the diagnostics behind Details', async () => {
const error = await uploadFailure(new DOMException('', 'AbortError'), new Blob(['abcd']), {
startedAt: Date.now(),
});
const html = renderToStaticMarkup(<UploadFailureNote error={error} />);
const before = html.slice(0, html.indexOf('<details'));
expect(text(before)).toBe(
'Upload failed: the connection dropped before the file started. Send again.'
);
expect(before).not.toContain('bytes');
expect(html).toMatch(/<details[^>]*>.*Details.*<\/details>/s);
expect(text(html.slice(html.indexOf('<details')))).toContain('4 bytes');
});
it('an error nobody classified still shows a sentence, not a blank card', () => {
const html = renderToStaticMarkup(<UploadFailureNote error={new Error('boom')} />);
expect(text(html)).toContain('Upload failed. Send again');
expect(text(html)).toContain('boom');
});
});

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Text } from 'folds';
import { uploadFailureText } from '../../utils/uploadReason';
/** A failed upload as the card shows it: the reason and what to do, the diagnostic text one tap away. */
export function UploadFailureNote({ error }: { error: Error }) {
return (
<>
<Text size="T200">{uploadFailureText(error)}</Text>
<details style={{ flex: 1, minWidth: 0 }}>
<summary style={{ minHeight: 44, display: 'flex', alignItems: 'center' }}>
<Text as="span" size="T200">
Details
</Text>
</summary>
<Text size="T200" style={{ wordBreak: 'break-word' }}>
{error.message}
</Text>
</details>
</>
);
}

View file

@ -1,3 +1,4 @@
import classNames from 'classnames';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
@ -10,6 +11,7 @@ import {
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css';
import { PhoneHitInPlace } from '../../styles/PhoneHit.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -181,7 +183,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
className={classNames(
css.UrlPreviewHolderBtn({ position: 'Left' }),
PhoneHitInPlace
)}
variant="Secondary"
radii="Pill"
size="300"
@ -199,7 +204,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
className={classNames(
css.UrlPreviewHolderBtn({ position: 'Right' }),
PhoneHitInPlace
)}
variant="Primary"
radii="Pill"
size="300"

View file

@ -43,6 +43,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { PhoneHit } from '../../styles/PhoneHit.css';
type SelfDemoteAlertProps = {
power: number;
@ -70,7 +71,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
<Box grow="Yes">
<Text size="H4">Self Demotion</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
<Box grow="Yes">
<Text size="H4">Shared Power</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
<Text size="H4">Add Existing</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -49,3 +49,23 @@ export const FilesWide = style({
});
export const Dim = style({ opacity: config.opacity.P300 });
/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */
export const SharePanel = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
export const ShareTitle = style({
all: 'unset',
boxSizing: 'border-box',
minHeight: 44,
width: '100%',
borderBottom: `1px solid ${color.Background.ContainerLine}`,
color: color.Background.OnContainer,
':focus-visible': { borderBottomColor: color.Background.OnContainer },
});
/** Picked names and send notes wrap, even a long unbroken name. */
export const ShareNames = style({ overflowWrap: 'anywhere' });

View file

@ -14,8 +14,17 @@ import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { readLine } from './fileReceipt';
import {
Album,
AlbumFile,
TimelineEvent,
lastEvent,
looseFiles,
looseNoteFor,
} from './buildAlbums';
import { folderOf, listFolder, pathLabel } from './albumFolders';
import { FileReadBy } from './FileReadBy';
import { ShareSet, useShareEnabled } from './ShareSet';
import { useAlbums } from './useAlbums';
import * as css from './AlbumView.css';
@ -48,11 +57,7 @@ function FileRow({ room, file, author }: FileRowProps) {
<Text size="T300" truncate>
{file.name}
</Text>
{file.readBy.length > 0 && (
<Text size="T200" className={css.Dim}>
{readLine(file.readBy, (id) => getMemberDisplayName(room, id) ?? id)}
</Text>
)}
<FileReadBy room={room} fileId={file.id} content={file.content} also={file.readBy} />
{audio ? (
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
) : (
@ -70,6 +75,26 @@ function FileRow({ room, file, author }: FileRowProps) {
);
}
/** A file, and under it a row that unfolds the versions it supersedes. */
function VersionedFileRow({ room, file, author }: FileRowProps) {
const [shown, setShown] = useState(false);
const n = file.earlier.length;
return (
<>
<FileRow room={room} file={file} author={author} />
{n > 0 && (
<button type="button" className={css.Row} onClick={() => setShown(!shown)}>
<Text size="T200" className={css.Dim}>
{`${shown ? '▾' : '▸'} ${n} earlier ${n === 1 ? 'version' : 'versions'}`}
</Text>
</button>
)}
{shown &&
file.earlier.map((e) => <FileRow key={e.id} room={room} file={e} author={author} />)}
</>
);
}
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
@ -88,15 +113,51 @@ function useMarkZoneRead(room: Room) {
}, [mx, room, hideActivity]);
}
/** The file events in the room's live timeline that no album thread holds, kept current as it syncs. */
function useLooseFiles(room: Room): AlbumFile[] {
const [, bump] = useState(0);
useEffect(() => {
const on = () => bump((n) => n + 1);
room.on(RoomEvent.Timeline, on);
room.on(RoomEvent.Redaction, on);
return () => {
room.off(RoomEvent.Timeline, on);
room.off(RoomEvent.Redaction, on);
};
}, [room]);
const events: TimelineEvent[] = room
.getLiveTimeline()
.getEvents()
.map((e) => ({
event_id: e.getId(),
type: e.getType(),
sender: e.getSender(),
origin_server_ts: e.getTs(),
content: e.getContent(),
unsigned: e.getUnsigned() as Record<string, unknown>,
threadRoot: e.threadRootId,
}));
return looseFiles(events);
}
type AlbumViewProps = { room: Room; onTimeline: () => void };
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
useMarkZoneRead(room);
const { loading, error, albums } = useAlbums(room);
const { loading, error, albums, truncated } = useAlbums(room);
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
const [openId, setOpenId] = useState<string>();
const [path, setPath] = useState<string[]>([]);
const open: Album | undefined = albums.find((a) => a.id === openId);
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
const encrypted = room.hasEncryptionStateEvent();
const canShare = useShareEnabled(room);
const loose = useLooseFiles(room);
const looseNote = looseNoteFor(loose, {
open: !!open,
encrypted,
loading,
error: !!error,
});
let body: React.ReactNode;
if (loading) body = <Text>Loading albums…</Text>;
@ -105,39 +166,98 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
body = (
<>
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
<Text size="B400">‹ Albums</Text>
<button
type="button"
className={css.Back}
onClick={() => {
setPath(folderOf(open));
setOpenId(undefined);
}}
>
<Text size="B400">{`‹ ${
folderOf(open).length ? pathLabel(folderOf(open)) : 'Albums'
}`}</Text>
</button>
<Text size="H4">{open.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
</Text>
{open.count === 0 && <Text>This album has no files.</Text>}
{canShare && (
<ShareSet
key={open.id}
room={room}
into={{ albumId: open.id, lastId: lastEvent(open).id, lastTs: lastEvent(open).ts }}
/>
)}
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
{open.files.map((f) => (
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
<VersionedFileRow key={f.id} room={room} file={f} author={authorOf(open)} />
))}
</div>
</>
);
} else if (encrypted)
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
else if (albums.length === 0 && path.length === 0) body = <Text>No albums yet.</Text>;
else {
body = albums.map((a) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{a.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
));
const here = listFolder(albums, path);
body = (
<>
{path.length > 0 && (
<button type="button" className={css.Back} onClick={() => setPath(path.slice(0, -1))}>
<Text size="B400">{`‹ ${
path.length > 1 ? pathLabel(path.slice(0, -1)) : 'Albums'
}`}</Text>
</button>
)}
{path.length > 0 && <Text size="H4">{pathLabel(path)}</Text>}
{here.folders.map((f) => (
<button
key={`d:${f.name}`}
type="button"
className={css.Row}
onClick={() => setPath(f.path)}
>
<Text size="T400">{`${f.name} /`}</Text>
<Text size="T200" className={css.Dim}>
{f.albums} {f.albums === 1 ? 'album' : 'albums'} · {timeDayMonYear(f.ts, dateFormat)}
</Text>
</button>
))}
{here.albums.map(({ album: a, leaf }) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{leaf}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
))}
{here.folders.length === 0 && here.albums.length === 0 && <Text>Nothing here.</Text>}
{truncated && (
<Text size="T200" className={css.Dim}>
The list is cut short: some albums or files are not loaded.
</Text>
)}
</>
);
}
return (
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
{looseNote && (
<Text size="T200" className={css.Dim}>
{looseNote}
</Text>
)}
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
{canShare && (
<div hidden={!!open}>
<ShareSet room={room} folder={path} />
</div>
)}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>

View file

@ -0,0 +1,32 @@
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Text } from 'folds';
import { getMemberDisplayName } from '../../utils/room';
import { FileReader, readLine } from './fileReceipt';
import { useFileReaders } from './useFileReaders';
import * as css from './AlbumView.css';
type FileReadByProps = {
room: Room;
fileId: string;
content: Record<string, unknown>;
/** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */
also?: FileReader[];
};
/** "fetched for <Mind>" under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */
export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) {
const seen = useFileReaders(room, fileId, content);
const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort(
(a, b) => a.ts - b.ts
);
if (readers.length === 0) return null;
return (
<Text size="T200" className={css.Dim}>
{readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)}
</Text>
);
}
/** A timeline message that is a file: audio, image, video or file. */
export const isFileMessage = (mEvent: MatrixEvent): boolean =>
['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype);

View file

@ -0,0 +1,140 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { MediaConfig, MediaConfigProvider } from '../../hooks/useMediaConfig';
// ShareSet hands its folder to the title the send lands under. The controller is held still and its
// `run` captured, and sendShareSet records the title it was given, so the path from the `folder`
// prop to the album root's title is read without a homeserver or a DOM.
const captured = vi.hoisted(() => ({
run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown),
maxBytes: undefined as undefined | (() => number | undefined),
titles: [] as string[],
}));
vi.mock('./shareController', () => ({
createShareController: (deps: {
run: NonNullable<typeof captured.run>;
maxBytes: NonNullable<typeof captured.maxBytes>;
}) => {
captured.run = deps.run;
captured.maxBytes = deps.maxBytes;
return {
subscribe: () => () => undefined,
getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }),
pick: () => undefined,
setTitle: () => undefined,
send: () => undefined,
cancel: () => undefined,
};
},
}));
vi.mock('./shareSet', async (importOriginal) => ({
...(await importOriginal<typeof import('./shareSet')>()),
sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => {
captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`);
return { albumId: '$a', sent: 0, failed: [], failedAt: [] };
},
}));
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>();
// The server renderer has no snapshot for an external store; read the client one.
const useSyncExternalStore = (_sub: unknown, get: () => unknown) => get();
return { ...actual, default: { ...actual, useSyncExternalStore }, useSyncExternalStore };
});
// eslint-disable-next-line import/first
import { ShareSet } from './ShareSet';
const timeline: unknown[] = [];
const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({
getId: () => id,
getTs: () => ts,
threadRootId,
status,
});
const mount = (
folder?: string[],
into?: { albumId: string; lastId: string; lastTs: number },
mediaConfig: MediaConfig = {}
) => {
const room = {
roomId: '!r:x',
hasEncryptionStateEvent: () => false,
getLiveTimeline: () => ({ getEvents: () => timeline }),
} as never;
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<MatrixClientProvider value={{ sendMessage: async () => ({ event_id: '$e' }) } as never}>
<MediaConfigProvider value={mediaConfig}>
<ShareSet room={room} folder={folder} into={into} />
</MediaConfigProvider>
</MatrixClientProvider>
</QueryClientProvider>
);
};
const send = async (typed: string) => {
await captured.run?.([], typed, () => undefined);
return captured.titles.at(-1);
};
describe('ShareSet: the folder reaches the album title', () => {
beforeEach(() => {
captured.run = undefined;
captured.titles.length = 0;
timeline.length = 0;
});
it('at the top, the typed title goes as typed', async () => {
mount();
expect(await send('Poses')).toBe('Poses');
});
it('inside a folder, the path prefixes the typed title', async () => {
mount(['cast-art', 'poses']);
expect(await send('Set 2')).toBe('cast-art/poses/Set 2');
});
it('an empty typed title falls back to the default, still under the folder', async () => {
mount(['handoff']);
expect(await send(' ')).toBe('handoff/Shared files');
});
it('a typed title containing a slash goes deeper', async () => {
mount(['a']);
expect(await send('b/c')).toBe('a/b/c');
});
it('an existing album takes the files instead of a title, whatever the folder', async () => {
mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 });
expect(await send('ignored')).toBe('into:$alb@$last');
});
it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => {
timeline.push(
ev('$other', 99, '$else'),
ev('$local', 98, '$alb', 'sending'),
ev('$new', 50, '$alb'),
ev('$sentNotEchoed', 60, '$alb', 'sent'),
ev('$failed', 70, '$alb', 'not_sent')
);
mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 });
expect(await send('x')).toBe('into:$alb@$sentNotEchoed');
});
});
describe('ShareSet: the homeserver upload cap reaches the controller', () => {
it('is the server-stated m.upload.size, and none when the server states none', () => {
mount([], undefined, { 'm.upload.size': 52428800 });
expect(captured.maxBytes?.()).toBe(52428800);
mount([], undefined, {});
expect(captured.maxBytes?.()).toBeUndefined();
// A cap that is not a positive number is no cap, never "refuse everything".
[0, -1, Number.NaN, '50' as unknown as number].forEach((bad) => {
mount([], undefined, { 'm.upload.size': bad });
expect(captured.maxBytes?.()).toBeUndefined();
});
});
});

View file

@ -0,0 +1,123 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ClientConfigProvider } from '../../hooks/useClientConfig';
import type { ClientConfig } from '../../hooks/useClientConfig';
import { ShareState } from './shareController';
import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet';
const text = (html: string) =>
html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const noop = () => undefined;
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
const view = (over: Partial<ShareState<File>> = {}, adding = false) =>
renderToStaticMarkup(
<ShareSetView
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
onPickClick={noop}
onPicked={noop}
onTitle={noop}
onSend={noop}
onCancel={noop}
onStop={noop}
adding={adding}
/>
);
describe('the share panel, drawn', () => {
it('idle: one "Share files" row and no title, names, Send or Cancel', () => {
const html = view();
expect(text(html)).toBe('Share files ›');
expect(html).not.toContain('aria-label="Album title"');
expect(html).toContain('type="file"');
expect(html).toContain('multiple');
expect(html).toContain('hidden');
});
it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => {
const html = view({ files: [file('a.ogg'), file('b.ogg')] });
expect(html).toContain('aria-label="Album title"');
expect(html).toContain('placeholder="Shared files"');
expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel');
expect(text(html)).not.toContain('Share files');
});
it('one picked file reads "Send 1 file"', () => {
expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel');
});
it('sending: progress replaces Send and Cancel, and the title is locked', () => {
const html = view({
files: [file('a'), file('b'), file('c')],
phase: { kind: 'sending', done: 2 },
});
expect(text(html)).toContain('Sending 2 of 3…');
expect(text(html)).toContain('Stop');
expect(text(html)).not.toContain('Send 3 files');
expect(text(html)).not.toContain('Cancel');
expect(html).toMatch(
/aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/
);
});
it('the encrypted note shows above the kept pick', () => {
const html = view({
files: [file('a.ogg')],
phase: { kind: 'done', text: 'Not shared: this room is encrypted.' },
});
expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel');
});
it('after a full success the idle row and the note show together', () => {
expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe(
'Share files › Shared 2 files.'
);
});
});
describe('adding to an existing album', () => {
it('the idle row says Add files', () => {
expect(text(view({}, true))).toBe('Add files ›');
});
it('picked: no title field, the names, Send and Cancel', () => {
const html = view({ files: [file('a.ogg')] }, true);
expect(html).not.toContain('aria-label="Album title"');
expect(text(html)).toBe('a.ogg Send 1 file Cancel');
});
});
describe('the flag', () => {
it('off, missing or empty offers no share', () => {
expect(shareEnabled({}, false)).toBe(false);
expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false);
expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false);
});
it('on offers it, except in an encrypted room', () => {
expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true);
expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false);
});
it('the hook reads the client config and the room, as AlbumView mounts it', () => {
const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never);
function Probe({ encrypted }: { encrypted: boolean }) {
return <span>{String(useShareEnabled(room(encrypted)))}</span>;
}
const probe = (config: ClientConfig, encrypted: boolean) =>
text(
renderToStaticMarkup(
<ClientConfigProvider value={config}>
<Probe encrypted={encrypted} />
</ClientConfigProvider>
)
);
expect(probe({}, false)).toBe('false');
expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true');
expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false');
});
});

View file

@ -0,0 +1,210 @@
import React, { useRef, useState, useSyncExternalStore } from 'react';
import { Text } from 'folds';
import { useQueryClient } from '@tanstack/react-query';
import { EventStatus, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { uploadContent } from '../../utils/matrix';
import { safeFile } from '../../utils/mimeTypes';
import { getMsgContent } from '../room/msgContent';
import { FILE_HASH_KEY, hashForSend } from './fileReceipt';
import { ShareController, ShareState, createShareController } from './shareController';
import { titleIn } from './albumFolders';
import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } from './shareSet';
import * as css from './AlbumView.css';
/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */
export const shareEnabled = (
config: { fileZoneShare?: { enabled?: boolean } },
encrypted: boolean
) => !!config.fileZoneShare?.enabled && !encrypted;
export const useShareEnabled = (room: Room): boolean =>
shareEnabled(useClientConfig(), room.hasEncryptionStateEvent());
type ShareViewProps = {
state: ShareState<File>;
onPickClick: () => void;
onPicked: (files: File[]) => void;
onTitle: (title: string) => void;
onSend: () => void;
onCancel: () => void;
onStop: () => void;
/** Adding to an existing album: the files join it, so there is no title to ask for. */
adding?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
};
/**
* One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked
* names, Send. Nothing but the row is visible until files are picked.
*/
export function ShareSetView({
state,
onPickClick,
onPicked,
onTitle,
onSend,
onCancel,
onStop,
adding,
inputRef,
}: ShareViewProps) {
const { files, title, phase, epoch } = state;
return (
<div className={css.SharePanel}>
<input
key={epoch}
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => onPicked(Array.from(e.target.files ?? []))}
/>
{files.length === 0 && phase.kind !== 'sending' && (
<button type="button" className={css.Row} onClick={onPickClick}>
<Text size="B400">{adding ? 'Add files ›' : 'Share files ›'}</Text>
</button>
)}
{phase.kind === 'done' && (
<Text size="T200" className={css.ShareNames}>
{phase.text}
</Text>
)}
{files.length > 0 && (
<>
{!adding && (
<input
className={css.ShareTitle}
value={title}
placeholder={DEFAULT_TITLE}
aria-label="Album title"
disabled={phase.kind === 'sending'}
onChange={(e) => onTitle(e.target.value)}
/>
)}
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
{files.map((f) => f.name).join(' · ')}
</Text>
{phase.kind === 'sending' ? (
<>
<Text size="T300">
Sending {phase.done} of {files.length}…
</Text>
<button type="button" className={css.Row} onClick={onStop}>
<Text size="B400">Stop</Text>
</button>
</>
) : (
<>
<button type="button" className={css.Row} onClick={onSend}>
<Text size="B400">
Send {files.length} {files.length === 1 ? 'file' : 'files'}
</Text>
</button>
<button type="button" className={css.Row} onClick={onCancel}>
<Text size="B400">Cancel</Text>
</button>
</>
)}
</>
)}
</div>
);
}
/** The sent events in the room's live timeline, as the thread view of them `freshest` reads. */
const threadEvents = (room: Room): ThreadEvent[] =>
room
.getLiveTimeline()
.getEvents()
.filter((e) => (!e.status || e.status === EventStatus.SENT) && !!e.getId())
.map((e) => ({ id: e.getId() as string, ts: e.getTs(), threadRoot: e.threadRootId }));
type ShareSetProps = {
room: Room;
/** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */
folder?: string[];
/** Add the files to this album instead of making one. */
into?: ExistingAlbum;
};
export function ShareSet({ room, folder = [], into }: ShareSetProps) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const mediaConfig = useMediaConfig();
const input = useRef<HTMLInputElement>(null);
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
// rerender with a new client or Room object must not build another. Live values go through `live`.
const live = useRef({ mx, room, queryClient, folder, into, mediaConfig });
live.current = { mx, room, queryClient, folder, into, mediaConfig };
const [controller]: [ShareController<File>, unknown] = useState(() =>
createShareController<File>({
roomId: room.roomId,
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
maxBytes: () => {
const cap = live.current.mediaConfig['m.upload.size'];
return typeof cap === 'number' && Number.isFinite(cap) && cap > 0 ? cap : undefined;
},
onSent: () =>
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
run: (files, title, onFile, signal) =>
sendShareSet(
{
signal,
send: async (content) =>
(await live.current.mx.sendMessage(room.roomId, content as any)).event_id,
contentOf: async (f) => {
const file = safeFile(f.file);
const mxc = await new Promise<string>((resolve, reject) => {
const { mx: client } = live.current;
let upload: Promise<unknown> | undefined;
const abort = () => upload && client.cancelUpload(upload as never);
signal.addEventListener('abort', abort, { once: true });
uploadContent(client, file, {
onPromise: (p) => {
upload = p;
if (signal.aborted) abort();
},
onSuccess: resolve,
onError: reject,
});
});
const content = await getMsgContent(
live.current.mx,
{
file,
originalFile: f.file,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
},
mxc
);
const sha256 = await hashForSend(f.file);
if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 };
return content;
},
onFile,
},
live.current.into
? freshest(live.current.into, threadEvents(live.current.room))
: titleIn(live.current.folder, title, DEFAULT_TITLE),
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
),
})
);
const state = useSyncExternalStore(controller.subscribe, controller.getState);
return (
<ShareSetView
state={state}
inputRef={input}
onPickClick={() => input.current?.click()}
onPicked={controller.pick}
onTitle={controller.setTitle}
onSend={controller.send}
onCancel={controller.cancel}
onStop={controller.stop}
adding={!!into}
/>
);
}

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import { buildAlbums } from './buildAlbums';
import type { Album, ZoneEvent } from './buildAlbums';
import { folderOf, listFolder, splitPath, titleIn } from './albumFolders';
import { DEFAULT_TITLE, sendShareSet } from './shareSet';
const album = (id: string, title: string, ts = 1): Album => ({
id,
title,
author: '@a:x',
ts,
files: [],
count: 0,
});
const names = (l: ReturnType<typeof listFolder>) => ({
folders: l.folders.map((f) => `${f.name}:${f.albums}`),
albums: l.albums.map((a) => `${a.album.id}=${a.leaf}`),
});
describe('splitPath', () => {
it('drops empty segments and padding; dots are names, not navigation', () => {
expect(splitPath(' a // b /')).toEqual(['a', 'b']);
expect(splitPath('../x')).toEqual(['..', 'x']);
expect(splitPath('///')).toEqual([]);
});
});
describe('listFolder', () => {
const all = [
album('$1', 'handoff/2026-11/notes', 10),
album('$2', 'handoff/2026-11/plan', 30),
album('$3', 'handoff/readme', 20),
album('$4', 'cast-art', 5),
album('$5', 'Plain album', 40),
];
it('root: first segments become folders (counted below, newest first); single-segment titles are albums', () => {
expect(names(listFolder(all, []))).toEqual({
folders: ['handoff:3'],
albums: ['$5=Plain album', '$4=cast-art'],
});
});
it('nested: a folder shows its sub-folders and its own albums under their leaf names', () => {
expect(names(listFolder(all, ['handoff']))).toEqual({
folders: ['2026-11:2'],
albums: ['$3=readme'],
});
expect(names(listFolder(all, ['handoff', '2026-11']))).toEqual({
folders: [],
albums: ['$2=plan', '$1=notes'],
});
});
it('an album titled exactly like a folder is not hidden by it', () => {
const l = listFolder([album('$a', 'x'), album('$b', 'x/y')], []);
expect(names(l)).toEqual({ folders: ['x:1'], albums: ['$a=x'] });
expect(names(listFolder([album('$a', 'x'), album('$b', 'x/y')], ['x']))).toEqual({
folders: [],
albums: ['$b=y'],
});
});
it('a path that exists nowhere lists nothing; the prefix match is by whole segment', () => {
expect(names(listFolder(all, ['hand']))).toEqual({ folders: [], albums: [] });
});
it('an untitled or all-slash album stays at the root, nowhere else', () => {
const odd = [album('$u', '///')];
expect(names(listFolder(odd, []))).toEqual({ folders: [], albums: ['$u=///'] });
expect(names(listFolder(odd, ['x']))).toEqual({ folders: [], albums: [] });
expect(names(listFolder([album('$w', ' ')], []))).toEqual({
folders: [],
albums: ['$w=Untitled album'],
});
});
it('server-supplied names like "constructor" are folders, not prototype hits', () => {
expect(names(listFolder([album('$c', 'constructor/a')], []))).toEqual({
folders: ['constructor:1'],
albums: [],
});
});
});
describe('titleIn and folderOf', () => {
it('prefixes the folder, keeps a typed sub-path, falls back to the default title', () => {
expect(titleIn([], '', DEFAULT_TITLE)).toBe(DEFAULT_TITLE);
expect(titleIn([], ' Mine ', DEFAULT_TITLE)).toBe('Mine');
expect(titleIn(['uploads', 'me'], '', DEFAULT_TITLE)).toBe(`uploads/me/${DEFAULT_TITLE}`);
expect(titleIn(['uploads'], 'a/b', DEFAULT_TITLE)).toBe('uploads/a/b');
});
it('folderOf is the title path without its leaf', () => {
expect(folderOf(album('$', 'a/b/c'))).toEqual(['a', 'b']);
expect(folderOf(album('$', 'c'))).toEqual([]);
});
});
describe('a share into a folder, read back as the viewer reads it', () => {
it('posts a root titled under the folder, and buildAlbums + listFolder put it in that folder', async () => {
const sent: Record<string, unknown>[] = [];
const res = await sendShareSet(
{
send: async (c) => {
sent.push(c);
return `$e${sent.length}`;
},
contentOf: async (f) => ({ msgtype: 'm.file', body: f.name, filename: f.name }),
},
titleIn(['uploads', 'meta'], 'Photos', DEFAULT_TITLE),
[{ name: 'a.png', mimeType: 'image/png' }]
);
expect(res.sent).toBe(1);
expect(sent[0]).toEqual({ msgtype: 'm.text', body: 'uploads/meta/Photos' });
const asEvent = (c: Record<string, unknown>, id: string): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@a:x',
origin_server_ts: 1,
content: c,
});
const albums = buildAlbums([
{ root: asEvent(sent[0], '$e1'), replies: [asEvent(sent[1], '$e2')] },
]);
expect(names(listFolder(albums, []))).toEqual({ folders: ['uploads:1'], albums: [] });
expect(names(listFolder(albums, ['uploads', 'meta']))).toEqual({
folders: [],
albums: ['$e1=Photos'],
});
});
});

View file

@ -0,0 +1,73 @@
// Pure: folders are albums whose title is a `/`-separated path (docs/commons-files-desk.md, "Folders").
// No wire change: the album is still one thread root; the folder tree is only how the list is grouped.
import { Album, UNTITLED } from './buildAlbums';
/** A path as segments. Empty segments and surrounding space drop out; `.` and `..` are ordinary names, never navigation. */
export const splitPath = (title: string): string[] =>
title
.split('/')
.map((s) => s.trim())
.filter(Boolean);
export type FolderEntry = {
name: string;
/** Segments from the root to this folder. */
path: string[];
/** Albums at any depth below. */
albums: number;
/** The newest album ts below, for ordering. */
ts: number;
};
export type AlbumEntry = { album: Album; leaf: string };
export type FolderListing = { folders: FolderEntry[]; albums: AlbumEntry[] };
const startsWith = (path: string[], prefix: string[]) => prefix.every((seg, i) => path[i] === seg);
/**
* What one folder shows: its direct sub-folders and the albums titled exactly at its path plus a leaf.
* An album titled `x` and a folder `x/` coexist at the same level; neither hides the other. Newest first, both lists.
*/
export const listFolder = (albums: readonly Album[], at: string[]): FolderListing => {
const folders = new Map<string, FolderEntry>();
const direct: AlbumEntry[] = [];
albums.forEach((album) => {
const segs = splitPath(album.title);
if (segs.length === 0) {
// An untitled or all-slash title lives at the root under the title as written.
if (at.length === 0) direct.push({ album, leaf: album.title.trim() || UNTITLED });
return;
}
if (!startsWith(segs, at)) return;
// An album titled exactly at this path sits in the parent, not inside the folder it shares a name with.
if (segs.length <= at.length) return;
if (segs.length === at.length + 1) {
direct.push({ album, leaf: segs[segs.length - 1] });
return;
}
const name = segs[at.length];
const f = folders.get(name) ?? { name, path: [...at, name], albums: 0, ts: 0 };
f.albums += 1;
f.ts = Math.max(f.ts, album.ts);
folders.set(name, f);
});
return {
folders: [...folders.values()].sort((a, b) => b.ts - a.ts),
albums: direct.sort((a, b) => b.album.ts - a.album.ts),
};
};
/** The folder an album sits in: its title path without the leaf. */
export const folderOf = (album: Album): string[] => splitPath(album.title).slice(0, -1);
/**
* The title a share sends into `folder`: the folder path then what the sender typed. The typed title
* may itself contain `/`, which just goes deeper. An empty typed title falls back to `fallback`.
*/
export const titleIn = (folder: string[], typed: string, fallback: string): string => {
const leaf = splitPath(typed).join('/') || fallback;
return [...folder, leaf].join('/');
};
export const pathLabel = (path: string[]): string => path.join(' / ');

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
import { AlbumInput, ZoneEvent, buildAlbums, lastEvent } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
event_id: id,
@ -215,6 +215,42 @@ describe('buildAlbums', () => {
});
});
describe('versions', () => {
const v = (id: string, ts: number, replaces?: string) =>
reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {});
it('lists the latest of a chain and folds the earlier versions under it', () => {
const a = one(root('$r', 1), [v('$v1', 10), v('$v2', 20, '$v1'), reply('$x', 30, 'm.file')]);
expect(a.files.map((f) => [f.id, f.earlier.map((e) => e.id)])).toEqual([
['$v2', ['$v1']],
['$x', []],
]);
expect(a.count).toBe(2);
});
});
describe('versions: cover and sender', () => {
it('the cover comes from the shown version, not a folded image', () => {
const img = (id: string, ts: number, type: string, replaces?: string) =>
reply(id, ts, type, id, replaces ? { 'org.gamecult.publish': { replaces } } : {});
const a = one(root('$r', 1), [img('$v1', 10, 'm.image'), img('$v2', 20, 'm.file', '$v1')]);
expect(a.files.map((f) => f.id)).toEqual(['$v2']);
expect(a.cover).toBeUndefined();
});
});
describe('lastEvent', () => {
it('is the newest event of the thread, a folded version included; the root when empty', () => {
const v = (id: string, ts: number, replaces?: string) =>
reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {});
expect(lastEvent(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toEqual({
id: '$b',
ts: 20,
});
expect(lastEvent(one(root('$r', 1), []))).toEqual({ id: '$r', ts: 1 });
});
});
describe('Mind receipts', () => {
const HASH = 'a'.repeat(64);
const file = (id: string, sha?: string) =>

View file

@ -1,8 +1,9 @@
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
// into albums. See docs/file-zone.md for the rulings this encodes.
import { MINDS } from '../../components/message/pose/poses';
import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt';
import { readersOf } from './fileReceipt';
import type { FileReader } from './fileReceipt';
import { foldVersions } from './versions';
/** The slice of a Matrix event this module reads. Every field may be missing. */
export type ZoneEvent = {
@ -18,18 +19,18 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] };
export type FileKind = 'audio' | 'image' | 'video' | 'file';
/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */
export type FileReader = { sender: string; ts: number };
export type AlbumFile = {
id: string;
kind: FileKind;
name: string;
sender: string;
ts: number;
/** The event content, as RenderMessageContent reads it. */
content: Record<string, unknown>;
/** Receipts in this album that name this file, one per sender (the latest), oldest first. */
readBy: FileReader[];
/** The versions this file supersedes, nearest first (`org.gamecult.publish.replaces`). */
earlier: AlbumFile[];
};
export type Album = {
@ -67,7 +68,7 @@ const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
export const toFile = (e: ZoneEvent): AlbumFile | undefined => {
if (!live(e)) return undefined;
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
if (!kind) return undefined;
@ -75,50 +76,14 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => {
id: e.event_id,
kind,
name: asString(e.content.filename) || asString(e.content.body),
sender: e.sender ?? '',
ts: tsOf(e.origin_server_ts),
content: e.content,
readBy: [],
earlier: [],
};
};
// A receipt is a thread reply of type org.gamecult.file_received whose content names a file of this
// album; it carries no msgtype, so it never passes `live`.
const receiptOf = (e: ZoneEvent) => {
if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined;
if (e.unsigned?.redacted_because) return undefined;
const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined;
if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined;
if (typeof e.sender !== 'string') return undefined;
return {
file: body.event_id,
sender: e.sender,
ts: tsOf(e.origin_server_ts),
sha256: body.sha256,
};
};
// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix
// identity on this homeserver (`@<mind>:<server>`, the mind named in the Persona catalogue). "Read" means verified: the receipt's hash must be
// the file event's own.
const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`);
const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[], server: string) => {
const byId = new Map(files.map((f) => [f.id, f]));
replies
.map(receiptOf)
.filter((r): r is NonNullable<typeof r> => !!r && isMind(r.sender, server))
.sort((a, b) => a.ts - b.ts)
.forEach((r) => {
const file = byId.get(r.file);
const sent = (file?.content.info as Record<string, unknown> | undefined)?.[FILE_HASH_KEY];
if (!file || typeof sent !== 'string' || sent !== r.sha256) return;
file.readBy = [
...file.readBy.filter((x) => x.sender !== r.sender),
{ sender: r.sender, ts: r.ts },
];
});
};
export const UNTITLED = 'Untitled album';
// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is
@ -134,23 +99,25 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => {
const { root, replies } = input ?? {};
if (!live(root)) return undefined;
const seen = new Set<string>();
const files = (Array.isArray(replies) ? replies : [])
const list = Array.isArray(replies) ? replies : [];
const files = list
.map(toFile)
.filter((f): f is AlbumFile => {
if (!f || seen.has(f.id)) return false;
seen.add(f.id);
return true;
})
.sort((a, b) => a.ts - b.ts);
attachReceipts(files, Array.isArray(replies) ? replies : [], server);
.sort((a, b) => a.ts - b.ts)
.map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) }));
const heads = foldVersions(files);
return {
id: root.event_id,
title: titleOf(root),
author: root.sender ?? '',
ts: tsOf(root.origin_server_ts),
cover: files.find((f) => f.kind === 'image'),
files,
count: files.length,
cover: heads.find((f) => f.kind === 'image'),
files: heads,
count: heads.length,
};
};
@ -167,3 +134,51 @@ export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => {
})
.sort((a, b) => b.ts - a.ts);
};
/** The newest event in an album's thread (shown or folded), else its root: where a new file threads after. */
export const lastEvent = (album: Album): { id: string; ts: number } =>
album.files
.flatMap((f) => [f, ...f.earlier])
.reduce<{ id: string; ts: number }>((a, f) => (f.ts >= a.ts ? { id: f.id, ts: f.ts } : a), {
id: album.id,
ts: album.ts,
});
/** A room event with the thread root the SDK resolved for it, when it has one. */
export type TimelineEvent = ZoneEvent & { threadRoot?: string };
/**
* File events that sit in the room outside any album thread: what a poster that does not thread
* its files leaves behind. The album list never reads them (it lists threads), so they are named
* to the sender instead of left out silently.
*/
export const looseFiles = (events: TimelineEvent[]): AlbumFile[] =>
events
.filter((e) => {
const rel = e?.content?.['m.relates_to'] as { rel_type?: unknown } | undefined;
return !e?.threadRoot && rel?.rel_type !== 'm.thread';
})
.map(toFile)
.filter((f): f is AlbumFile => !!f);
const NAMED = 3;
/** The line that says loose files exist and are not listed; empty when there are none. */
export const looseNote = (files: AlbumFile[]): string => {
if (files.length === 0) return '';
const names = files.slice(0, NAMED).map((f) => f.name || 'unnamed');
const more = files.length > NAMED ? ` and ${files.length - NAMED} more` : '';
const what = files.length === 1 ? '1 file was' : `${files.length} files were`;
return `${what} posted outside an album and ${
files.length === 1 ? 'is' : 'are'
} not listed here: ${names.join(', ')}${more}. ${
files.length === 1 ? 'It is' : 'They are'
} in the Timeline.`;
};
/** Where the album view stands when it decides whether to show the loose-files note. */
export type ViewState = { open: boolean; encrypted: boolean; loading: boolean; error: boolean };
/** The note for the list view only: never inside an open album, an encrypted zone, or a load that is not finished. */
export const looseNoteFor = (files: AlbumFile[], view: ViewState): string =>
view.open || view.encrypted || view.loading || view.error ? '' : looseNote(files);

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { hashForSend, readLine, sha256Hex } from './fileReceipt';
import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt';
describe('sha256Hex', () => {
it('hashes the bytes: the FIPS 180 vector for "abc"', async () => {
@ -32,6 +32,134 @@ describe('readLine', () => {
],
name
)
).toBe('read by void, eureka');
).toBe('fetched for void, eureka');
});
});
describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => {
const HASH = 'a'.repeat(64);
const file = { info: { sha256: HASH } };
const receipt = (
id: string,
ts: number,
sender: string,
sha: unknown | undefined,
fileId: string | undefined,
relates?: object
): ReceiptEvent => ({
event_id: id,
type: 'org.gamecult.file_received',
sender,
origin_server_ts: ts,
content: {
...(relates ? { 'm.relates_to': relates } : {}),
'org.gamecult.file_received': {
event_id: fileId ?? '$f',
sha256: sha === undefined ? HASH : sha,
},
},
});
const readers = (events: ReceiptEvent[], content: Record<string, unknown> | undefined) =>
readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender);
it('counts a threadless receipt, as a composer file with no album root gets', () => {
expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([
'@void:gamecult.org',
]);
});
it('counts a thread reply the same way', () => {
const t = { rel_type: 'm.thread', event_id: '$root' };
expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([
'@eureka:gamecult.org',
]);
});
it('refuses lookalike localparts and foreign servers', () => {
expect(
readers(
[
receipt('$1', 1, '@void2:gamecult.org', undefined, undefined),
receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined),
receipt('$3', 3, '@Void:gamecult.org', undefined, undefined),
receipt('$4', 4, '@void:evil.example', undefined, undefined),
receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined),
receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined),
],
file
)
).toEqual([]);
});
it('shows nothing for a mismatched hash, a file with no hash, or another file', () => {
expect(
readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file)
).toEqual([]);
expect(
readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} })
).toEqual([]);
expect(
readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined)
).toEqual([]);
expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]);
});
it('keeps one entry per Mind, the latest, oldest first', () => {
expect(
readersOf(
[
receipt('$1', 30, '@void:gamecult.org', undefined, undefined),
receipt('$2', 10, '@void:gamecult.org', undefined, undefined),
receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined),
],
'$f',
file,
'gamecult.org'
)
).toEqual([
{ sender: '@aqua:gamecult.org', ts: 20 },
{ sender: '@void:gamecult.org', ts: 30 },
]);
});
it('ignores a redacted receipt and a malformed one', () => {
const redacted = {
...receipt('$1', 1, '@void:gamecult.org', undefined, undefined),
unsigned: { redacted_because: {} },
};
expect(
readers(
[
redacted,
receipt('$2', 2, '@void:gamecult.org', 7, undefined),
{
...receipt('$3', 3, '@void:gamecult.org', undefined, undefined),
type: 'm.room.message',
},
],
file
)
).toEqual([]);
});
describe('an m.reference to the file', () => {
const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id });
it('counts when it names the same event as the content', () => {
expect(
readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file)
).toEqual(['@void:gamecult.org']);
});
it('counts for nothing when it names another event', () => {
expect(
readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file)
).toEqual([]);
});
it('draws one entry per Mind however many receipts exist', () => {
expect(
readers(
[
receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')),
receipt('$2', 2, '@void:gamecult.org', HASH, '$f'),
receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')),
],
file
)
).toEqual(['@void:gamecult.org']);
});
});
});

View file

@ -1,7 +1,20 @@
// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes
// into a file event, and the receipt a Mind answers with.
import type { FileReader } from './buildAlbums';
import { MINDS } from '../../components/message/pose/poses';
/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */
export type FileReader = { sender: string; ts: number };
/** The slice of a Matrix event a receipt is read from. Every field may be missing. */
export type ReceiptEvent = {
event_id?: string;
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
unsigned?: Record<string, unknown>;
};
/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */
export const FILE_HASH_KEY = 'sha256';
@ -24,4 +37,53 @@ export const hashForSend = async (data: Blob): Promise<string | undefined> => {
};
export const readLine = (readBy: FileReader[], name: (sender: string) => string): string =>
`read by ${readBy.map((r) => name(r.sender)).join(', ')}`;
`fetched for ${readBy.map((r) => name(r.sender)).join(', ')}`;
const receiptOf = (e: ReceiptEvent) => {
if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined;
if (e.unsigned?.redacted_because) return undefined;
const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined;
if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined;
if (typeof e.sender !== 'string') return undefined;
// A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the
// receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read.
const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined;
if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined;
const ts = e.origin_server_ts;
return {
file: body.event_id,
sender: e.sender,
ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0,
sha256: body.sha256,
};
};
// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix
// identity on this homeserver (`@<mind>:<server>`, a mind named in the Persona catalogue).
const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`);
/**
* Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's
* timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count).
* "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a
* mismatch, shows nothing. One entry per Mind (its latest), oldest first.
*/
export const readersOf = (
events: ReceiptEvent[],
fileId: string,
fileContent: Record<string, unknown> | undefined,
server: string
): FileReader[] => {
const sent = (fileContent?.info as Record<string, unknown> | undefined)?.[FILE_HASH_KEY];
if (typeof sent !== 'string') return [];
const latest = new Map<string, FileReader>();
events
.map(receiptOf)
.filter(
(r): r is NonNullable<typeof r> =>
!!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server)
)
.sort((a, b) => a.ts - b.ts)
.forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts }));
return [...latest.values()].sort((a, b) => a.ts - b.ts);
};

View file

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import { buildAlbums, looseFiles, looseNote, looseNoteFor, TimelineEvent } from './buildAlbums';
// The Commons publisher as it posted before gamecult-ops #121: loose files (no album thread), a
// flat `org.gamecult.publish.replaces` key, no info.sha256. The sender must see that, not a
// silently empty or silently unfolded zone.
const MIND = '@eureka:gamecult.org';
const file = (
id: string,
name: string,
extra: Record<string, unknown> = {},
ts = 1
): TimelineEvent => ({
event_id: id,
type: 'm.room.message',
sender: MIND,
origin_server_ts: ts,
content: { msgtype: 'm.file', body: name, filename: name, url: `mxc://x/${id}`, ...extra },
});
const text = (id: string, body: string): TimelineEvent => ({
event_id: id,
type: 'm.room.message',
sender: MIND,
origin_server_ts: 1,
content: { msgtype: 'm.text', body },
});
describe('loose files from a poster that does not thread', () => {
it('are found, with the names the sender sees', () => {
const loose = looseFiles([file('$a', 'notes.md'), file('$b', 'plan.pdf')]);
expect(loose.map((f) => f.name)).toEqual(['notes.md', 'plan.pdf']);
});
it('are not the album roots, thread replies (by SDK root or by relation), edits or redactions', () => {
const events: TimelineEvent[] = [
text('$root', 'handoff/eureka'),
{ ...file('$r1', 'a.md'), threadRoot: '$root' },
file('$r2', 'b.md', {
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
}),
file('$edit', 'c.md', { 'm.relates_to': { rel_type: 'm.replace', event_id: '$r1' } }),
{ ...file('$gone', 'd.md'), unsigned: { redacted_because: {} } },
file('$loose', 'e.md'),
];
expect(looseFiles(events).map((f) => f.id)).toEqual(['$loose']);
});
it('a zone holding only loose files still has no albums, and the note says why', () => {
const events = [file('$a', 'notes.md')];
expect(buildAlbums([])).toEqual([]);
expect(looseNote(looseFiles(events))).toBe(
'1 file was posted outside an album and is not listed here: notes.md. It is in the Timeline.'
);
});
it('the note names three and counts the rest', () => {
const many = looseFiles(['a', 'b', 'c', 'd', 'e'].map((n) => file(`$${n}`, `${n}.md`)));
expect(looseNote(many)).toBe(
'5 files were posted outside an album and are not listed here: a.md, b.md, c.md and 2 more. They are in the Timeline.'
);
expect(looseNote([])).toBe('');
});
});
describe('where the loose-files note shows', () => {
const loose = looseFiles([file('$a', 'notes.md')]);
const list = { open: false, encrypted: false, loading: false, error: false };
it('on the album list, and only there', () => {
expect(looseNoteFor(loose, list)).toContain('notes.md');
expect(looseNoteFor(loose, { ...list, open: true })).toBe('');
expect(looseNoteFor(loose, { ...list, encrypted: true })).toBe('');
expect(looseNoteFor(loose, { ...list, loading: true })).toBe('');
expect(looseNoteFor(loose, { ...list, error: true })).toBe('');
});
it('says nothing when there are no loose files', () => {
expect(looseNoteFor([], list)).toBe('');
});
it('names m.notice and m.sticker posts as not files', () => {
const notice = { ...text('$n', 'hi'), content: { msgtype: 'm.notice', body: 'hi' } };
expect(looseFiles([notice])).toEqual([]);
});
it('a file with no name reads as unnamed', () => {
const bare: TimelineEvent = { ...file('$b', ''), content: { msgtype: 'm.file', body: '' } };
expect(looseNote(looseFiles([bare]))).toContain('unnamed');
});
});
describe('the old wire shape inside an album', () => {
const root = text('$root', 'handoff/eureka');
const albumOf = (replies: TimelineEvent[]) => buildAlbums([{ root, replies }], 'gamecult.org')[0];
it('a flat replaces key folds nothing: both versions show, none is hidden', () => {
const album = albumOf([
file('$v1', 'plan.md', { 'org.gamecult.publish.sha256': 'aa' }, 1),
file('$v2', 'plan.md', { 'org.gamecult.publish.replaces': '$v1' }, 2),
]);
expect(album.files.map((f) => f.id)).toEqual(['$v1', '$v2']);
expect(album.files.every((f) => f.earlier.length === 0)).toBe(true);
});
it('the nested shape from the fixed publisher folds as before', () => {
const album = albumOf([
file('$v1', 'plan.md', {}, 1),
file('$v2', 'plan.md', { 'org.gamecult.publish': { replaces: '$v1' } }, 2),
]);
expect(album.files.map((f) => f.id)).toEqual(['$v2']);
expect(album.files[0].earlier.map((f) => f.id)).toEqual(['$v1']);
});
it('a file with no info.sha256 lists fine, with no readers even when a receipt names it', () => {
const receipt: TimelineEvent = {
event_id: '$rc',
type: 'org.gamecult.file_received',
sender: '@void:gamecult.org',
origin_server_ts: 5,
content: { 'org.gamecult.file_received': { event_id: '$v1', sha256: 'aa', size: 1 } },
};
const album = albumOf([file('$v1', 'plan.md'), receipt]);
expect(album.count).toBe(1);
expect(album.files[0].readBy).toEqual([]);
});
});

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { Page, readPages } from './readPages';
const pager = (pages: Page<number>[]) => {
const froms: (string | undefined)[] = [];
return {
froms,
fetchPage: async (from: string | undefined) => {
froms.push(from);
return pages[froms.length - 1] ?? {};
},
};
};
describe('readPages', () => {
it('one page without a token is the whole read', async () => {
const p = pager([{ chunk: [1, 2] }]);
expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2], truncated: false });
expect(p.froms).toEqual([undefined]);
});
it('follows next_batch in order, passing each token back', async () => {
const p = pager([
{ chunk: [1], next_batch: 'a' },
{ chunk: [2], next_batch: 'b' },
{ chunk: [3] },
]);
expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2, 3], truncated: false });
expect(p.froms).toEqual([undefined, 'a', 'b']);
});
it('a missing chunk reads as empty', async () => {
expect(await readPages(async () => ({}))).toEqual({ items: [], truncated: false });
});
it('an empty page ends the read even when it carries a token', async () => {
const p = pager([
{ chunk: [1], next_batch: 'a' },
{ chunk: [], next_batch: 'b' },
]);
expect(await readPages(p.fetchPage)).toEqual({ items: [1], truncated: false });
expect(p.froms).toHaveLength(2);
});
it('the cap stops the read and says it was cut short', async () => {
const p = pager(Array.from({ length: 10 }, (_, i) => ({ chunk: [i], next_batch: `t${i}` })));
expect(await readPages(p.fetchPage, 3)).toEqual({ items: [0, 1, 2], truncated: true });
expect(p.froms).toHaveLength(3);
});
it('a last page exactly at the cap is not truncated', async () => {
const p = pager([{ chunk: [1], next_batch: 'a' }, { chunk: [2] }]);
expect((await readPages(p.fetchPage, 2)).truncated).toBe(false);
});
});

View file

@ -0,0 +1,27 @@
/** One page of a Matrix paginated endpoint: its events and the token for the next page, if any. */
export type Page<T> = { chunk?: T[]; next_batch?: string };
/** Pages read per endpoint at most, so a runaway zone cannot hold the phone in a loop. */
export const MAX_PAGES = 20;
/**
* Reads every page of an endpoint by following `next_batch`, up to `maxPages`. `truncated` is true
* when the cap stopped the read with more still to come, so a caller can say the list is partial.
*/
export const readPages = async <T>(
fetchPage: (from: string | undefined) => Promise<Page<T>>,
maxPages = MAX_PAGES
): Promise<{ items: T[]; truncated: boolean }> => {
const items: T[] = [];
let from: string | undefined;
for (let n = 0; n < maxPages; n += 1) {
// eslint-disable-next-line no-await-in-loop
const page = await fetchPage(from);
items.push(...(page.chunk ?? []));
// An empty page ends the read even if a token is returned: a server that keeps handing out
// tokens with nothing behind them would otherwise spend every page of the cap.
if (!page.next_batch || !page.chunk?.length) return { items, truncated: false };
from = page.next_batch;
}
return { items, truncated: true };
};

Some files were not shown because too many files have changed in this diff Show more