feat(ui): refine black Pixel model with official GrapheneOS boot artwork
This commit is contained in:
+15
-10
@@ -9,7 +9,7 @@ computer illustrations in `archipelago-website-deck`.
|
||||
|
||||
`neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations,
|
||||
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
|
||||
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/
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
## Marketplace follow-up
|
||||
## Hardware discovery
|
||||
|
||||
The user requested a checkpoint after the models to switch to a cheaper model.
|
||||
No Buy router button, relay searches, product cards or checkout implementation
|
||||
are included in this change. Next: research the current NIP-99 specification,
|
||||
add router marketplace discovery and product cards, then connect the purchase
|
||||
entry point to Archipelago's existing checkout capabilities. The user explicitly
|
||||
excluded building the whole checkout flow at this stage.
|
||||
The router gateway and private-phone Setup goal share `HardwareMarketplace.vue`.
|
||||
Buy router / Buy phone replaces the model with a horizontal product carousel,
|
||||
preserving the outer container's height. Discovery queries enabled relays from
|
||||
`nostr.list-relays` for NIP-99 kind-30402 events. Development builds show clearly
|
||||
labeled sample listings when no matching live listings are found. Checkout and
|
||||
the product View/Buy actions remain unimplemented.
|
||||
|
||||
Browser scenarios and reports stay outside this repository, under
|
||||
`/home/yaya/.local/share/browser-check/projects/archy-devices/`.
|
||||
|
||||
@@ -33,6 +33,8 @@ planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
||||
|
||||
## 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
|
||||
(game-icons.net CC BY 3.0; pixelarticons MIT).
|
||||
- `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 |
@@ -1,5 +1,6 @@
|
||||
import * as THREE from 'three'
|
||||
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
|
||||
// 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 },
|
||||
'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 },
|
||||
'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 root = new THREE.Group()
|
||||
const geometries = new Map<string, THREE.BufferGeometry>()
|
||||
const materials: THREE.Material[] = []
|
||||
const textures: THREE.Texture[] = []
|
||||
let disposed = false
|
||||
const material = (color: number, roughness = .5, metalness = .1) => {
|
||||
const m = new THREE.MeshStandardMaterial({ color, roughness, metalness })
|
||||
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)
|
||||
underside.rotation.x = Math.PI / 2
|
||||
} else if (spec.kind === 'phone') {
|
||||
// Pixel-inspired handset: metal edge, side keys, rear camera bar and a
|
||||
// conceptual GrapheneOS verified-boot display. This is not device CAD.
|
||||
box(w + .035, h + .035, d + .025, 0, 0, 0, silver, .22)
|
||||
box(w, h, d, 0, 0, 0, body, .21)
|
||||
box(w - .08, h - .09, .025, 0, 0, d / 2 + .018, black, .19)
|
||||
box(w - .15, h - .18, .008, 0, 0, d / 2 + .036, glass, .15)
|
||||
const bootCanvas = document.createElement('canvas')
|
||||
bootCanvas.width = 512; bootCanvas.height = 1024
|
||||
const boot = bootCanvas.getContext('2d')!
|
||||
const glow = boot.createLinearGradient(0, 0, 512, 1024)
|
||||
glow.addColorStop(0, '#080d11'); glow.addColorStop(1, '#121c1d')
|
||||
boot.fillStyle = glow; boot.fillRect(0, 0, 512, 1024)
|
||||
boot.strokeStyle = '#e6c775'; boot.lineWidth = 12
|
||||
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)
|
||||
// Rounded outlines are independent of thickness: a thin RoundedBox clamps
|
||||
// the corner radius to its depth and makes a handset look square.
|
||||
function outline(width: number, height: number, radius: number) {
|
||||
const x = -width / 2, y = -height / 2, r = radius
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(x + r, y); shape.lineTo(x + width - r, y)
|
||||
shape.quadraticCurveTo(x + width, y, x + width, y + r)
|
||||
shape.lineTo(x + width, y + height - r)
|
||||
shape.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
|
||||
shape.lineTo(x + r, y + height)
|
||||
shape.quadraticCurveTo(x, y + height, x, y + height - r)
|
||||
shape.lineTo(x, y + r); shape.quadraticCurveTo(x, y, x + r, y)
|
||||
return shape
|
||||
}
|
||||
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') {
|
||||
box(w, h, d, 0, 0, 0, body, .06)
|
||||
// Plated through holes, copper traces, solder joints and populated reverse.
|
||||
@@ -340,6 +398,7 @@ export function createDeviceModel(id: string) {
|
||||
return {
|
||||
pivot, radius, name: spec.name,
|
||||
dispose() {
|
||||
disposed = true
|
||||
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())
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ export function createDeviceScene(host: HTMLElement, id: string) {
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
const scene = new THREE.Scene()
|
||||
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)
|
||||
model.pivot.rotation.set(id === 'beryl-ax' ? .27 : -.08, -.4, 0)
|
||||
const studio = new RoomEnvironment()
|
||||
@@ -28,7 +29,6 @@ export function createDeviceScene(host: HTMLElement, id: string) {
|
||||
light.position.set(x!, y!, z!); scene.add(light)
|
||||
}
|
||||
host.append(renderer.domElement)
|
||||
let disposed = false
|
||||
function render() { if (!disposed) renderer.render(scene, camera) }
|
||||
function resize() {
|
||||
if (disposed) return
|
||||
@@ -44,7 +44,7 @@ export function createDeviceScene(host: HTMLElement, id: string) {
|
||||
return {
|
||||
canvas: renderer.domElement, get rotation() { return model.pivot.rotation }, render, resize,
|
||||
setModel(next: string) {
|
||||
const replacement = createDeviceModel(next)
|
||||
const replacement = createDeviceModel(next, render)
|
||||
scene.remove(model.pivot); model.dispose(); model = replacement
|
||||
model.pivot.rotation.set(next === 'beryl-ax' ? .27 : -.08, -.4, 0)
|
||||
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 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user