feat(ui): refine black Pixel model with official GrapheneOS boot artwork

This commit is contained in:
yaya
2026-10-09 15:02:25 +01:00
parent cfde1343a4
commit dd909040f8
7 changed files with 130 additions and 49 deletions
+15 -10
View File
@@ -9,7 +9,7 @@ computer illustrations in `archipelago-website-deck`.
`neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations, `neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations,
including the generic fallback and three optional enclosure variants, plus including the generic fallback and three optional enclosure variants, plus
the Beryl AX (GL-MT3000). All existing `meshDeviceImages.ts` detection results the Beryl AX (GL-MT3000) and a black Pixel 9 Pro. All existing `meshDeviceImages.ts` detection results
resolve to a procedural model key. USB chip identification remains a resolve to a procedural model key. USB chip identification remains a
best-effort visual match; it does not select firmware or claim exact hardware. best-effort visual match; it does not select firmware or claim exact hardware.
@@ -33,7 +33,12 @@ Reference material:
https://wiki.lilygo.cc/products/t-deck-series/t-deck/ https://wiki.lilygo.cc/products/t-deck-series/t-deck/
Manufacturer photographs are references only; none are copied into the app. Manufacturer photographs are references only; none are copied into the app.
Geometry, grain, labels and screens are generated locally without CDN assets. Geometry, grain and device labels are generated locally without CDN assets.
The black Pixel 9 Pro illustration uses an unmodified, locally bundled GrapheneOS
boot logo mask from its framework source. Its rounded outline, glass panels,
camera island, layered lenses, side buttons and ports are procedural geometry.
The display shows a static phase of the OS boot animation, distinct from Google's
bootloader notice. Source and attribution: `neode-ui/src/graphics/assets/ATTRIBUTION.md`.
## Interaction and lifecycle ## Interaction and lifecycle
@@ -50,17 +55,17 @@ materials. Unmount releases the renderer and context. Unsupported browser APIs,
unavailable WebGL, import failures or lost contexts leave a static fallback. unavailable WebGL, import failures or lost contexts leave a static fallback.
The router figure uses the remaining viewport height below the connection form The router figure uses the remaining viewport height below the connection form
on desktop (minimum 260px); phones get a fixed 330px illustration. Small desktop on desktop (minimum 260px); mobile layouts get a fixed 290px illustration. Small desktop
windows can scroll normally rather than collapsing the model to zero height. windows can scroll normally rather than collapsing the model to zero height.
## Marketplace follow-up ## Hardware discovery
The user requested a checkpoint after the models to switch to a cheaper model. The router gateway and private-phone Setup goal share `HardwareMarketplace.vue`.
No Buy router button, relay searches, product cards or checkout implementation Buy router / Buy phone replaces the model with a horizontal product carousel,
are included in this change. Next: research the current NIP-99 specification, preserving the outer container's height. Discovery queries enabled relays from
add router marketplace discovery and product cards, then connect the purchase `nostr.list-relays` for NIP-99 kind-30402 events. Development builds show clearly
entry point to Archipelago's existing checkout capabilities. The user explicitly labeled sample listings when no matching live listings are found. Checkout and
excluded building the whole checkout flow at this stage. the product View/Buy actions remain unimplemented.
Browser scenarios and reports stay outside this repository, under Browser scenarios and reports stay outside this repository, under
`/home/yaya/.local/share/browser-check/projects/archy-devices/`. `/home/yaya/.local/share/browser-check/projects/archy-devices/`.
+2
View File
@@ -33,6 +33,8 @@ planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
## Vendored ## Vendored
- `src/graphics/assets/grapheneos-boot-mask.png` — GrapheneOS boot artwork,
Apache-2.0; see the adjacent ATTRIBUTION.md for the pinned source and credits.
- `public/assets/icon/` — see ATTRIBUTION.md in that directory - `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT). (game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0; - `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
@@ -0,0 +1,15 @@
# GrapheneOS boot artwork
`grapheneos-boot-mask.png` is the unmodified boot logo mask from GrapheneOS's
Android framework, commit `0f505137b78efaee3058434e88974e2a06e0917c`:
https://github.com/GrapheneOS/platform_frameworks_base/blob/0f505137b78efaee3058434e88974e2a06e0917c/core/res/assets/images/android-logo-mask.png
The source commit credits Daniel Micay and smdyv. The framework is distributed
under Apache-2.0; the license text is also shipped at
`public/assets/fonts/Open_Sans/LICENSE.txt` in this frontend.
The phone displays a static white phase of the actual GrapheneOS OS boot logo.
It does not reproduce Google's separate bootloader / yellow alternate-OS notice.
GrapheneOS branding identifies the illustrated operating system; it does not
imply endorsement. The phone geometry and materials are original procedural art.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+94 -35
View File
@@ -1,5 +1,6 @@
import * as THREE from 'three' import * as THREE from 'three'
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js' import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
import grapheneBootMaskUrl from './assets/grapheneos-boot-mask.png'
// Original, procedural display models. Dimensions/layouts follow the existing // Original, procedural display models. Dimensions/layouts follow the existing
// device illustrations and manufacturer references listed in device-models.md. // device illustrations and manufacturer references listed in device-models.md.
@@ -36,15 +37,16 @@ export const deviceSpecs: Record<string, DeviceSpec> = {
'rak4631_case': { kind: 'case', name: 'RAK WisBlock', w: 1.4, h: 2.3, d: .55, color: 0xd5dad5, screen: 'oled', antenna: true }, 'rak4631_case': { kind: 'case', name: 'RAK WisBlock', w: 1.4, h: 2.3, d: .55, color: 0xd5dad5, screen: 'oled', antenna: true },
'unknown-new': { kind: 'case', name: 'LoRa mesh radio', w: 1.8, h: 2.6, d: .5, color: 0x182623, screen: 'oled' }, 'unknown-new': { kind: 'case', name: 'LoRa mesh radio', w: 1.8, h: 2.6, d: .5, color: 0x182623, screen: 'oled' },
'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x171b20 }, 'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x171b20 },
'pixel-graphene': { kind: 'phone', name: 'Pixel · GrapheneOS', w: 1.82, h: 3.7, d: .26, color: 0x24282c }, 'pixel-graphene': { kind: 'phone', name: 'Pixel 9 Pro · GrapheneOS', w: 1.8, h: 3.82, d: .213, color: 0x08090b },
} }
export function createDeviceModel(id: string) { export function createDeviceModel(id: string, invalidate: () => void = () => {}) {
const spec = deviceSpecs[id] ?? deviceSpecs['unknown-new']! const spec = deviceSpecs[id] ?? deviceSpecs['unknown-new']!
const root = new THREE.Group() const root = new THREE.Group()
const geometries = new Map<string, THREE.BufferGeometry>() const geometries = new Map<string, THREE.BufferGeometry>()
const materials: THREE.Material[] = [] const materials: THREE.Material[] = []
const textures: THREE.Texture[] = [] const textures: THREE.Texture[] = []
let disposed = false
const material = (color: number, roughness = .5, metalness = .1) => { const material = (color: number, roughness = .5, metalness = .1) => {
const m = new THREE.MeshStandardMaterial({ color, roughness, metalness }) const m = new THREE.MeshStandardMaterial({ color, roughness, metalness })
materials.push(m); return m materials.push(m); return m
@@ -159,40 +161,96 @@ export function createDeviceModel(id: string) {
const underside = label('GL-MT3000 / BERYL AX', 1.25, .15, 0, -.413, 0) const underside = label('GL-MT3000 / BERYL AX', 1.25, .15, 0, -.413, 0)
underside.rotation.x = Math.PI / 2 underside.rotation.x = Math.PI / 2
} else if (spec.kind === 'phone') { } else if (spec.kind === 'phone') {
// Pixel-inspired handset: metal edge, side keys, rear camera bar and a // Rounded outlines are independent of thickness: a thin RoundedBox clamps
// conceptual GrapheneOS verified-boot display. This is not device CAD. // the corner radius to its depth and makes a handset look square.
box(w + .035, h + .035, d + .025, 0, 0, 0, silver, .22) function outline(width: number, height: number, radius: number) {
box(w, h, d, 0, 0, 0, body, .21) const x = -width / 2, y = -height / 2, r = radius
box(w - .08, h - .09, .025, 0, 0, d / 2 + .018, black, .19) const shape = new THREE.Shape()
box(w - .15, h - .18, .008, 0, 0, d / 2 + .036, glass, .15) shape.moveTo(x + r, y); shape.lineTo(x + width - r, y)
const bootCanvas = document.createElement('canvas') shape.quadraticCurveTo(x + width, y, x + width, y + r)
bootCanvas.width = 512; bootCanvas.height = 1024 shape.lineTo(x + width, y + height - r)
const boot = bootCanvas.getContext('2d')! shape.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
const glow = boot.createLinearGradient(0, 0, 512, 1024) shape.lineTo(x + r, y + height)
glow.addColorStop(0, '#080d11'); glow.addColorStop(1, '#121c1d') shape.quadraticCurveTo(x, y + height, x, y + height - r)
boot.fillStyle = glow; boot.fillRect(0, 0, 512, 1024) shape.lineTo(x, y + r); shape.quadraticCurveTo(x, y, x + r, y)
boot.strokeStyle = '#e6c775'; boot.lineWidth = 12 return shape
boot.beginPath(); boot.moveTo(256, 288); boot.lineTo(326, 314); boot.lineTo(315, 395)
boot.quadraticCurveTo(294, 433, 256, 451); boot.quadraticCurveTo(218, 433, 197, 395)
boot.lineTo(186, 314); boot.closePath(); boot.stroke()
boot.fillStyle = '#e8ede9'; boot.textAlign = 'center'
boot.font = '500 57px sans-serif'; boot.fillText('GrapheneOS', 256, 590)
boot.fillStyle = '#d4bc78'; boot.font = '30px sans-serif'; boot.fillText('Verified boot', 256, 644)
boot.fillStyle = '#859699'; boot.font = '23px sans-serif'; boot.fillText('Bootloader locked', 256, 684)
const bootTexture = new THREE.CanvasTexture(bootCanvas); bootTexture.colorSpace = THREE.SRGBColorSpace; textures.push(bootTexture)
const bootMaterial = new THREE.MeshBasicMaterial({ map: bootTexture, toneMapped: false }); materials.push(bootMaterial)
const bootScreen = new THREE.Mesh(geometry('pixel-boot-screen', () => new THREE.PlaneGeometry(w - .19, h - .23)), bootMaterial)
bootScreen.position.z = d / 2 + .043; root.add(bootScreen)
disk(.035, 0, h / 2 - .13, d / 2 + .05, dark)
box(.045, .31, .07, w / 2 + .019, .57, 0, silver, .018)
box(.045, .17, .07, w / 2 + .019, .15, 0, silver, .018)
box(w * .82, .42, .07, 0, h * .29, -d / 2 - .04, black, .18)
for (const x of [-.43, .03, .47]) {
disk(.135, x, h * .29, -d / 2 - .085, silver)
disk(.095, x, h * .29, -d / 2 - .098, dark)
disk(.04, x, h * .29, -d / 2 - .11, glass)
} }
box(.13, .045, .025, 0, -h / 2 - .014, 0, dark, .014) function panel(width: number, height: number, depth: number, radius: number, z: number, m: THREE.Material, x = 0, y = 0) {
const bevel = Math.min(.012, depth / 4)
const g = geometry(`phone${width},${height},${depth},${radius}`, () => {
const shape = outline(width - bevel * 2, height - bevel * 2, radius - bevel)
const result = new THREE.ExtrudeGeometry(shape, { depth: depth - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, steps: 1, curveSegments: 12 })
result.translate(0, 0, -depth / 2 + bevel)
return result
})
const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); root.add(mesh); return mesh
}
const frame = material(0x101113, .32, .72)
const edge = material(0x24262a, .27, .8)
const rear = material(0x08090b, .32, .12)
const lensRing = material(0x303238, .25, .78)
const lens = material(0x080d16, .12, .3)
const lensCoating = material(0x172a38, .16, .45)
const seal = material(0x030405, .85)
panel(w, h, d, .265, 0, frame)
panel(w - .024, h - .024, .022, .255, -d / 2, edge)
panel(w - .048, h - .048, .018, .245, -d / 2 - .013, rear)
panel(w - .02, h - .02, .02, .255, d / 2, seal)
panel(w - .044, h - .044, .012, .244, d / 2 + .011, material(0x050609, .2, .08))
const oled = new THREE.MeshBasicMaterial({ color: 0x000000, toneMapped: false }); materials.push(oled)
panel(w - .108, h - .108, .004, .216, d / 2 + .02, oled)
// Unmodified GrapheneOS boot mask, centered at its native Pixel 9 Pro
// screen ratio (448 / 1280). Static white phase of the OS boot animation.
const logoW = (w - .108) * 448 / 1280, logoH = logoW * 605 / 448
const logoGeometry = geometry('graphene-boot-logo', () => new THREE.PlaneGeometry(logoW, logoH))
const bootCanvas = document.createElement('canvas'); bootCanvas.width = 448; bootCanvas.height = 605
const boot = bootCanvas.getContext('2d')!
boot.fillStyle = '#000'; boot.fillRect(0, 0, 448, 605)
const bootTexture = new THREE.CanvasTexture(bootCanvas)
bootTexture.colorSpace = THREE.SRGBColorSpace; textures.push(bootTexture)
new THREE.ImageLoader().load(grapheneBootMaskUrl, image => {
if (disposed) return
boot.fillStyle = '#f3f3f3'; boot.fillRect(0, 0, 448, 605)
boot.drawImage(image, 0, 0); bootTexture.needsUpdate = true; invalidate()
})
const logoMaterial = new THREE.MeshBasicMaterial({ map: bootTexture, toneMapped: false }); materials.push(logoMaterial)
const logo = new THREE.Mesh(logoGeometry, logoMaterial); logo.position.z = d / 2 + .026; root.add(logo)
disk(.031, 0, h / 2 - .145, d / 2 + .028, seal)
disk(.017, 0, h / 2 - .145, d / 2 + .039, lens)
panel(.32, .014, .006, .007, d / 2 + .02, seal, 0, h / 2 - .044)
// Pixel 9 Pro's raised camera island, glass pill, three layered optics,
// flash and temperature sensor; all face outward on the rear.
const cameraY = h / 2 - .68
panel(1.57, .59, .105, .285, -d / 2 - .058, frame, 0, cameraY)
panel(1.51, .53, .018, .255, -d / 2 - .117, edge, 0, cameraY)
panel(1.14, .402, .012, .196, -d / 2 - .13, seal, .125, cameraY)
for (const x of [-.245, .125, .49]) {
disk(.154, x, cameraY, -d / 2 - .142, lensRing)
disk(.13, x, cameraY, -d / 2 - .153, dark)
disk(.103, x, cameraY, -d / 2 - .164, lens)
disk(.064, x, cameraY, -d / 2 - .175, lensCoating)
disk(.039, x, cameraY, -d / 2 - .186, dark)
disk(.018, x - .029, cameraY + .038, -d / 2 - .197, material(0x46556c, .18, .3))
}
disk(.069, -.582, cameraY + .11, -d / 2 - .139, material(0xc8c4ae, .5))
disk(.033, -.582, cameraY - .11, -d / 2 - .139, lens)
disk(.009, -.45, cameraY - .12, -d / 2 - .14, seal)
// Black buttons, antenna seams, bottom USB-C opening and speaker holes.
box(.038, .28, .09, w / 2 + .012, .74, 0, frame, .012)
box(.038, .45, .09, w / 2 + .012, .19, 0, frame, .012)
for (const side of [-1, 1]) for (const y of [-1.35, 1.36]) box(.008, .027, d - .035, side * (w / 2 + .001), y, 0, seal, .002)
const port = panel(.245, .071, .009, .032, 0, edge)
port.rotation.x = Math.PI / 2; port.position.set(0, -h / 2 - .002, 0)
const portHole = panel(.213, .047, .01, .021, 0, seal)
portHole.rotation.x = Math.PI / 2; portHole.position.set(0, -h / 2 - .008, 0)
box(.145, .008, .012, 0, -h / 2 - .015, 0, frame, .003)
for (let i = 0; i < 5; i++) {
const hole = disk(.016, 0, 0, 0, seal)
hole.rotation.x = 0; hole.position.set(.34 + i * .067, -h / 2 - .003, 0)
}
const mic = disk(.015, 0, 0, 0, seal); mic.rotation.x = 0; mic.position.set(-.4, -h / 2 - .003, 0)
} else if (spec.kind === 'board') { } else if (spec.kind === 'board') {
box(w, h, d, 0, 0, 0, body, .06) box(w, h, d, 0, 0, 0, body, .06)
// Plated through holes, copper traces, solder joints and populated reverse. // Plated through holes, copper traces, solder joints and populated reverse.
@@ -340,6 +398,7 @@ export function createDeviceModel(id: string) {
return { return {
pivot, radius, name: spec.name, pivot, radius, name: spec.name,
dispose() { dispose() {
disposed = true
root.traverse(object => { if (object instanceof THREE.InstancedMesh) object.dispose() }) root.traverse(object => { if (object instanceof THREE.InstancedMesh) object.dispose() })
geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); textures.forEach(t => t.dispose()) geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); textures.forEach(t => t.dispose())
}, },
+3 -3
View File
@@ -12,7 +12,8 @@ export function createDeviceScene(host: HTMLElement, id: string) {
renderer.domElement.setAttribute('aria-hidden', 'true') renderer.domElement.setAttribute('aria-hidden', 'true')
const scene = new THREE.Scene() const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(33, 1, .1, 100) const camera = new THREE.PerspectiveCamera(33, 1, .1, 100)
let model = createDeviceModel(id) let disposed = false
let model = createDeviceModel(id, render)
scene.add(model.pivot) scene.add(model.pivot)
model.pivot.rotation.set(id === 'beryl-ax' ? .27 : -.08, -.4, 0) model.pivot.rotation.set(id === 'beryl-ax' ? .27 : -.08, -.4, 0)
const studio = new RoomEnvironment() const studio = new RoomEnvironment()
@@ -28,7 +29,6 @@ export function createDeviceScene(host: HTMLElement, id: string) {
light.position.set(x!, y!, z!); scene.add(light) light.position.set(x!, y!, z!); scene.add(light)
} }
host.append(renderer.domElement) host.append(renderer.domElement)
let disposed = false
function render() { if (!disposed) renderer.render(scene, camera) } function render() { if (!disposed) renderer.render(scene, camera) }
function resize() { function resize() {
if (disposed) return if (disposed) return
@@ -44,7 +44,7 @@ export function createDeviceScene(host: HTMLElement, id: string) {
return { return {
canvas: renderer.domElement, get rotation() { return model.pivot.rotation }, render, resize, canvas: renderer.domElement, get rotation() { return model.pivot.rotation }, render, resize,
setModel(next: string) { setModel(next: string) {
const replacement = createDeviceModel(next) const replacement = createDeviceModel(next, render)
scene.remove(model.pivot); model.dispose(); model = replacement scene.remove(model.pivot); model.dispose(); model = replacement
model.pivot.rotation.set(next === 'beryl-ax' ? .27 : -.08, -.4, 0) model.pivot.rotation.set(next === 'beryl-ax' ? .27 : -.08, -.4, 0)
scene.add(model.pivot); resize() scene.add(model.pivot); resize()
@@ -32,7 +32,7 @@ async function closeShop() {
<div ref="showcase" class="glass-card p-6 overflow-hidden phone-showcase" :style="showcaseHeight ? { height: showcaseHeight } : undefined"> <div ref="showcase" class="glass-card p-6 overflow-hidden phone-showcase" :style="showcaseHeight ? { height: showcaseHeight } : undefined">
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)] gap-6 items-start phone-showcase__grid"> <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)] gap-6 items-start phone-showcase__grid">
<DeviceModel v-if="!shopOpen" model="pixel-graphene" label="Pixel phone with GrapheneOS verified boot" fill-viewport :controls="false" /> <DeviceModel v-if="!shopOpen" model="pixel-graphene" label="Black Pixel 9 Pro with the GrapheneOS boot screen" fill-viewport :controls="false" />
<HardwareMarketplace kind="phone" :expanded="shopOpen" @open="openShop" @close="closeShop" /> <HardwareMarketplace kind="phone" :expanded="shopOpen" @open="openShop" @close="closeShop" />
</div> </div>
</div> </div>