From dd909040f8cdce00a6a49ab2b1ee35084a21f66c Mon Sep 17 00:00:00 2001 From: yaya Date: Fri, 9 Oct 2026 15:02:25 +0100 Subject: [PATCH] feat(ui): refine black Pixel model with official GrapheneOS boot artwork --- docs/device-models.md | 25 ++-- neode-ui/THIRD-PARTY-LICENSES.md | 2 + neode-ui/src/graphics/assets/ATTRIBUTION.md | 15 ++ .../graphics/assets/grapheneos-boot-mask.png | Bin 0 -> 7046 bytes neode-ui/src/graphics/deviceModels.ts | 129 +++++++++++++----- neode-ui/src/graphics/deviceScene.ts | 6 +- .../src/views/goals/NonSurveillancePhone.vue | 2 +- 7 files changed, 130 insertions(+), 49 deletions(-) create mode 100644 neode-ui/src/graphics/assets/ATTRIBUTION.md create mode 100644 neode-ui/src/graphics/assets/grapheneos-boot-mask.png diff --git a/docs/device-models.md b/docs/device-models.md index 1fd47cdc..88b92529 100644 --- a/docs/device-models.md +++ b/docs/device-models.md @@ -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/`. diff --git a/neode-ui/THIRD-PARTY-LICENSES.md b/neode-ui/THIRD-PARTY-LICENSES.md index b3a19c50..43b5780e 100644 --- a/neode-ui/THIRD-PARTY-LICENSES.md +++ b/neode-ui/THIRD-PARTY-LICENSES.md @@ -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; diff --git a/neode-ui/src/graphics/assets/ATTRIBUTION.md b/neode-ui/src/graphics/assets/ATTRIBUTION.md new file mode 100644 index 00000000..86e07187 --- /dev/null +++ b/neode-ui/src/graphics/assets/ATTRIBUTION.md @@ -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. diff --git a/neode-ui/src/graphics/assets/grapheneos-boot-mask.png b/neode-ui/src/graphics/assets/grapheneos-boot-mask.png new file mode 100644 index 0000000000000000000000000000000000000000..fb26e9288bd208e4a49c85851b0a6ecf77dda4bc GIT binary patch literal 7046 zcmdUUWl$AR*X}-acS)yoNQZRyp+P{D5b17#qm+O&hmcfSQb1DS0MZAfJ4HYm>Gs3# z{dND}x%1vVYi2#ObIqRFYd_E4(NG;#d>m>V006$anvy;M5Ci}ymRP9wHOV_B^7l>+ zs%5Br@Bgoq^6-kR`-v#M^>qvY5u<>dF4XFon|mlMAu%t%x~Bcd@&QinY9RpBQ|e0c zhW>MVIrDGIx$q*1%_?2WrYcvi+qZIU%CS)1u*}G&3pwI5uri^sc6mgklsqOVXNAZC zwEr4={IPEkcZCiJrNr00c^5G$4PFSHiL+6EAcVjkycH)cFhl_c48bO~93~JLr=}%Q z>f_-ERK|=={ zRPFC>^3f3C;lyrbK!9%5HliM6l%w8wgxyb)BxJ^B0<;tZYxU8<26YGR!*~f4IeFt) zG{h_El@fQLCHFfJMh4E)H3dZ}Y1=upD}xp$^i2pj+*0+7Qv$Q>%8k-%CU?DNo$zx`T-jXDEa86Jy*q=Kfp zaJITSt5Io$CrQvI;|Uh1KFA&nxlYD`z^*kv=Po+{#I5QxIin}h#7$CdBNn9qA-zfT zdayZ<6q^9ny1IrQ&1lAOn<9irOIAz%%#EacR(dS;mK#Z)FP_rZ2@#Om^g}brU_(SE zYqR!}Htm8RWwR9Dj%{*nlE-K*KKy!2mam3vz}kyWuBQp)e-k0_&L)?nv!y1N!w$VK z8K>@axVkBrZ}5ufMv_Z=Y0Nibxp;#Nqegw8LHLPEdY;kg(yYiGJ=LcKI|mYWfW2XO z@*Gu-bU^$;_A>X8yR?9dEmY}amAkE@DVAJrybCdM<)+6`sz`;OfN5_Si@mt`DIrv% zbDLHFcM|F5rfgTfP)cexx1rY1&!lIA_rZE214l2l)t1u=(zCp^cXlJ(??hkm$_-&l zdM7B%RMm^i7C}lW+p2@=+iRk_M>F3Sm7#f@!mfR^C^t)l6$%LbsJF5fFTG+5te!Vl zpS`|zV*B!~v?e%SOSq&z|D%x&_v$zDR{rxRYrEvfaxDkvP767Ck-^hbH;V6z{aQlY zOiB|zILthDU-ph@dm8OB6?{^4=yn9vR4*oiNfmPkR?2gVEymYoaZj8ZwU&82(b+y9 z(Qt#t47GO$6iD%$Z(nnH{Vr!Mv~kUBp5~ujx_c%xy4T@Ys@u`vlJi!jF&9UhZZXSK1oHDTdY7hGKo0@3`#th^VdV@k>iLuGlFb zzUwb3{CN78(`?7IMmaJ_qP=Y4HYf7e-9XIbup7Eu0k^PkmDj3ePov=brDbwo`&tDv z7pqf`Mq8=M?+(grFIvlw4ahDw>CLek6lNtAtk^v2$B;gi@KEYq%ToE-LGmZEi{=wm z1A%8owMoXE>$H~Z^Z_~@m(5KIMrP^E9^0M?p;m08^;}K53EAB6cgM%R)bTWAy$#d( zyD@DVqN4+shdJr(Qwci;C|?Xk?G|;jgMI%ze41wMV%K0QxFVBv=AS(Lqub%en~z>| z)+ZRQG_^f0bwL->tsVSTtu)u)%Wi9~jk;D$p&jMPHT~0`aqkcv-&9q7-{5?epHAe; zI{kao^UzsMIV=2y9M*$U1RC>izB=9)YP~J)u#&tdpF5O zqCc51UoU+a*gjl?+7$#`m6jNWhrc{$5?XcOpdo+OCnjEdjWsEOZ&!eVceK?E!IN+- zRU>^i(-~GZyBFQ4l@v5T}W`I zpiyOH`sYzSd_hL)#jHy*x~0o1K*~ajMs*>7{*|B&Z?{s3mgqjKeTCqFaBOr0olhV) z?8qMoWvLX7ncJLQ*OUp!Wm*?(mu+IA;;u>Z2z7zqjP*x^_wImM2zRQc(d7{G4 zrdUbPsh%0v*5xzVIXr^L;nrw=(7l6utD`L>2_>EcW2^)Qk&0|8CI5yC4-)2A<%phQ zqgu>zyD6r1lGccRhQ$2jRT#;WOWk*K`B>e#xXkj#?BEeIr9iKhiZorQ&6wuRIrh`Y z2}s~Pd_M8>g$~9bQvj^ZzBk)zL#)qp(>*9wgN*-;Gu2Ym3dVSmwnp@0tlX&8U@X^1 zi;Kk+TB!#se?z)`JaS$1TD}@R$}{jc7VcvD)n;SASV29$%0vDzGVE>Gk9->VJ7*Ie zr^^f%(UL2rt?K!l*L8N%Cu3$@4|2BG=B&2(23*ZY`0(gbzH~v9|E@pn+~&RB-jE_` zi6u#*nCGgm=6B9WM%p*V4YK{U)>N1Ed=*DUz!z+Ov!2`A4mjdd{5(jm(xs}v{SEN{ zsR_u!ZZ(9)-1L2nB>NkzDe=LDBF@t~d>-ktQAh3rnnMrp2d z&-;rnaI8rJg~MY9jF}Z-n$&cJ&t3G=>@jN!zCSGKx=`q-7|>6mL~wf~7T_ri9$pjY z63gk4h^?xw`1u%~v3fx`I6UB7e}6|vaJNQ0V7opVloeyqB(xJ_RP)_Pzot6**@)h8 z0g4!86{;jqD;~pn?T&!%ca!Z`mmY3*)n%wBCedWG!R&i7?|&_nk|4{}y+G%$i&#Q@XW!&fiRRXN)YWzvS#`g{ZqFrC>VB{Ntpk2VZK zancpJAsS7nC;AbeEt(3x>-;-ZexN}e<%Y7*;05QNQ)2BjD>^h zkNtuajGVv6q=-J5$^Z_DI+>F*>CqfnI()))wHaJ$v%&@r&o386`C&fHnb#Sh^*j?J zl53tF8yYEohSdM?Tu)2Mu3)%r=DGz_=L&r3Ly1# zxN8E(`W zXkJm1`d-R7tH1Gh?JGulK;i2 zKD%ZoxyJsqNjsd^hLc+q{NBBJ;h57K2sdJxz9qZus(oO;*;wkV_jGpvLlXbz0?U`4 z4j z$Cc-}E?#BOZ_F9L9?03`FXtgp|8c4OxuCY{;qSqLGp32)>^w~_k5L#u%2)QJ%oEj@ z7w`9erU%Und-?~)9-Cs<%nw~gC{f`0#80Fq%}-u(R*kqF{Hn#4KG?jOYEjV4{3OaD z@a|8JsMgT(M#CH?m$~R{2phq_`@eM^L18I}GpwtZ)?%;1tH+^hZ8khhu2JGhn`@Zu z(kPw)bNIe=qA+DQk0pUBOb%hN*S%YX^teYfOSanpyycdV zq=20~X+%e&p2R-#z#S+OnA@X*eLndp22HSF`DXY@C|KDiA?r2-P3<`HUUC3AhrV%u zc8p1NCVhBAU@l9GK#)HECrLVGtd;($kLP&{12T9+42-D%v|t7lC=neOgX#>GU%Vm@ zj{-EGaaCd>a@Y=I_wN_tm4`dvf#djl-LkQV76cZSu_@fX<_5j}RKG)GKB5TlYChcS zG@`Sr=#c`jyW|JxQCH@e{9zD(BAB%iqXs%CWyZ=cDUX$kxO$SbR}AY zzhVFb@{k%h%YTvnMMH?8Tu`9E02V|J3!pHf0j7TtgoFP;7}0+qgb3w75Ga8Eg=qKP z@8N&C|34X-xB!fQsZxzVR+1oR_|-D&o%8fyE!uf!(7yGkC1v>BTUNlopjjOiI#t2Q z!a1V|o}q2=c`sD0EBh}in)31hDHUOTY=D=lGG0?*P9OA@u_22LcBF-ujM3=~@hxgI zn^mZria?F!L`-=PMvV{vlflV_ss;DhXB$B=QS^-LvHGYhlEmx!bx%*vN0ccnFh@H1 z(chS%)YmW;owRSNC;A_<6rv(7Zm=XOn>PJ~zkvpgra2s@t9V_WM{w|)*TAZWPkVeK z-E%f3w}eNM33IkFg;SZS7_cMxS)f0izf6NBMw>3xqUipj zy;*BBh9JKLBJwUa;cvEzZIQ#s($F8M7{*M(#Sr7EGMC~#O)6mWGWhE!lp*@rJ%Oi0 zF%g+zFvRGjWr+@4F4NH|NbA_^#YVfCc&&hHXfTh|PiioYkI+cxF1SA~mf;?`HH_N~ zn!yj>7LIy0Lw0#a6-z{1s#nMOmkhoEiiWbKx^l9Dj)mL4>DKgst0j>&9bis zHuOWJ(`q%jDOCq2MSDwqLMN6C-eJ<{!fIN(#}F>r0ZhFp*Kjq%ecb^fJD<`rV+q|^QlHIelU zM&YUK7wiu$$`T7cb(|rVGr(yi3xp9a;%ltMfo)?QVYKyrrm%%?^gD+nR!1Xz3mhi7 z>57U52z|43uBS)nP?ET)L+dezSsoz)H^TJdkqxUnfNxKrAA6JM3yoZV7A)6JYOz>*?!^l+vgZZS)y@gn_zLN(oM2Mf`D38|h`F`vi zMCdS8anBHo#hDmEWXgkjB(9=l5|l-z1%tz+)A4$6F_5Xt6BhGBM&?v1CYGu>k!uxS zz)hoTw=d4&M94YWBRbfcb6`1cnu;zb^|$lwdT=lo%k83=SHzdMyanRJesPbYT^5KH zf8!GxqL@WKM3)@+v{z!(h?W?EYWS%fCm18q0f{bF5fiO2eya*-f_YSZP!qT4SjIs% zMt0H0qDmb1iK6=OQiGSepDaZI|C28Spj!h*$>wEO)4!-MDW^TXa}*BWvMzqWH9<7s zQ|BaQFk#~KF|6m1fWM}l;Afs@t0;zSd2~C@koY@Ps5avC#3WD=iQLrXLih>w6Vv1|U85+Q*~2&-Os zx!+qxLKgsD#VuX$ex~exY4Qk!ybvjsuvep)%Iky-6bn|Ui$loj1SU}PMO-9XW>BAP z9+?)mDIB2GJpIJMflu4l87@SY-;^OHB>_o&!Q~5cR)tO2Fk7|$ns0M7D0SV^f?T}C zgg#9-^hOb9#j5!Onq(*)O{Hv)$&-sF-`bb`h*}7SJZZ51iZ0h0lvU_NtHY8}&lQT| zOc6^-PQD1{8m7OZBbJt`*1XFNXhRJm88h#o4{%p9tO6V)MQ(y{VPj8r#il-cXGJeY z7uPh7RK5=fwCm06*rqLtM2g&X-VmU>(fgEAWB46@kY~20koI%_x~kt-5VA-k23^=K zvi*L?Hb?8E!QJLsEmshkP=ahOd^obewpi0%DF7!HrDn8hGY}Q;eV++E+?O6i*i2Lt zn6%>V+Nxz& zCIM0-z(#okY7-~l4j1STvQs{}LZd@YRlOe~jjN0mz?XC7W;;dUh*Lq}=4a3_62fZT z`F&3zzaRZ+$ZF85VcZByZcad?H()sJp+mnzO3Ntzm=T_op8SFQHbK`;k0KqHM%Ef> zX=!xzJ>K6WhRA6B9Rb;=dfI;y2@%Ft3%A#Gw;Hg)y?HMK_6ajjSI!W-!6afD2ra%m zZ-*}(qMY;B7%=RWnbz_M1_MPox36krJ8&7TN`oj3UA`q}*d$|)7CW{mlNHcTzl_?q zeN)|B`Ech=Mzkt?(wa;Aa`9+L?@7sk+J^z-*B?*?tzX&UPR`#gF5&@Xg%3pyzYm!j)C)zK;x@sKrj#oMZO zLlrIWRQ7w;>@TEXqF0L9O{930X}qj@8q=m{eCmh^JH0_~spHMO4gQjC6-+Y)F>IiE z+^Aa@!5M{%P@feJx|y1^z?(#_*IVxVpp6|XMP`2v`9Njc%l?DJ0Jb*%jG~a177T3d zXri-=aW9GE5~m>;C?a)hO^MbAr;I+`a#!=BQ}57EnYwOJ3TF^2@Nf5a!< z8bed%j}Su469H(Nj_w(HlNyvJ>*@~;MZ(+L4R#U0-s`vZ}?4a*Md#lD^n>e17mtv`csB(5!rHe;_QTRa9OgB<5(?-yDI zk|u8Wh#xv-v@K5tPn$>nxNL4=ine^gI2|AG^J_XfIp) zw=u = { '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() 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()) }, diff --git a/neode-ui/src/graphics/deviceScene.ts b/neode-ui/src/graphics/deviceScene.ts index e4a13a45..6731bd93 100644 --- a/neode-ui/src/graphics/deviceScene.ts +++ b/neode-ui/src/graphics/deviceScene.ts @@ -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() diff --git a/neode-ui/src/views/goals/NonSurveillancePhone.vue b/neode-ui/src/views/goals/NonSurveillancePhone.vue index 7ff0c04b..4d97eba5 100644 --- a/neode-ui/src/views/goals/NonSurveillancePhone.vue +++ b/neode-ui/src/views/goals/NonSurveillancePhone.vue @@ -32,7 +32,7 @@ async function closeShop() {
- +