mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat: a unit is a size, a palette is fetched, and a far vertex is drawn
Following through on the comparison with the author's own client. Four things it surfaced that we did not, and one decision of ours that it showed was wrong. `unit` was never on screen. §1.6 makes it the exponent on a model unit and CYBERSPACE_V2 §9.2 fixes the base unit at 2^-33 metres, so the same geometry at unit 0 and unit 40 is a molecule and a mountain — and a card that reads "8 vertices · 12 faces" for both has said nothing about the only field that separates them. CyberspaceScale turns the exponent into a length, using sno-core/scale.ts's measure table and its three-significant-figure rounding so an object reads the same size in both clients. The test covers every value unit may take, 0 to 84, against that file run over the same range, including unit 28, which is 3.125 cm exactly and the one entry where a language's default rounding shows. An object naming an external palette was drawn in the wrong colours. The reader and the parser's fetchedPalette had been there from the start with nothing ever calling them, so every §1.3a reference resolved to the built-in 256 in silence. WithSnoPalette loads the named event by its own id, as §1.3a's pinning rule requires, and re-parses. Every palette on the network today is a kind 3367 colour moment of three to six colours, so an object built against one and drawn against 256 entries is not slightly off, it is a different object. The re-parse can also fail where the first succeeded — index 238 is fine against 256 entries and out of range against five — and §1.3 makes that a defect in the payload rather than something to clamp. That fetch exposed a real bug next to it. The thumbnail's Coil key was sno:<id>:<size>:<view>, on the reasoning that an addressable object republished under the same d arrives as a new id: true of geometry, false of colour. One id now legitimately produces two pictures a second apart, so the key carries a hash of the resolved colours. Without it the first paint would have been served from memory forever and the fetch would have bought nothing. The default avatar drew nothing. §8.10 makes an empty kind 11333 the default avatar, which is what everyone is before they adopt a shape, and drawing nothing for it means the note vanishes out of the feed, which reads as a fault. It gets the wireframe icosahedron the reference puts in its place, built as an ordinary payload so it goes through the same rasterizer. Its faces are derived rather than remembered, because a hand-written icosahedron comes out looking almost right — the test asserts twelve corners, thirty edges, twenty faces, every corner on five and every edge on two, and it caught the hand-written one. A shard's C tag was parsed and dropped. §7.6 lets an item carry its exact coordinate so a client can place it in its region; we have no region, but the tag still carries the plane, which says whether the thing was hidden at a place on Earth or at one with no physical counterpart. That is bit 0, and the decoding stops there on purpose: the axes are §2.3 plus an 85-bit decimal printer, and three twenty-six-digit integers tell a reader with no world nothing, while the reading that would mean something is §9.7's 96-digit decimal arithmetic with a hand-rolled trig series, carried so every client agrees on where a place on Earth is. There is no Earth here to agree about. And the ±64-unit rejection is gone. §1.8 states the position bound as an obligation on publishers and gives a reader the choice in as many words: "A reader MAY reject such a payload and MAY instead repair it by growing the extent." Both references repair. The argument recorded for rejecting — that we draw strangers' events in a feed — defended nothing: a far-off vertex costs the rasterizer nothing, because the projection fits whatever it is handed to the frame, and the cost that does scale is in the vertex and face counts, which §1.8 bounds and rule 3 enforces. What was real was arithmetic, and it is now bounded where it lives: the whole is read as a Long, the product is checked against Int.MAX_VALUE ticks, and past that there is no coordinate to repair, only one that would wrap. An object at 65 units is drawn and rule 9 grows the extent past MAX_EXTENT to hold it, exactly as neededExtent does. The conformance harness pinned that divergence, so it now pins the agreement instead, and pins the narrow one that replaced it. 11 of 11 against the reference implementations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
This commit is contained in:
@@ -125,7 +125,10 @@ the only palette-event reader worth writing.
|
||||
it. Ignore it.
|
||||
2. **No hard bound on how far a vertex may lie from the origin** — §8 open question 3
|
||||
says so outright. "512 vertices at 2^50 units is valid under this text and will
|
||||
produce a grid no renderer wants." We must clamp defensively (see §5).
|
||||
produce a grid no renderer wants." §1.8 puts the 64-unit bound on publishers and
|
||||
lets a reader "reject such a payload" or "repair it by growing the extent". Both
|
||||
references repair; so do we (see §4.2d). What is left is the lattice's own limit:
|
||||
a position is a total tick count in an Int.
|
||||
3. **`extent` is repaired, never validated** (§1.8). Out of range → `8`, then grow to
|
||||
contain the data. It is the one deliberately forgiving rule; a reader that rejects
|
||||
on it is wrong.
|
||||
@@ -601,6 +604,85 @@ defaults **off**, so the feed and a freshly opened viewer both show §4's unlit
|
||||
reading and an object looks the same everywhere it is quoted; it is offered only
|
||||
on a filled object, since points and wireframes look identical either way.
|
||||
|
||||
### 4.2d Following the comparison through
|
||||
|
||||
§4.2c ended with two gaps and a list of things ONOSENDAI does that we do not.
|
||||
Five of those were ours to close, and closing the last one reversed a decision
|
||||
this document had recorded twice.
|
||||
|
||||
**`unit` was never on screen.** The plan said to surface it and the first pass
|
||||
did not, so a card read "8 vertices · 12 faces" for an object at `unit: 0` and
|
||||
for the same geometry at `unit: 40` — a molecule and a mountain. `unit` is the
|
||||
only field separating them. `CyberspaceScale` turns the exponent into a length
|
||||
using the two facts that make it one: §1.6 says a model unit is `2^unit` base
|
||||
units, and `CYBERSPACE_V2.md` §9.2 fixes the base unit, the gibson, at `2^-33`
|
||||
metres. The measure table and the three-significant-figure rounding are
|
||||
`sno-core/scale.ts`'s, and the test checks every value `unit` may take, 0 to 84,
|
||||
against that file run over the same range — including `unit: 28`, which is
|
||||
3.125 cm exactly and the one entry where a language's default rounding shows
|
||||
(ECMAScript's `toPrecision` takes the larger; `roundToLong` agrees).
|
||||
|
||||
**An object naming an external palette was drawn in the wrong colours.**
|
||||
`SnoPaletteEventReader` and `SnoParser`'s `fetchedPalette` existed from the
|
||||
start and nothing ever called them with anything, so every §1.3a reference
|
||||
resolved to the built-in 256 in silence. ONOSENDAI does not fetch either —
|
||||
`resolvePalette` has zero call sites there — but Amethyst already has the relay
|
||||
plumbing, so `WithSnoPalette` loads the named event by id (§1.3a pins a
|
||||
reference to the event it names and forbids following the `e` chain forward)
|
||||
and re-parses. It matters more than it sounds: every palette on the network
|
||||
today is a `kind 3367` colour moment of three to six colours, so an object built
|
||||
against one and drawn against 256 built-in entries is not slightly off, it is a
|
||||
different object. The re-parse can also *fail* where the first succeeded — index
|
||||
238 is fine against 256 entries and out of range against five — and §1.3 makes
|
||||
that a defect in the payload rather than something to clamp, which is what the
|
||||
reference does too.
|
||||
|
||||
That fetch exposed a real bug in the thumbnail cache. Coil keyed on
|
||||
`sno:<event id>:<size>:<view>`, on the reasoning that an addressable object
|
||||
republished under the same `d` arrives as a new id — true of geometry, false of
|
||||
colour. One event id now legitimately produces two pictures a second apart, so
|
||||
the key carries a hash of the resolved colours; without it the first paint would
|
||||
have been served from memory forever and the fetch would have bought nothing.
|
||||
|
||||
**The default avatar drew nothing.** §8.10 makes an empty `kind 11333` the
|
||||
default avatar — no geometry, no work owed, what everyone is before they adopt a
|
||||
shape — and drawing nothing for it is correct and unhelpful: the note vanishes
|
||||
out of the feed, which reads as a fault. It now gets the wireframe icosahedron
|
||||
the reference puts in its place, built as an ordinary payload so it goes through
|
||||
the same rasterizer and needs no second drawing path. Its face list is derived,
|
||||
not remembered, because a hand-written icosahedron comes out looking almost
|
||||
right; the test asserts twelve corners, thirty edges, twenty faces, every corner
|
||||
on five and every edge on two, and it caught the hand-written one.
|
||||
|
||||
**A shard's `C` tag was parsed and dropped.** §7.6 lets an item carry its exact
|
||||
coordinate so a client can "render it at a point rather than somewhere in the
|
||||
region". Amethyst has no region to render it in, but the tag still carries one
|
||||
thing a reader understands unaided: the plane (§2.4), which says whether the
|
||||
shard was hidden at a place on Earth or at one with no physical counterpart.
|
||||
That is bit 0 of the 256-bit coordinate and it is where the decoding stops, on
|
||||
purpose. Pulling X, Y and Z out is §2.3 plus an 85-bit decimal printer, and what
|
||||
it would put on screen is three twenty-six-digit integers that tell a reader
|
||||
with no world exactly nothing. The reading that *would* mean something — the GPS
|
||||
position under a dataspace coordinate — is §9.7: 96-digit decimal arithmetic
|
||||
with a hand-rolled deterministic trig series, carried so every client agrees on
|
||||
where a place on Earth is. Amethyst has no Earth to agree about.
|
||||
|
||||
**The ±64-unit rejection is gone, and should never have shipped.** §1.8 states
|
||||
the position bound as an obligation on publishers and gives a reader a choice in
|
||||
as many words: "A reader MAY reject such a payload and MAY instead repair it by
|
||||
growing the extent." Both references repair. The argument recorded here for
|
||||
rejecting — that we draw strangers' events in a feed — was a defence that was
|
||||
never load-bearing: a far-off vertex costs the rasterizer nothing, because the
|
||||
projection fits whatever it is handed to the frame, and the cost that does scale
|
||||
is in the vertex and face counts, which §1.8 bounds and rule 3 enforces. What
|
||||
was real was arithmetic: `whole * 120` overflows an Int, and `abs(Int.MIN_VALUE)`
|
||||
is its own negative, so a coordinate could be silently rewritten to the origin.
|
||||
That is now bounded where it actually lives — the whole is read as a Long, the
|
||||
product is checked against `Int.MAX_VALUE` ticks, and past that there is no
|
||||
coordinate to repair, only one that would wrap. An object at 65 units is drawn,
|
||||
and rule 9 grows the extent past `MAX_EXTENT` to hold it, exactly as
|
||||
`neededExtent` does.
|
||||
|
||||
### 4.3 Two surfaces, and only one of them needs to be fast
|
||||
|
||||
| Surface | Renderer | Cache |
|
||||
@@ -628,11 +710,13 @@ size that suits its context rather than refusing it."
|
||||
buffer from a declared length. The format has no length fields for this reason.
|
||||
- **Face indices are the crash** (§1.4). Every index `0 ≤ i < vertexCount` and the
|
||||
three distinct, validated before a single triangle reaches the rasterizer.
|
||||
- **Clamp the position bound that the spec leaves open.** §1.8 puts the 64-unit bound
|
||||
on publishers only and §8.3 admits a reader obligation is missing. Amethyst should
|
||||
reject (or clamp to) vertices beyond ±64 units — a payload at 2^50 units is valid
|
||||
per the text and is a denial of service against our own projection math. Log it as
|
||||
a deliberate divergence in the KDoc.
|
||||
- **The position bound the spec leaves open.** ~~Reject beyond ±64 units.~~ Reversed,
|
||||
see §4.2d: §1.8 offers a reader reject *or* repair and both references repair, so
|
||||
rejecting made Amethyst the only client that refused an object the rest of the
|
||||
network drew. The reach a payload can claim is not a denial of service on its own —
|
||||
the projection fits whatever it is given to the frame — and the one genuine hazard,
|
||||
`whole * 120` overflowing an Int, is now checked as what it is: a representation
|
||||
limit at `Int.MAX_VALUE` ticks, computed in a Long so the multiply cannot wrap.
|
||||
- **A palette reference is a network fetch driven by a stranger's event.** Fetch it
|
||||
through the existing relay accessories with the account's relay set, never from a
|
||||
URL, never blocking a frame, and cap it at one fetch per object.
|
||||
@@ -665,7 +749,8 @@ Built in this order, each step verified before the next:
|
||||
|
||||
**Decisions as taken:** D1 accept (parity with sno-core — all seven objects
|
||||
read), D1b tolerate, D2 deferred, D3 read-only with the writer in quartz, D4
|
||||
inline quote + thread, D5 clamp at ±64 units.
|
||||
inline quote + thread, D5 ~~clamp at ±64 units~~ repair by growing the extent,
|
||||
as both references do (reversed in §4.2d).
|
||||
|
||||
**Deliberately not wired:** kind 3330 bag items (D2), an authoring/modeling
|
||||
tool, and replacing a user's NIP-01 profile picture with their cyberspace
|
||||
@@ -739,13 +824,17 @@ are where the risk is, and they are testable without a device.
|
||||
- **D4 — where the object surfaces.** Inline quote + thread view is the minimum.
|
||||
A dedicated feed of `kind 33331` is not obviously wanted; there are seven objects
|
||||
on the network.
|
||||
- **D5 — the ±64 unit clamp** (§5). A deliberate divergence, and now a confirmed
|
||||
one: neither `sno-reference.py` nor `sno-core`'s `fromPayload` bounds how far a
|
||||
vertex may lie from the origin — both just grow the extent, which is what §8's
|
||||
third open question admits. sno-core even has a `validPoint(p, extent)` helper and
|
||||
does not call it on the read path. Recommend clamping anyway and documenting it as
|
||||
a divergence; we render strangers' events in a feed, which is not the position
|
||||
either of those readers is in.
|
||||
- **D5 — the ±64 unit clamp** (§5). **Reversed.** It shipped as a deliberate
|
||||
divergence and it was the wrong call. Neither `sno-reference.py` nor `sno-core`'s
|
||||
`fromPayload` bounds how far a vertex may lie from the origin — both grow the
|
||||
extent, which is what §8's third open question admits and what §1.8 names as one
|
||||
of a reader's two choices. sno-core even has a `validPoint(p, extent)` helper and
|
||||
deliberately does not call it on the read path. "We render strangers' events in a
|
||||
feed" argued for a defence that was never load-bearing: a far-off vertex costs the
|
||||
rasterizer nothing, because the projection fits whatever it is handed to the frame
|
||||
and the cost is in the vertex and face counts, which §1.8 *does* bound and rule 3
|
||||
*does* enforce. The only real hazard was arithmetic, and that is now bounded where
|
||||
it actually lives. See §4.2d.
|
||||
|
||||
## 8. Size, for the record
|
||||
|
||||
|
||||
@@ -1374,15 +1374,15 @@ private fun RenderNoteRow(
|
||||
}
|
||||
|
||||
is SnoObjectEvent -> {
|
||||
RenderSnoObject(baseNote)
|
||||
RenderSnoObject(baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
is SnoAvatarEvent -> {
|
||||
RenderSnoAvatar(baseNote)
|
||||
RenderSnoAvatar(baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
is SnoShardEvent -> {
|
||||
RenderSnoShard(baseNote)
|
||||
RenderSnoShard(baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
is ChessGameEvent -> {
|
||||
|
||||
@@ -33,8 +33,11 @@ import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarDefaultCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarUnpaidCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
|
||||
private val VIEWER_HEIGHT = 360.dp
|
||||
@@ -44,40 +47,57 @@ private val VIEWER_HEIGHT = 360.dp
|
||||
*
|
||||
* §8.10 gates the drawing rather than the parsing: an avatar that has not paid
|
||||
* for its reach and its detail, or whose content cannot be read, MUST NOT be
|
||||
* drawn. Empty content is the default avatar and owes no work, so there is
|
||||
* nothing to show for it either.
|
||||
* drawn.
|
||||
*
|
||||
* Empty content is the default avatar: it owes no work, and it is what every
|
||||
* identity is until it adopts a shape. Drawing nothing for it is correct and
|
||||
* unhelpful — the note disappears out of the feed, which reads as a fault — so
|
||||
* it gets the wireframe icosahedron the reference puts in its place.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderSnoAvatar(baseNote: Note) {
|
||||
fun RenderSnoAvatar(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? SnoAvatarEvent ?: return
|
||||
if (noteEvent.isDefaultAvatar()) return
|
||||
|
||||
// One parse: pricing an avatar reads its payload, and payment() hands that
|
||||
// back, so nothing here parses the same content twice on the way to a frame.
|
||||
val shape: SnoPayload? = remember(noteEvent) { noteEvent.payment().let { if (it.ok) it.payload else null } }
|
||||
|
||||
if (shape == null) {
|
||||
SnoAvatarUnpaidCard()
|
||||
if (noteEvent.isDefaultAvatar()) {
|
||||
SnoAvatarDefaultCard(noteEvent.id)
|
||||
return
|
||||
}
|
||||
|
||||
var turning by remember(noteEvent) { mutableStateOf(false) }
|
||||
// One parse: pricing an avatar reads its payload, and payment() hands that
|
||||
// back, so nothing here parses the same content twice on the way to a frame.
|
||||
// The price cannot change with the palette — §8.10 charges for reach and
|
||||
// detail and never for colour — but whether the payload reads at all can,
|
||||
// so the verdict is taken again with whichever palette arrives.
|
||||
val first = remember(noteEvent) { noteEvent.payment() }
|
||||
|
||||
SnoAvatarCard(
|
||||
payload = shape,
|
||||
eventId = noteEvent.id,
|
||||
name = noteEvent.nameTag(),
|
||||
onClick = { turning = true },
|
||||
)
|
||||
WithSnoPalette(first.payload?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
|
||||
val payment = remember(noteEvent, palette) { if (palette == null) first else noteEvent.payment(palette) }
|
||||
val shape: SnoPayload? = if (payment.ok) payment.payload else null
|
||||
|
||||
if (turning) {
|
||||
Dialog(onDismissRequest = { turning = false }) {
|
||||
SnoObjectViewer(
|
||||
if (shape == null) {
|
||||
SnoAvatarUnpaidCard()
|
||||
} else {
|
||||
var turning by remember(noteEvent) { mutableStateOf(false) }
|
||||
|
||||
SnoAvatarCard(
|
||||
payload = shape,
|
||||
eventId = noteEvent.id,
|
||||
contentDescription = noteEvent.nameTag() ?: shape.name.ifBlank { null },
|
||||
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
|
||||
name = noteEvent.nameTag(),
|
||||
onClick = { turning = true },
|
||||
)
|
||||
|
||||
if (turning) {
|
||||
Dialog(onDismissRequest = { turning = false }) {
|
||||
SnoObjectViewer(
|
||||
payload = shape,
|
||||
eventId = noteEvent.id,
|
||||
contentDescription = noteEvent.nameTag() ?: shape.name.ifBlank { null },
|
||||
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
|
||||
|
||||
private val VIEWER_HEIGHT = 360.dp
|
||||
@@ -42,35 +44,51 @@ private val VIEWER_HEIGHT = 360.dp
|
||||
/**
|
||||
* Entry for a Simple Nostr Object (DECK-0003 kind 33331).
|
||||
*
|
||||
* The geometry travels in the event's content, so the card is drawn from what
|
||||
* the note already carries: no fetch, no decode of an external asset, and no
|
||||
* network access at all. A payload that fails §1.9 is not drawn — the deck
|
||||
* requires that — and the rule it broke is shown instead of nothing.
|
||||
* The geometry travels in the event's content, so the object itself is drawn
|
||||
* from what the note already carries. The one thing that can be elsewhere is
|
||||
* its palette: §1.3a lets `colors` index a palette another event holds, and
|
||||
* [WithSnoPalette] fetches that one. Until it arrives the object is drawn
|
||||
* against the built-in, which is what §1.3b says an unresolved reference means.
|
||||
*
|
||||
* A payload that fails §1.9 is not drawn — the deck requires that — and the
|
||||
* rule it broke is shown instead of nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderSnoObject(baseNote: Note) {
|
||||
fun RenderSnoObject(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? SnoObjectEvent ?: return
|
||||
val parsed = remember(noteEvent) { noteEvent.sno() }
|
||||
// Parsed once against the built-in to learn which palette it wants, then
|
||||
// again with that palette once it is in hand. The second parse can fail
|
||||
// where the first did not — an index of 238 is fine against 256 entries and
|
||||
// out of range against a five-colour moment — and that is the object being
|
||||
// wrong rather than the palette.
|
||||
val first = remember(noteEvent) { noteEvent.sno() }
|
||||
|
||||
when (parsed) {
|
||||
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
|
||||
is SnoResult.Valid -> {
|
||||
var turning by remember(noteEvent) { mutableStateOf(false) }
|
||||
WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
|
||||
val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.sno(palette) }
|
||||
|
||||
SnoObjectCard(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
onClick = { turning = true },
|
||||
)
|
||||
when (parsed) {
|
||||
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
|
||||
is SnoResult.Valid -> {
|
||||
var turning by remember(noteEvent) { mutableStateOf(false) }
|
||||
|
||||
if (turning) {
|
||||
Dialog(onDismissRequest = { turning = false }) {
|
||||
SnoObjectViewer(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
contentDescription = parsed.payload.name.ifBlank { null },
|
||||
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
|
||||
)
|
||||
SnoObjectCard(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
onClick = { turning = true },
|
||||
)
|
||||
|
||||
if (turning) {
|
||||
Dialog(onDismissRequest = { turning = false }) {
|
||||
SnoObjectViewer(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
contentDescription = parsed.payload.name.ifBlank { null },
|
||||
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPalette
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteEventReader
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
|
||||
/**
|
||||
* The 256 colours an object's palette indices name, fetched when the object
|
||||
* names them in another event (DECK-0003 §1.3a).
|
||||
*
|
||||
* `colors` is an index into a palette and the payload says which one: absent
|
||||
* means the built-in, a list means the object carries its own, and an `nevent`
|
||||
* or `naddr` means somebody else's event holds it. Until that event is in hand,
|
||||
* an object that names one is drawn against the built-in — §1.3b is explicit
|
||||
* that a reference which has not resolved "counts as a failed fetch, which
|
||||
* means the built-in", never a refusal to draw. So this hands back null first
|
||||
* and the palette after, and the object appears immediately and repaints once.
|
||||
*
|
||||
* **Pinned to the event named, and never to a newer one.** §1.3a: "A reader
|
||||
* MUST NOT follow the `e` chain forward to a newer version, and MUST render the
|
||||
* object with the event the object names." That is why an `nevent` is loaded by
|
||||
* its own id. An `naddr` is accepted too, for a palette somebody publishes as an
|
||||
* addressable event of their own, and there the address *is* what was named.
|
||||
*
|
||||
* Fetching matters more than it sounds. Every palette on the network today is a
|
||||
* `kind 3367` colour moment of three to six colours, so an object built against
|
||||
* one and drawn against the built-in 256 is not slightly off — it is a
|
||||
* different object, in colours its author never chose. It can also stop being
|
||||
* drawable: an index of 238 is fine against 256 entries and out of range
|
||||
* against five, and §1.3 makes that a defect in the payload rather than
|
||||
* something to clamp. Both are the format working as written.
|
||||
*/
|
||||
@Composable
|
||||
fun WithSnoPalette(
|
||||
ref: SnoPaletteRef,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (SnoPalette?) -> Unit,
|
||||
) {
|
||||
if (ref !is SnoPaletteRef.Event) {
|
||||
// Inline and built-in are already resolved inside the payload; there is
|
||||
// nothing to wait for and nothing to fetch.
|
||||
content(null)
|
||||
return
|
||||
}
|
||||
|
||||
when (val entity = remember(ref.bech32) { Nip19Parser.uriToRoute(ref.bech32)?.entity }) {
|
||||
is NEvent -> LoadPaletteNote(entity.hex, accountViewModel, content)
|
||||
is NNote -> LoadPaletteNote(entity.hex, accountViewModel, content)
|
||||
is NAddress ->
|
||||
LoadAddressableNote(entity.address(), accountViewModel) { note ->
|
||||
content(remember(note?.event) { note?.event?.let { SnoPaletteEventReader.read(it) } })
|
||||
}
|
||||
// A reference the parser accepted as well-formed but that names nothing
|
||||
// this client can look up. The built-in, as an unresolved one always is.
|
||||
else -> content(null)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadPaletteNote(
|
||||
hex: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (SnoPalette?) -> Unit,
|
||||
) {
|
||||
LoadNote(hex, accountViewModel) { note ->
|
||||
// The kind is deliberately not checked: §1.3b says this format "does
|
||||
// not define a palette kind and does not want one", and a reader that
|
||||
// accepts the shape reads whatever convention wins. An event that is
|
||||
// not one reads as null, which is the built-in.
|
||||
content(remember(note?.event) { note?.event?.let { SnoPaletteEventReader.read(it) } })
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,19 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_shard_dataspace
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_shard_ideaspace
|
||||
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate
|
||||
import com.vitorpamplona.quartz.cyberspace.CyberspacePlane
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private val VIEWER_HEIGHT = 360.dp
|
||||
|
||||
@@ -50,7 +58,10 @@ private val VIEWER_HEIGHT = 360.dp
|
||||
* a renderer.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderSnoShard(baseNote: Note) {
|
||||
fun RenderSnoShard(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? SnoShardEvent ?: return
|
||||
|
||||
// A shard whose geometry is still inside its bag is not a broken payload
|
||||
@@ -58,29 +69,64 @@ fun RenderSnoShard(baseNote: Note) {
|
||||
// draw. Both 3330s reachable on a relay today are this.
|
||||
if (noteEvent.isSealed()) return
|
||||
|
||||
val parsed = remember(noteEvent) { noteEvent.shard() }
|
||||
val first = remember(noteEvent) { noteEvent.shard() }
|
||||
val place = placeOf(noteEvent)
|
||||
|
||||
when (parsed) {
|
||||
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
|
||||
is SnoResult.Valid -> {
|
||||
var turning by remember(noteEvent) { mutableStateOf(false) }
|
||||
WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
|
||||
val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.shard(palette) }
|
||||
|
||||
SnoObjectCard(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
onClick = { turning = true },
|
||||
)
|
||||
when (parsed) {
|
||||
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
|
||||
is SnoResult.Valid -> {
|
||||
var turning by remember(noteEvent) { mutableStateOf(false) }
|
||||
|
||||
if (turning) {
|
||||
Dialog(onDismissRequest = { turning = false }) {
|
||||
SnoObjectViewer(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
contentDescription = parsed.payload.name.ifBlank { null },
|
||||
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
|
||||
)
|
||||
SnoObjectCard(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
onClick = { turning = true },
|
||||
footnote = place,
|
||||
)
|
||||
|
||||
if (turning) {
|
||||
Dialog(onDismissRequest = { turning = false }) {
|
||||
SnoObjectViewer(
|
||||
payload = parsed.payload,
|
||||
eventId = noteEvent.id,
|
||||
contentDescription = parsed.payload.name.ifBlank { null },
|
||||
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this shard says it is, in the little a client with no world can say.
|
||||
*
|
||||
* §7.6 lets an item carry a `C` tag holding its exact coordinate, "which lets a
|
||||
* client render it at a point rather than somewhere in the region". Amethyst has
|
||||
* no region and no point to render it at, but the tag still carries one thing a
|
||||
* reader understands on its own: the plane, which says whether the shard was
|
||||
* hidden at a place on Earth or at one with no physical counterpart (§2.4). The
|
||||
* coordinate itself is abbreviated beside it — enough to tell two shards apart,
|
||||
* and the whole of it is in the event for a client that can go there. See
|
||||
* [CyberspaceCoordinate] for why the axes are not decoded.
|
||||
*
|
||||
* Null when there is no `C` tag, which §7.6 allows: such an item "is located no
|
||||
* more precisely than the region".
|
||||
*/
|
||||
@Composable
|
||||
private fun placeOf(noteEvent: SnoShardEvent): String? {
|
||||
val coordinate = noteEvent.coordinate() ?: return null
|
||||
val plane = CyberspaceCoordinate.planeOf(coordinate) ?: return null
|
||||
val short = coordinate.take(SHORT_COORDINATE) + "\u2026" + coordinate.takeLast(SHORT_COORDINATE)
|
||||
return when (plane) {
|
||||
CyberspacePlane.DATASPACE -> stringResource(Res.string.sno_shard_dataspace, short)
|
||||
CyberspacePlane.IDEASPACE -> stringResource(Res.string.sno_shard_ideaspace, short)
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of a 32-byte coordinate to show at each end. */
|
||||
private const val SHORT_COORDINATE = 6
|
||||
|
||||
+3
-3
@@ -1071,11 +1071,11 @@ private fun FullBleedNoteCompose(
|
||||
} else if (noteEvent is BirdDetectionEvent) {
|
||||
RenderBirdDetection(baseNote)
|
||||
} else if (noteEvent is SnoObjectEvent) {
|
||||
RenderSnoObject(baseNote)
|
||||
RenderSnoObject(baseNote, accountViewModel)
|
||||
} else if (noteEvent is SnoAvatarEvent) {
|
||||
RenderSnoAvatar(baseNote)
|
||||
RenderSnoAvatar(baseNote, accountViewModel)
|
||||
} else if (noteEvent is SnoShardEvent) {
|
||||
RenderSnoShard(baseNote)
|
||||
RenderSnoShard(baseNote, accountViewModel)
|
||||
} else if (noteEvent is Ps1SaveEvent) {
|
||||
RenderPs1Save(baseNote)
|
||||
} else if (noteEvent is GeocacheListingEvent) {
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
# through `amy sno work` and `avatar_work()`.
|
||||
# 3. Avatar payment — synthesised events through `amy sno verify` and
|
||||
# `verify_avatar_work()`.
|
||||
# 4. Reader divergence — the two places we knowingly differ from the §1.9
|
||||
# arbiter, pinned so neither can drift quietly.
|
||||
# 4. Reader divergence — where we knowingly differ from the §1.9 arbiter,
|
||||
# and one place we used to and no longer do, pinned so
|
||||
# neither can drift quietly.
|
||||
#
|
||||
# Divergences we already know about are asserted as divergences, not ignored:
|
||||
# a reader that silently stopped diverging would be just as interesting as one
|
||||
@@ -247,10 +248,11 @@ check_verify "verify-legacy-kind" "$(avatar_event 33331 16 16 "$SHAPE")" not-an-
|
||||
|
||||
# ---- 4. the two places our reader knowingly differs ------------------------
|
||||
|
||||
banner "4. reader divergence — pinned, not ignored"
|
||||
banner "4. reader divergence and agreement — pinned, not ignored"
|
||||
|
||||
# These are not in the deck's rejection table, so section 1 never sees them.
|
||||
# Both are deliberate and documented; a change in either direction is news.
|
||||
# None of these are in the deck's rejection table, so section 1 never sees
|
||||
# them. Each is deliberate and documented; a change in either direction is
|
||||
# news, including a divergence that quietly reappears after being repealed.
|
||||
|
||||
check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, note
|
||||
local label="$1" payload="$2" want_ours="$3" want_theirs="$4" note="$5"
|
||||
@@ -275,12 +277,21 @@ check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, not
|
||||
TRIPLES='{"v":2,"name":"legacy","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]],"faces":[[0,1,2]]}'
|
||||
check_verdict "divergence-v2-triples" "$TRIPLES" true false "we read the legacy colour cohort; the arbiter refuses it"
|
||||
|
||||
# D5. A vertex past 64 units. §1.8 puts that bound on publishers only and lets
|
||||
# a reader repair instead; §8's third open question admits leaving it off
|
||||
# readers is unsafe. Neither reference bounds it. We draw strangers' events in
|
||||
# a feed, and whole*120 overflows an Int long before the text's limit.
|
||||
# D5, repealed. A vertex past 64 units. §1.8 puts that bound on publishers only
|
||||
# and gives a reader the choice — "MAY reject such a payload and MAY instead
|
||||
# repair it by growing the extent" — and neither reference bounds it: both grow
|
||||
# the extent. Amethyst rejected, which made it the only reader that refused an
|
||||
# object the rest of the network drew, so it repairs now and this is pinned as
|
||||
# agreement. A reader that started refusing again would be news.
|
||||
FAR='{"v":2,"name":"far","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[9999,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}'
|
||||
check_verdict "divergence-position-bound" "$FAR" false true "we refuse an unbounded vertex; the arbiter accepts it"
|
||||
check_verdict "agreement-position-repair" "$FAR" true true "both repair a vertex past the grid rather than refusing it"
|
||||
|
||||
# D5b. What is left of that bound: the lattice itself. A position is a total
|
||||
# tick count in an Int, so past Int.MAX_VALUE ticks — 17,895,697 units — there
|
||||
# is no coordinate to repair, only one that would wrap. The reference keeps its
|
||||
# positions in a double and carries this one fine, which is the divergence.
|
||||
WRAP='{"v":2,"name":"wrap","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[17895698,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}'
|
||||
check_verdict "divergence-lattice-limit" "$WRAP" false true "past what an Int lattice holds we refuse; the arbiter keeps it in a float"
|
||||
|
||||
print_summary
|
||||
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.sno
|
||||
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
|
||||
/**
|
||||
* The shape an avatar has when its author has not given it one.
|
||||
*
|
||||
* `CYBERSPACE_V2.md` §8.10 makes a `kind 11333` with empty content the default
|
||||
* avatar: it carries no geometry, owes no proof of work, and is what everyone
|
||||
* starts as. Drawing nothing for it is technically correct and tells a reader
|
||||
* nothing — the note simply vanishes from the feed, which reads as a bug. The
|
||||
* reference puts a wireframe icosahedron in its place wherever an avatar is
|
||||
* drawn (`scene/AvatarShape.tsx`), so this is that icosahedron, built as a
|
||||
* payload so it goes through the same renderer as everything else and needs no
|
||||
* second drawing path.
|
||||
*
|
||||
* **Not an event and not from the wire.** Nothing here was published by
|
||||
* anybody, so §1.2's obligation to keep the lattice exact does not apply to how
|
||||
* it was derived: the twelve corners of an icosahedron are `(0, ±1, ±φ)`
|
||||
* and its rotations, `φ` is irrational, and 194 ticks is the nearest the
|
||||
* lattice comes to `φ` units. What §1.2 does still govern is everything
|
||||
* downstream, and once these are ticks they are as exact as any other object's.
|
||||
*
|
||||
* `lines`, because the reference draws the edges rather than the solid: a
|
||||
* placeholder should not look like something somebody made.
|
||||
*/
|
||||
object SnoDefaultAvatar {
|
||||
/** One unit, in ticks — the icosahedron's short radius. */
|
||||
private const val ONE = SnoPayload.TICKS_PER_UNIT
|
||||
|
||||
/** The golden ratio in ticks, to the nearest one: `1.618034 * 120`. */
|
||||
private const val PHI = 194
|
||||
|
||||
private const val WHITE = 0xFFFFFFFF.toInt()
|
||||
|
||||
val payload: SnoPayload by lazy { build() }
|
||||
|
||||
private fun build(): SnoPayload {
|
||||
// The three mutually perpendicular golden rectangles whose corners are
|
||||
// an icosahedron: (0, ±1, ±φ) and its two cyclic rotations.
|
||||
val positions =
|
||||
intArrayOf(
|
||||
0,
|
||||
ONE,
|
||||
PHI,
|
||||
0,
|
||||
ONE,
|
||||
-PHI,
|
||||
0,
|
||||
-ONE,
|
||||
PHI,
|
||||
0,
|
||||
-ONE,
|
||||
-PHI,
|
||||
ONE,
|
||||
PHI,
|
||||
0,
|
||||
ONE,
|
||||
-PHI,
|
||||
0,
|
||||
-ONE,
|
||||
PHI,
|
||||
0,
|
||||
-ONE,
|
||||
-PHI,
|
||||
0,
|
||||
PHI,
|
||||
0,
|
||||
ONE,
|
||||
-PHI,
|
||||
0,
|
||||
ONE,
|
||||
PHI,
|
||||
0,
|
||||
-ONE,
|
||||
-PHI,
|
||||
0,
|
||||
-ONE,
|
||||
)
|
||||
// The twenty faces: every triple of corners that are one edge apart,
|
||||
// each wound so its normal points out of the solid. Derived rather than
|
||||
// remembered — a hand-written icosahedron comes out looking almost
|
||||
// right, and the test next door is what says whether it did.
|
||||
val faces =
|
||||
intArrayOf(
|
||||
0,
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
6,
|
||||
9,
|
||||
1,
|
||||
10,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
11,
|
||||
1,
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
4,
|
||||
10,
|
||||
1,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
5,
|
||||
8,
|
||||
2,
|
||||
9,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
3,
|
||||
10,
|
||||
5,
|
||||
3,
|
||||
7,
|
||||
11,
|
||||
4,
|
||||
8,
|
||||
10,
|
||||
5,
|
||||
10,
|
||||
8,
|
||||
6,
|
||||
11,
|
||||
9,
|
||||
7,
|
||||
9,
|
||||
11,
|
||||
)
|
||||
return SnoPayload(
|
||||
version = 2,
|
||||
name = "",
|
||||
// One gibson: the cell an avatar occupies, which is what the
|
||||
// reference draws it at.
|
||||
unit = 0,
|
||||
extent = 2,
|
||||
mode = SnoMode.LINES,
|
||||
positions = positions,
|
||||
colors = IntArray(positions.size / 3) { WHITE },
|
||||
faces = faces,
|
||||
faceColors = null,
|
||||
paletteRef = SnoPaletteRef.BuiltIn,
|
||||
up = false,
|
||||
spin = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.sno
|
||||
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The placeholder an avatar with no shape is drawn as.
|
||||
*
|
||||
* Asserted as geometry rather than as a picture, because a hand-written face
|
||||
* list is exactly the kind of thing that comes out looking almost right: an
|
||||
* icosahedron is twelve corners, thirty edges and twenty faces, every corner on
|
||||
* five of them and every edge on two, and a transposed index breaks one of
|
||||
* those without breaking the others.
|
||||
*/
|
||||
class SnoDefaultAvatarTest {
|
||||
private val payload = SnoDefaultAvatar.payload
|
||||
|
||||
@Test
|
||||
fun itIsAnIcosahedron() {
|
||||
assertEquals(12, payload.vertexCount)
|
||||
assertEquals(20, payload.faceCount)
|
||||
assertEquals(SnoMode.LINES, payload.mode, "a placeholder should read as a wireframe, not as something somebody made")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyEdgeJoinsTwoFacesAndEveryCornerFive() {
|
||||
val onEdge = HashMap<Long, Int>()
|
||||
val onCorner = IntArray(payload.vertexCount)
|
||||
for (face in 0 until payload.faceCount) {
|
||||
val corners = intArrayOf(payload.faces[face * 3], payload.faces[face * 3 + 1], payload.faces[face * 3 + 2])
|
||||
for (corner in corners) onCorner[corner]++
|
||||
for (i in 0..2) {
|
||||
val a = corners[i]
|
||||
val b = corners[(i + 1) % 3]
|
||||
val key = (minOf(a, b).toLong() shl 32) or maxOf(a, b).toLong()
|
||||
onEdge[key] = (onEdge[key] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
assertEquals(30, onEdge.size, "an icosahedron has thirty edges")
|
||||
assertTrue(onEdge.values.all { it == 2 }, "every edge should join exactly two faces")
|
||||
assertTrue(onCorner.all { it == 5 }, "every corner should be on five faces")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyEdgeIsTheSameLength() {
|
||||
// What actually makes it regular. The corners are on the lattice and
|
||||
// the golden ratio is not, so the edges differ by the rounding and by
|
||||
// nothing else: 194 ticks against 1.618034 units is 0.02% out.
|
||||
var shortest = Double.MAX_VALUE
|
||||
var longest = 0.0
|
||||
for (face in 0 until payload.faceCount) {
|
||||
for (i in 0..2) {
|
||||
val a = payload.faces[face * 3 + i]
|
||||
val b = payload.faces[face * 3 + (i + 1) % 3]
|
||||
var square = 0.0
|
||||
for (axis in 0..2) {
|
||||
val d = (payload.tickAt(a, axis) - payload.tickAt(b, axis)).toDouble()
|
||||
square += d * d
|
||||
}
|
||||
val length = sqrt(square)
|
||||
if (length < shortest) shortest = length
|
||||
if (length > longest) longest = length
|
||||
}
|
||||
}
|
||||
assertTrue(longest / shortest < 1.001, "edges ran $shortest to $longest ticks")
|
||||
// And the edge of an icosahedron of short radius 1 is 2 units.
|
||||
assertTrue(abs(shortest - 2.0 * SnoPayload.TICKS_PER_UNIT) < 1.0, "an edge should be two units, was $shortest ticks")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itDrawsSomethingAndFitsTheFrame() {
|
||||
val size = 64
|
||||
val pixels = SnoRasterizer.render(payload, size, size)
|
||||
assertTrue(pixels.count { it != 0 } > 100, "a wireframe icosahedron should light a good many pixels")
|
||||
// Nothing may spill past the margin the projection leaves.
|
||||
for (x in 0 until size) {
|
||||
assertEquals(0, pixels[x], "the top row should be clear")
|
||||
assertEquals(0, pixels[(size - 1) * size + x], "the bottom row should be clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3087,4 +3087,9 @@
|
||||
<string name="sno_avatar_unpaid">This avatar has not paid for its size, so it is not drawn</string>
|
||||
<string name="sno_light_on">Lit; tap to draw the object flat</string>
|
||||
<string name="sno_light_off">Flat; tap to light the object</string>
|
||||
<string name="sno_object_scale">1 unit = %1$s</string>
|
||||
<string name="sno_shard_dataspace">Hidden in dataspace · %1$s</string>
|
||||
<string name="sno_shard_ideaspace">Hidden in ideaspace · %1$s</string>
|
||||
<string name="sno_avatar_default">Default avatar</string>
|
||||
<string name="sno_avatar_default_details">No shape published, so everyone sees this one</string>
|
||||
</resources>
|
||||
|
||||
+20
-4
@@ -36,9 +36,15 @@ import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
* A Simple Nostr Object to draw, addressed by the event that carried it.
|
||||
*
|
||||
* [cacheKey] is what Coil's memory cache keys on, so it has to name everything
|
||||
* that changes the pixels: the event, the view and the size. The payload itself
|
||||
* is not part of the key because an addressable object republished under the
|
||||
* same `d` arrives as a new event id.
|
||||
* that changes the pixels: the event, the view, the size — and the colours.
|
||||
*
|
||||
* The geometry needs no part in the key, because an addressable object
|
||||
* republished under the same `d` arrives as a new event id. The colours are not
|
||||
* like that. DECK-0003 §1.3a lets `colors` index a palette held in *another*
|
||||
* event, and until that event is fetched the object is drawn against the
|
||||
* built-in (§1.3b), so one event id legitimately produces two different
|
||||
* pictures within a second of each other. Keyed on the id alone, the first
|
||||
* would be served from memory forever and the fetch would buy nothing.
|
||||
*/
|
||||
@Stable
|
||||
data class SnoObjectToRender(
|
||||
@@ -49,7 +55,17 @@ data class SnoObjectToRender(
|
||||
val pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES,
|
||||
val background: Int = 0,
|
||||
) {
|
||||
val cacheKey: String get() = "sno:$eventId:$size:$yawDegrees:$pitchDegrees:$background"
|
||||
val cacheKey: String get() = "sno:$eventId:$size:$yawDegrees:$pitchDegrees:$background:${colourSignature()}"
|
||||
|
||||
/**
|
||||
* A stand-in for the resolved palette: the colours themselves, hashed.
|
||||
*
|
||||
* The palette reference cannot serve instead, because what changes is not
|
||||
* which palette was named but whether it has arrived yet, and the payload
|
||||
* carries only the name. The colours are already resolved to ARGB by the
|
||||
* parser, so they are the thing that actually differs.
|
||||
*/
|
||||
private fun colourSignature(): Int = 31 * payload.colors.contentHashCode() + (payload.faceColors?.contentHashCode() ?: 0)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+58
@@ -34,12 +34,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_default
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_default_details
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_unpaid
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_object_details
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_object_scale
|
||||
import com.vitorpamplona.amethyst.commons.sno.SnoDefaultAvatar
|
||||
import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.cyberspace.CyberspaceScale
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@@ -85,6 +90,59 @@ fun SnoAvatarCard(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// An avatar's `unit` is what it was priced on (§8.10 pays for reach
|
||||
// as well as detail), so it is the one card where the scale is not
|
||||
// only informative but the reason the work came out as it did.
|
||||
Text(
|
||||
text = stringResource(Res.string.sno_object_scale, CyberspaceScale.describeUnit(payload.unit)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar of someone who has not published a shape.
|
||||
*
|
||||
* §8.10 makes a `kind 11333` with empty content the default avatar: no
|
||||
* geometry, no work owed, and what everyone is until they adopt something.
|
||||
* Drawing nothing for it is correct and unhelpful — the note disappears out of
|
||||
* the feed, which reads as a fault — so it is drawn as the wireframe
|
||||
* icosahedron the reference puts in its place, which is also a shape nobody
|
||||
* could mistake for one somebody made.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoAvatarDefaultCard(eventId: String) {
|
||||
Row(
|
||||
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SnoThumbnail(
|
||||
payload = SnoDefaultAvatar.payload,
|
||||
eventId = eventId,
|
||||
size = AVATAR_THUMBNAIL_SIZE,
|
||||
contentDescription = stringResource(Res.string.sno_avatar_default),
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.sno_avatar_default),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.sno_avatar_default_details),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -35,11 +35,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_object_details
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_object_scale
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_object_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.sno_object_unreadable
|
||||
import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.cyberspace.CyberspaceScale
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@@ -48,14 +50,25 @@ private val THUMBNAIL_SIZE = 96.dp
|
||||
/**
|
||||
* A Simple Nostr Object in a feed or a thread (DECK-0003 kind 33331).
|
||||
*
|
||||
* The object is the event, so there is nothing to fetch and nothing to expand:
|
||||
* the card draws what it already has. Tapping opens it where it can be turned.
|
||||
* The geometry is the event, so there is nothing to fetch and nothing to
|
||||
* expand: the card draws what it already has. Tapping opens it where it can be
|
||||
* turned.
|
||||
*
|
||||
* The scale line is not decoration. §1.6's `unit` is the only field separating
|
||||
* two objects with byte-identical geometry, and at `0` and `40` those two are a
|
||||
* molecule and a mountain; a card that prints the vertex count for both has
|
||||
* said nothing about the difference. [CyberspaceScale] turns the exponent into
|
||||
* a length.
|
||||
*
|
||||
* @param footnote one more line under the scale, for something the container
|
||||
* knows and the payload does not — a shard's place, say.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoObjectCard(
|
||||
payload: SnoPayload,
|
||||
eventId: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
footnote: String? = null,
|
||||
) {
|
||||
val modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp)
|
||||
|
||||
@@ -86,6 +99,22 @@ fun SnoObjectCard(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.sno_object_scale, CyberspaceScale.describeUnit(payload.unit)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.sno
|
||||
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* What Coil's memory cache is allowed to treat as the same picture.
|
||||
*
|
||||
* The interesting case is the one DECK-0003 §1.3a creates: an object whose
|
||||
* `colors` index a palette in another event is drawn against the built-in until
|
||||
* that event arrives (§1.3b), so a single event id produces two different
|
||||
* pictures. A key that names only the id would serve the first of them forever.
|
||||
*/
|
||||
class SnoFetcherKeyTest {
|
||||
private fun payload(colors: String): SnoPayload =
|
||||
SnoParser
|
||||
.parse(
|
||||
"""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":$colors,"faces":[[0,1,2]]}""",
|
||||
).payloadOrNull()!!
|
||||
|
||||
private fun render(payload: SnoPayload) = SnoObjectToRender(payload, eventId = "abc", size = 96)
|
||||
|
||||
@Test
|
||||
fun theSameObjectInTheSameViewIsTheSamePicture() {
|
||||
assertEquals(render(payload("[238,238,238]")).cacheKey, render(payload("[238,238,238]")).cacheKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repaintingTheSameEventInAnotherPaletteIsADifferentPicture() {
|
||||
assertNotEquals(
|
||||
render(payload("[238,238,238]")).cacheKey,
|
||||
render(payload("[225,225,225]")).cacheKey,
|
||||
"the same id in different colours must not be served from one cache entry",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aColourOnAFaceCountsToo() {
|
||||
// §1.4a's facecolors replace the fill without touching `colors`, so a
|
||||
// key built from the vertex colours alone would miss them entirely.
|
||||
val plain = payload("[238,238,238]")
|
||||
val flat =
|
||||
SnoParser
|
||||
.parse(
|
||||
"""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]],"facecolors":[225]}""",
|
||||
).payloadOrNull()!!
|
||||
assertNotEquals(render(plain).cacheKey, render(flat).cacheKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theViewAndTheSizeStillSeparatePictures() {
|
||||
val p = payload("[238,238,238]")
|
||||
assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "abc", 192).cacheKey)
|
||||
assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "abc", 96, yawDegrees = 90f).cacheKey)
|
||||
assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "def", 96).cacheKey)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.cyberspace
|
||||
|
||||
/** Which of the two overlapping coordinate spaces a place is in (§2.4). */
|
||||
enum class CyberspacePlane {
|
||||
/** Maps to physical reality through GPS (§9). */
|
||||
DATASPACE,
|
||||
|
||||
/** No physical counterpart; purely abstract positions. */
|
||||
IDEASPACE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The little of `CYBERSPACE_V2.md` §2 a client without a world can use.
|
||||
*
|
||||
* A coordinate is a 256-bit integer, written as 32 lowercase bytes of hex: the
|
||||
* three 85-bit axes interleaved `XYZXYZ…` with the plane bit in the least
|
||||
* significant position (§2.2). An item hidden at a place carries one in a `C`
|
||||
* tag (§7.6).
|
||||
*
|
||||
* Only the plane bit is read here, and that is a deliberate stopping point
|
||||
* rather than a half-finished decoder. Pulling X, Y and Z back out is the
|
||||
* twenty lines of §2.3 plus an 85-bit decimal printer, and what it would put on
|
||||
* screen is three twenty-six-digit integers, which tell a reader with no world
|
||||
* to place them in exactly nothing. The reading that *would* mean something —
|
||||
* the GPS position under a dataspace coordinate — is §9.7, and that is 96-digit
|
||||
* decimal arithmetic with a hand-rolled deterministic trig series, carried so
|
||||
* that every client agrees on where a place on Earth is. Amethyst has no Earth
|
||||
* to agree about. So it reports the plane, which is one bit and says whether
|
||||
* the thing was hidden somewhere real, and hands the coordinate itself over
|
||||
* intact for a client that does.
|
||||
*/
|
||||
object CyberspaceCoordinate {
|
||||
/** 32 bytes, as §7.6 and §8 write it. */
|
||||
const val HEX_LENGTH = 64
|
||||
|
||||
/** True when this is a coordinate at all: 64 lowercase hex characters. */
|
||||
fun isWellFormed(hex: String): Boolean {
|
||||
if (hex.length != HEX_LENGTH) return false
|
||||
for (c in hex) {
|
||||
val ok = (c in '0'..'9') || (c in 'a'..'f')
|
||||
if (!ok) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The plane a coordinate names, or null when the string is not one.
|
||||
*
|
||||
* Bit 0 of the 256-bit integer, which is the low bit of the last hex digit
|
||||
* (§2.2: "Bit `0` (LSB): plane bit `P`").
|
||||
*/
|
||||
fun planeOf(hex: String): CyberspacePlane? {
|
||||
if (!isWellFormed(hex)) return null
|
||||
val last = hex[HEX_LENGTH - 1]
|
||||
val nibble = if (last in '0'..'9') last - '0' else last - 'a' + 10
|
||||
return if (nibble and 1 == 1) CyberspacePlane.IDEASPACE else CyberspacePlane.DATASPACE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.cyberspace
|
||||
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* How big one model unit actually is.
|
||||
*
|
||||
* DECK-0003 §1.6 says a `unit` of `u` means one model unit is `2^u` of the
|
||||
* application's base unit, and leaves what that base unit *is* to the
|
||||
* application. In Cyberspace it is the gibson, and `CYBERSPACE_V2.md` §9.2 and
|
||||
* §9.7 fix the gibson at `2^-33` metres — about the width of a hydrogen atom.
|
||||
* Those two facts together are the only thing standing between the integer in
|
||||
* the payload and a size a reader can picture.
|
||||
*
|
||||
* They matter more than they look. Two objects with byte-identical geometry at
|
||||
* `unit: 0` and `unit: 40` are a molecule and a mountain, and a client that
|
||||
* prints "8 vertices · 12 faces" for both has told the reader nothing about
|
||||
* the one field that separates them. The reference workshop puts this on
|
||||
* screen as a scale ladder (`sno-core/scale.ts`); the numbers and the unit
|
||||
* thresholds here are that file's, so the same object reads the same size in
|
||||
* both.
|
||||
*
|
||||
* Nothing here is normative and nothing downstream depends on the exact
|
||||
* wording: it is a length, rendered the way a person reads lengths.
|
||||
*/
|
||||
object CyberspaceScale {
|
||||
/** The gibson in metres: `2^-33` (§9.2). */
|
||||
private const val GIBSON_METRES = 1.1641532182693481e-10
|
||||
|
||||
/** The largest `unit` DECK-0003 §1.8 allows, and the range these cover. */
|
||||
private const val MAX_UNIT = 84
|
||||
|
||||
private class Measure(
|
||||
/** Used below this many metres. */
|
||||
val limit: Double,
|
||||
val symbol: String,
|
||||
val perMetre: Double,
|
||||
)
|
||||
|
||||
// µ is the micro sign; written as an escape so the file stays
|
||||
// unambiguous about which of the two lookalike code points it carries.
|
||||
private val MEASURES =
|
||||
listOf(
|
||||
Measure(1e-9, "pm", 1e12),
|
||||
Measure(1e-6, "nm", 1e9),
|
||||
Measure(1e-3, "µm", 1e6),
|
||||
Measure(1e-2, "mm", 1e3),
|
||||
Measure(1e-1, "cm", 1e2),
|
||||
Measure(1e3, "m", 1.0),
|
||||
Measure(1e6, "km", 1e-3),
|
||||
Measure(1e9, "Mm", 1e-6),
|
||||
Measure(1.496e11, "AU", 1.0 / 1.496e11),
|
||||
)
|
||||
|
||||
/** One model unit in metres, at this scale exponent. */
|
||||
fun unitInMetres(unit: Int): Double {
|
||||
var metres = GIBSON_METRES
|
||||
repeat(unit.coerceIn(0, MAX_UNIT)) { metres *= 2.0 }
|
||||
return metres
|
||||
}
|
||||
|
||||
/**
|
||||
* One model unit as a length a person reads: `"116 pm"`, `"1 m"`, `"2 AU"`.
|
||||
*
|
||||
* Three significant figures, in the largest measure the length fits inside,
|
||||
* with nothing trailing that carries no information.
|
||||
*/
|
||||
fun describeUnit(unit: Int): String {
|
||||
val metres = unitInMetres(unit)
|
||||
val measure = MEASURES.firstOrNull { metres < it.limit } ?: MEASURES.last()
|
||||
return significant(metres * measure.perMetre) + " " + measure.symbol
|
||||
}
|
||||
|
||||
/**
|
||||
* A positive number to three significant figures, without a trailing zero
|
||||
* or a trailing point.
|
||||
*
|
||||
* Built from a Long rather than a platform formatter, because there is no
|
||||
* locale-independent one in common Kotlin and a size that renders as
|
||||
* "1,91 µm" for half the world and "1.91 µm" for the other half is a
|
||||
* number two readers cannot compare.
|
||||
*/
|
||||
private fun significant(value: Double): String {
|
||||
if (value <= 0.0 || !value.isFinite()) return "0"
|
||||
val exponent = floor(log10(value)).toInt()
|
||||
|
||||
// Four digits or more: three of them carry the figure and the rest are
|
||||
// zeros, so 1876 reads 1880 and 15060 reads 15100.
|
||||
if (exponent >= 2) {
|
||||
var step = 1L
|
||||
repeat((exponent - 2).coerceAtMost(17)) { step *= 10L }
|
||||
return ((value / step).roundToLong() * step).toString()
|
||||
}
|
||||
|
||||
val places = (2 - exponent).coerceAtMost(12)
|
||||
var scale = 1L
|
||||
repeat(places) { scale *= 10L }
|
||||
val scaled = (value * scale).roundToLong()
|
||||
val whole = scaled / scale
|
||||
val fraction = (scaled % scale).toString().padStart(places, '0').trimEnd('0')
|
||||
return if (fraction.isEmpty()) whole.toString() else "$whole.$fraction"
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -58,7 +58,7 @@ class SnoAvatarEvent(
|
||||
/** True when this identity asked for the default avatar, which owes no work. */
|
||||
fun isDefaultAvatar(): Boolean = content.isBlank()
|
||||
|
||||
fun sno(): SnoResult = SnoParser.parse(content)
|
||||
fun sno(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette)
|
||||
|
||||
/** The `name` tag: the shape's name for humans. */
|
||||
fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
|
||||
@@ -79,14 +79,14 @@ class SnoAvatarEvent(
|
||||
* committed 30 and an id carrying 20 gives 20, which clears 16 while
|
||||
* failing the second condition outright.
|
||||
*/
|
||||
fun payment(): SnoAvatarPayment {
|
||||
fun payment(fetchedPalette: SnoPalette? = null): SnoAvatarPayment {
|
||||
val zeros = PoWRankEvaluator.calculatePowRankOf(id)
|
||||
if (isDefaultAvatar()) {
|
||||
return SnoAvatarPayment(true, 0, null, zeros, SnoAvatarPayment.Reason.DEFAULT_AVATAR)
|
||||
}
|
||||
|
||||
val payload =
|
||||
sno().payloadOrNull()
|
||||
sno(fetchedPalette).payloadOrNull()
|
||||
?: return SnoAvatarPayment(false, 0, null, zeros, SnoAvatarPayment.Reason.NOT_AN_AVATAR)
|
||||
|
||||
val required = SnoAvatarWork.required(payload)
|
||||
|
||||
+32
-11
@@ -32,6 +32,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What [SnoParser] made of a payload. */
|
||||
@@ -124,27 +125,32 @@ object SnoParser {
|
||||
// before it can be assembled. Absent ticks means every position is whole.
|
||||
val ticks = expandTicks(root["ticks"], vertices.size) ?: return SnoResult.Invalid("7", "ticks do not expand to one remainder per vertex")
|
||||
|
||||
// Rule 6, and the position bound this reader adds (see MAX_TICKS_FROM_ORIGIN).
|
||||
// Rule 6. §1.8 puts the position bound on publishers — "a publisher MUST
|
||||
// NOT write a vertex further than 64 model units from the origin" — and
|
||||
// lets a reader either reject such a payload or "repair it by growing
|
||||
// the extent". Both references repair: `sno-core`'s `fromPayload` does
|
||||
// not check a position at all and lets `neededExtent` grow past its own
|
||||
// `MAX_EXTENT`, and `sno-reference.py` does not check either. So this
|
||||
// repairs too, under rule 9 below, and the only bound left here is the
|
||||
// one the lattice itself imposes (see MAX_TICKS_FROM_ORIGIN).
|
||||
val positions = IntArray(vertices.size * 3)
|
||||
for (i in 0 until vertices.size) {
|
||||
val triple = vertices[i] as? JsonArray ?: return SnoResult.Invalid("6", "vertex $i is not an array")
|
||||
if (triple.size != 3) return SnoResult.Invalid("6", "vertex $i is not three integers")
|
||||
for (axis in 0..2) {
|
||||
val whole = triple[axis].asIntOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers")
|
||||
// Range-checked rather than `abs`-checked, because abs(Int.MIN_VALUE)
|
||||
// is itself negative: a vertex of -2147483648 would pass the bound,
|
||||
// overflow `* 120` to 0, and parse as a silently rewritten coordinate.
|
||||
if (whole > SnoPayload.MAX_EXTENT || whole < -SnoPayload.MAX_EXTENT) {
|
||||
return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin")
|
||||
}
|
||||
// Read and multiplied as a Long, because the product is what has
|
||||
// to fit: a whole of Int.MIN_VALUE would otherwise overflow `*
|
||||
// 120` to 0 and parse as a silently rewritten coordinate, and
|
||||
// `abs` would not catch it either, being its own negative there.
|
||||
val whole = triple[axis].asLongOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers")
|
||||
val total = whole * SnoPayload.TICKS_PER_UNIT + ticks[i * 3 + axis]
|
||||
if (total > SnoPayload.MAX_TICKS_FROM_ORIGIN || total < -SnoPayload.MAX_TICKS_FROM_ORIGIN) {
|
||||
return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin")
|
||||
return SnoResult.Invalid("bound", "vertex $i lies past what the lattice can hold exactly")
|
||||
}
|
||||
// §2: a `v: 1` payload has +Z away from the viewer, so its Z is
|
||||
// negated on read, which renders the object exactly as its author
|
||||
// built it. A `v: 2` payload is read as written.
|
||||
positions[i * 3 + axis] = if (axis == 2 && version == 1) -total else total
|
||||
positions[i * 3 + axis] = (if (axis == 2 && version == 1) -total else total).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +212,11 @@ object SnoParser {
|
||||
// Rule 9. `extent` is repaired rather than validated: out of range becomes
|
||||
// the default, then it grows to contain the data. The data wins and the
|
||||
// hint is corrected, so an object is never refused for disagreeing with
|
||||
// its own extent (§1.8).
|
||||
// its own extent (§1.8) — and, since rule 6 no longer turns away a
|
||||
// vertex past 64 units, this is also where such a vertex is repaired.
|
||||
// The grown extent may therefore exceed MAX_EXTENT, exactly as the
|
||||
// reference's `neededExtent` does; MAX_EXTENT bounds what a payload may
|
||||
// *declare*, not what its geometry may need.
|
||||
val declared = root["extent"].asIntOrNull()
|
||||
val repaired = if (declared != null && declared >= SnoPayload.MIN_EXTENT && declared <= SnoPayload.MAX_EXTENT) declared else SnoPayload.DEFAULT_EXTENT
|
||||
val extent = grownExtent(repaired, positions)
|
||||
@@ -415,6 +425,17 @@ object SnoParser {
|
||||
return (clamped * 255.0).roundToInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON integer as a Long, or null when the token is not one — including a
|
||||
* whole number too large for a Long, which is not a coordinate anybody can
|
||||
* hold and is rejected the same way a string would be.
|
||||
*/
|
||||
private fun JsonElement?.asLongOrNull(): Long? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
if (primitive.isString) return null
|
||||
return primitive.longOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement?.asIntOrNull(): Int? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
if (primitive.isString) return null
|
||||
|
||||
+23
-14
@@ -49,7 +49,11 @@ class SnoPayload(
|
||||
val name: String,
|
||||
/** Scale exponent: one model unit is `2^unit` of the application's base unit (§1.6). */
|
||||
val unit: Int,
|
||||
/** Grid half-width in model units, repaired and grown to contain the data (§1.8). */
|
||||
/**
|
||||
* Grid half-width in model units, repaired and grown to contain the data
|
||||
* (§1.8). A declared extent is only honoured inside `1..`[MAX_EXTENT]; the
|
||||
* grown one has no ceiling, because the geometry is what it is.
|
||||
*/
|
||||
val extent: Int,
|
||||
val mode: SnoMode,
|
||||
/** Three total-tick coordinates per vertex: X, Y, Z. */
|
||||
@@ -91,20 +95,25 @@ class SnoPayload(
|
||||
/**
|
||||
* The farthest a vertex may lie from the origin on any axis, in ticks.
|
||||
*
|
||||
* DECK-0003 §1.8 states this bound as an obligation on publishers and
|
||||
* lets a reader "reject such a payload" or "repair it by growing the
|
||||
* extent"; §8's third open question admits that leaving it off readers
|
||||
* is unsafe as a general rule, since "a payload of 512 vertices at 2^50
|
||||
* units is valid under this text and will produce a grid no renderer
|
||||
* wants". Neither reference implementation bounds it — ONOSENDAI grows
|
||||
* the extent without a ceiling, and `sno-reference.py` does not check.
|
||||
* This is a representation limit and not a rule of the format. §1.8
|
||||
* puts the position bound on publishers — "a publisher MUST NOT write a
|
||||
* vertex further than `64` model units (`7680` ticks) from the origin
|
||||
* on any axis" — and offers a reader two ways to answer one that does:
|
||||
* "A reader MAY reject such a payload and MAY instead repair it by
|
||||
* growing the extent." Both references repair. `sno-core`'s
|
||||
* `fromPayload` never looks at a position's magnitude and lets
|
||||
* `neededExtent` grow the extent past its own `MAX_EXTENT`, and
|
||||
* `sno-reference.py` does not check either. So Amethyst repairs, and an
|
||||
* object that reaches past the grid is drawn rather than refused; what
|
||||
* is left here is only the point at which the lattice stops being able
|
||||
* to say where a vertex is.
|
||||
*
|
||||
* Amethyst rejects, which is the deliberate divergence, because it
|
||||
* draws strangers' events in a feed and because `whole * 120` overflows
|
||||
* an Int well before 2^50 units, so the bound is a representation limit
|
||||
* here and not only a matter of taste. Nothing on the network is near
|
||||
* it: the widest object published so far reaches 8 units.
|
||||
* That point is `Int.MAX_VALUE` ticks, about 17.9 million model units,
|
||||
* because a position is a total tick count in an Int — the exact
|
||||
* integer §1.2 requires every weld, comparison and hash to run on. Past
|
||||
* it there is no coordinate to repair, only one that would wrap, so
|
||||
* that is a rejection rather than a judgement about size.
|
||||
*/
|
||||
const val MAX_TICKS_FROM_ORIGIN = MAX_EXTENT * TICKS_PER_UNIT
|
||||
const val MAX_TICKS_FROM_ORIGIN = Int.MAX_VALUE.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.quartz.cyberspace.deck0003Sno
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate
|
||||
import com.vitorpamplona.quartz.cyberspace.CyberspacePlane
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@@ -86,6 +88,17 @@ class SnoShardEvent(
|
||||
*/
|
||||
fun coordinate(): String? = tags.firstOrNull { it.size > 1 && it[0] == "C" }?.get(1)
|
||||
|
||||
/**
|
||||
* The plane the claimed coordinate lies in, or null when there is no `C`
|
||||
* tag or it is not a coordinate.
|
||||
*
|
||||
* One bit of §2.2, and the only part of a coordinate a client with no world
|
||||
* can turn into something a reader understands: whether the shard was
|
||||
* hidden at a place on Earth or at one that has no physical counterpart.
|
||||
* See [CyberspaceCoordinate] for why it stops there.
|
||||
*/
|
||||
fun plane(): CyberspacePlane? = coordinate()?.let { CyberspaceCoordinate.planeOf(it) }
|
||||
|
||||
companion object {
|
||||
const val KIND = 3330
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.cyberspace
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** §2.2's plane bit, and what counts as a coordinate at all. */
|
||||
class CyberspaceCoordinateTest {
|
||||
private val zeros = "0".repeat(64)
|
||||
|
||||
@Test
|
||||
fun thePlaneIsTheLeastSignificantBit() {
|
||||
// §2.2: "Bit 0 (LSB): plane bit P", and §2.4: 0 is dataspace, 1 is
|
||||
// ideaspace. The LSB of a hex string is the low bit of its last digit,
|
||||
// so every odd digit is ideaspace and every even one dataspace.
|
||||
assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros))
|
||||
assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "1"))
|
||||
assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "e"))
|
||||
assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "f"))
|
||||
// The other 255 bits are the axes and say nothing about the plane.
|
||||
assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf("f".repeat(63) + "0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anythingThatIsNotThirtyTwoBytesOfLowercaseHexIsNotACoordinate() {
|
||||
assertTrue(CyberspaceCoordinate.isWellFormed(zeros))
|
||||
assertFalse(CyberspaceCoordinate.isWellFormed(""))
|
||||
assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1)))
|
||||
assertFalse(CyberspaceCoordinate.isWellFormed(zeros + "0"))
|
||||
// §7.6 and §8 both say lowercase; an uppercase one is somebody else's
|
||||
// convention and is not silently accepted.
|
||||
assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "A"))
|
||||
assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "g"))
|
||||
assertNull(CyberspaceCoordinate.planeOf("not a coordinate"))
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.cyberspace
|
||||
|
||||
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* One model unit as a length, checked against the reference the whole way up.
|
||||
*
|
||||
* Every value DECK-0003 §1.8 permits for `unit` — 0 to 84 — is here, taken
|
||||
* from `sno-core/scale.ts` run over the same range, because the point of the
|
||||
* readout is that the same object reads the same size in both clients. The
|
||||
* boundaries are where the two could drift: where the measure changes, where a
|
||||
* figure rounds up past its own magnitude, and `unit: 28`, which is 3.125 cm
|
||||
* exactly — a tie, and the one entry where a language's default rounding shows.
|
||||
* ECMAScript's `toPrecision` takes the larger, so 3.13, and `roundToLong` here
|
||||
* rounds half up and agrees.
|
||||
*/
|
||||
class CyberspaceScaleTest {
|
||||
private val reference =
|
||||
(
|
||||
"116 pm,233 pm,466 pm,931 pm,1.86 nm,3.73 nm,7.45 nm,14.9 nm,29.8 nm,59.6 nm,119 nm," +
|
||||
"238 nm,477 nm,954 nm,1.91 µm,3.81 µm,7.63 µm,15.3 µm,30.5 µm,61 µm," +
|
||||
"122 µm,244 µm,488 µm,977 µm,1.95 mm,3.91 mm,7.81 mm,1.56 cm,3.13 cm,6.25 cm," +
|
||||
"0.125 m,0.25 m,0.5 m,1 m,2 m,4 m,8 m,16 m,32 m,64 m,128 m,256 m,512 m,1.02 km,2.05 km," +
|
||||
"4.1 km,8.19 km,16.4 km,32.8 km,65.5 km,131 km,262 km,524 km,1.05 Mm,2.1 Mm,4.19 Mm," +
|
||||
"8.39 Mm,16.8 Mm,33.6 Mm,67.1 Mm,134 Mm,268 Mm,537 Mm,0.00718 AU,0.0144 AU,0.0287 AU," +
|
||||
"0.0574 AU,0.115 AU,0.23 AU,0.459 AU,0.919 AU,1.84 AU,3.67 AU,7.35 AU,14.7 AU,29.4 AU," +
|
||||
"58.8 AU,118 AU,235 AU,470 AU,941 AU,1880 AU,3760 AU,7530 AU,15100 AU"
|
||||
).split(",")
|
||||
|
||||
@Test
|
||||
fun everyUnitReadsAsTheReferenceReadsIt() {
|
||||
assertEquals(SnoPayload.MAX_UNIT + 1, reference.size, "the table should cover every unit the deck allows")
|
||||
for (unit in reference.indices) {
|
||||
assertEquals(reference[unit], CyberspaceScale.describeUnit(unit), "unit $unit")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAnchorsAreTheOnesTheSpecStates() {
|
||||
// §9.3: "Cantor Height 33 = 1 meter", and 34 is the two metres the
|
||||
// scale was calibrated on.
|
||||
assertEquals("1 m", CyberspaceScale.describeUnit(33))
|
||||
assertEquals("2 m", CyberspaceScale.describeUnit(34))
|
||||
// §9.2: a gibson is 2^-33 m, "roughly the size of a hydrogen atom".
|
||||
assertEquals("116 pm", CyberspaceScale.describeUnit(0))
|
||||
assertEquals(1.0, CyberspaceScale.unitInMetres(33), 1e-12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aUnitOutsideTheDeckIsHeldAtTheEdgeRatherThanOverflowing() {
|
||||
// The parser rejects these long before here, so this is only about not
|
||||
// producing a nonsense string if some other caller asks.
|
||||
assertEquals(CyberspaceScale.describeUnit(0), CyberspaceScale.describeUnit(-5))
|
||||
assertEquals(CyberspaceScale.describeUnit(SnoPayload.MAX_UNIT), CyberspaceScale.describeUnit(9999))
|
||||
assertTrue(CyberspaceScale.unitInMetres(9999).isFinite())
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -293,13 +293,16 @@ class SnoParserAcceptanceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVertexBeyondTheBoundIsRefused() {
|
||||
// Amethyst's own rule, and a deliberate divergence: §1.8 puts the bound on
|
||||
// publishers and lets readers repair instead, which neither reference
|
||||
// implementation does either way.
|
||||
val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[65,2,1]]"))
|
||||
assertTrue(result is SnoResult.Invalid)
|
||||
assertEquals("bound", result.rule)
|
||||
fun aVertexBeyondTheGridGrowsItRatherThanFailing() {
|
||||
// §1.8 puts the position bound on publishers — a vertex further than 64
|
||||
// model units from the origin is theirs not to write — and gives a
|
||||
// reader the choice: "A reader MAY reject such a payload and MAY
|
||||
// instead repair it by growing the extent." Both references repair, so
|
||||
// this does too, and the repair is visible in the extent.
|
||||
val payload = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[65,2,1]]")).payloadOrNull()
|
||||
assertNotNull(payload)
|
||||
assertEquals(65, payload.extent, "the extent should have grown past MAX_EXTENT to hold the vertex")
|
||||
assertEquals(65 * SnoPayload.TICKS_PER_UNIT, payload.tickAt(3, 0), "and the coordinate should be untouched")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -307,6 +310,28 @@ class SnoParserAcceptanceTest {
|
||||
assertNotNull(SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[64,2,1]]")).payloadOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVertexPastWhatTheLatticeHoldsIsStillRefused() {
|
||||
// The one bound left is the lattice's own: a position is a total tick
|
||||
// count in an Int, and past Int.MAX_VALUE ticks there is no coordinate
|
||||
// to repair, only one that would wrap.
|
||||
val justInside = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[17895697,2,1]]")).payloadOrNull()
|
||||
assertNotNull(justInside)
|
||||
assertEquals(17895697 * SnoPayload.TICKS_PER_UNIT, justInside.tickAt(3, 0))
|
||||
|
||||
val justOutside = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[17895698,2,1]]"))
|
||||
assertTrue(justOutside is SnoResult.Invalid)
|
||||
assertEquals("bound", justOutside.rule)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWholeTooLargeForALongIsRefusedRatherThanRounded() {
|
||||
// A JSON integer has no width. One past a Long would come back as a
|
||||
// Double through a lazier reader and land somewhere near the origin.
|
||||
val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[99999999999999999999,2,1]]"))
|
||||
assertTrue(result is SnoResult.Invalid)
|
||||
}
|
||||
|
||||
private fun payload(
|
||||
version: Int = 2,
|
||||
name: String = "tetra",
|
||||
|
||||
Reference in New Issue
Block a user