feat(ui): add interactive 3D LoRa devices and OpenWrt router

This commit is contained in:
yaya
2026-10-09 13:15:59 +01:00
parent 5812f53c7c
commit 5d64991203
8 changed files with 705 additions and 37 deletions
+89
View File
@@ -0,0 +1,89 @@
# Interactive hardware illustrations
The radio detection modal and unconfigured OpenWrt gateway use original
procedural Three.js models. The renderer follows the studio lighting,
rounded geometry, material finishes and instancing approach used for the
computer illustrations in `archipelago-website-deck`.
## Coverage
`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
resolve to a model by their SVG filename. The existing SVGs and attribution
remain available as static fallbacks. USB chip identification remains a
best-effort visual match; it does not select firmware or claim exact hardware.
The radios include modeled rear faces, enclosure panels or populated boards,
and appropriate battery holders, connectors and antennas. These are stylized
recognition illustrations, not CAD models. Fine component placement and rear
details are representative; not every board revision or enclosure has been
verified against a rear photograph. The Beryl AX is the example router from
`docs/openwrt-gateway-setup.md`, not an assertion about the connected router.
Reference material:
- Existing Meshtastic illustrations in `neode-ui/public/assets/img/mesh-devices/`.
- Beryl AX official front/rear/side hardware drawing:
https://static.gl-inet.com/docs/router/en/4/user_guide/gl-mt3000/hardware_info/mt3000_interface.jpg
- Beryl AX guide:
https://docs.gl-inet.com/router/en/4/user_guide/gl-mt3000/
- T-Echo manufacturer board and enclosure reference:
https://github.com/Xinyuan-LilyGO/T-Echo
https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/image/T-ECHO.jpg
- T-Deck manufacturer documentation:
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.
## Interaction and lifecycle
`DeviceModel.vue` loads the renderer on intersection. It turns once per minute,
capped at 30 rendered frames per second. Drag horizontally to rotate; a mouse
can also tilt vertically. Touch preserves vertical page scrolling. Arrow keys
rotate/tilt, Home shows the front and End shows the back. Keyboard interaction
pauses automatic turning. The Pause/Rotate button controls autoplay.
Reduced motion disables autoplay and hides its control while preserving manual
rotation. Hidden tabs and offscreen models stop scheduling frames. Model changes
reuse the renderer/environment and dispose the previous geometry, textures and
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
windows can scroll normally rather than collapsing the model to zero height.
## Marketplace follow-up
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.
Browser scenarios and reports stay outside this repository, under
`/home/yaya/.local/share/browser-check/projects/archy-devices/`.
## Validation (2026-10-09)
- Production typecheck/build passes; the lazy renderer chunk is 136 KB gzipped.
- Existing gateway/cache tests (10) and radio setup tests (2) pass.
- Chromium 153, Firefox 155 and WebKit 26.6 pass at desktop 1440×900 and
phone 390×844: all 26 models, front/back rotation, drag, autoplay/pause,
reduced motion, zero offscreen/idle draws, model replacement, unmount/remount
and context-loss fallback. Reports: `/tmp/archy-models-final/`.
- Actual gateway and detection-modal components pass the same six-case matrix
with mocked RPC data. Reports: `/tmp/archy-device-pages-matrix/`.
- Delayed renderer loading, storage denial, reload and unavailable WebGL pass
the same matrix. Reports: `/tmp/archy-device-fallbacks/`.
- These are Linux browser tests with phone viewport emulation, not physical
iPhone/Android GPU or touch-scroll certification. No live router was changed.
Branch: `feat/3d-mesh-router`, based on ngit main
`5812f53c7c05027848a1007f818e9d33117b62e0`. The ngit helper could read relay
state but failed Git server authentication; main's published kind-30618 ref
was checked on relay.ngit.dev and that exact commit fetched from the existing
Git mirror. No remote proposal, merge or deployment was performed.
+65
View File
@@ -22,6 +22,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
"three": "^0.185.0",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -32,6 +33,7 @@
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4",
@@ -1839,6 +1841,13 @@
"node": ">=18"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -3590,6 +3599,13 @@
"node": ">=12"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -3958,12 +3974,41 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.0.tgz",
"integrity": "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vite-pwa/assets-generator": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@vite-pwa/assets-generator/-/assets-generator-1.0.2.tgz",
@@ -6872,6 +6917,13 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -8496,6 +8548,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -10939,6 +10998,12 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.185.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.0.tgz",
"integrity": "sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2
View File
@@ -39,6 +39,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
"three": "^0.185.0",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -49,6 +50,7 @@
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4",
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { createDeviceScene } from '@/graphics/deviceScene'
const props = withDefaults(defineProps<{
model: string
label: string
fallback?: string
fillViewport?: boolean
}>(), { fallback: '', fillViewport: false })
const host = ref<HTMLElement>()
const frame = ref<HTMLElement>()
const ready = ref(false)
const failed = ref(false)
const paused = ref(false)
const reducedMotion = ref(false)
const dragging = ref(false)
const remainingHeight = ref('360px')
let view: ReturnType<typeof createDeviceScene> | undefined
let observer: IntersectionObserver | undefined
let resizeObserver: ResizeObserver | undefined
let motion: MediaQueryList | undefined
let visible = false
let mounted = false
let generation = 0
let raf = 0
let previous = 0
let lastDraw = 0
let pointer: number | undefined
let lastX = 0
let lastY = 0
function stop() { cancelAnimationFrame(raf); raf = 0; previous = 0; lastDraw = 0 }
function canSpin() { return view && visible && !document.hidden && !paused.value && !motion?.matches && !dragging.value }
function animate(now: number) {
raf = 0
if (!canSpin()) { previous = 0; return }
// Cap to 30fps; complete a turn in about a minute, independent of refresh rate.
if (now - lastDraw >= 32) {
view!.rotation.y += previous ? Math.min((now - previous) / 1000, .1) * .105 : 0
previous = now; lastDraw = now; view!.render()
}
raf = requestAnimationFrame(animate)
}
function sync() { reducedMotion.value = !!motion?.matches; stop(); if (canSpin()) raf = requestAnimationFrame(animate) }
function layout() {
if (props.fillViewport && frame.value) {
// Use the layout position, not its changing scrolled screen position.
let scroll = 0
for (let el = frame.value.parentElement; el; el = el.parentElement) scroll += el.scrollTop
const top = frame.value.getBoundingClientRect().top + scroll
remainingHeight.value = `${Math.max(260, window.innerHeight - top - 24)}px`
}
view?.resize()
}
function disposeScene() {
stop(); ready.value = false
view?.canvas.removeEventListener('webglcontextlost', contextLost)
view?.dispose(); view = undefined
}
function contextLost(event: Event) {
event.preventDefault(); failed.value = true; disposeScene()
}
async function init() {
if (!mounted || !visible || view || failed.value) return
const token = ++generation
try {
const { createDeviceScene } = await import('@/graphics/deviceScene')
if (!mounted || token !== generation || !host.value) return
view = createDeviceScene(host.value, props.model)
view.canvas.addEventListener('webglcontextlost', contextLost)
ready.value = true; layout(); sync()
} catch {
if (token === generation) { failed.value = true; disposeScene() }
}
}
function down(event: PointerEvent) {
if (!view || (event.pointerType === 'mouse' && event.button !== 0) || pointer !== undefined) return
pointer = event.pointerId; lastX = event.clientX; lastY = event.clientY
dragging.value = true; stop(); host.value?.setPointerCapture(pointer)
}
function move(event: PointerEvent) {
if (pointer !== event.pointerId || !view) return
view.rotation.y += (event.clientX - lastX) * .012
// Touch preserves vertical page scrolling; mouse can also tilt the model.
if (event.pointerType === 'mouse') view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.clientY - lastY) * .008))
lastX = event.clientX; lastY = event.clientY; view.render()
}
function up() { pointer = undefined; dragging.value = false; sync() }
function key(event: KeyboardEvent) {
if (!view || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault(); paused.value = true; stop()
if (event.key === 'Home') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, -.4, 0)
else if (event.key === 'End') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, Math.PI - .4, 0)
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') view.rotation.y += event.key === 'ArrowLeft' ? -.2 : .2
else view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.key === 'ArrowUp' ? -.12 : .12)))
view.render()
}
watch(paused, sync)
watch(() => props.model, () => {
generation++; up()
if (view) {
try { view.setModel(props.model) } catch { failed.value = true; disposeScene() }
} else { failed.value = false; void init() }
})
onMounted(() => {
mounted = true
if (!window.matchMedia || !window.IntersectionObserver || !window.ResizeObserver) {
failed.value = true
return
}
motion = window.matchMedia('(prefers-reduced-motion: reduce)')
motion.addEventListener('change', sync)
document.addEventListener('visibilitychange', sync)
window.addEventListener('resize', layout)
resizeObserver = new ResizeObserver(layout)
if (host.value) resizeObserver.observe(host.value)
if (frame.value?.parentElement) resizeObserver.observe(frame.value.parentElement)
observer = new IntersectionObserver(entries => {
visible = entries.some(entry => entry.isIntersecting)
if (visible) void init()
sync()
})
if (frame.value) observer.observe(frame.value)
layout()
})
onBeforeUnmount(() => {
mounted = false; generation++; observer?.disconnect(); resizeObserver?.disconnect()
motion?.removeEventListener('change', sync)
document.removeEventListener('visibilitychange', sync); window.removeEventListener('resize', layout)
disposeScene()
})
</script>
<template>
<figure ref="frame" class="device-model" :class="{ 'device-model--viewport': fillViewport }"
:style="{ '--device-height': remainingHeight }" :data-model="model" :data-ready="ready">
<div ref="host" class="device-model__stage" :class="{ 'is-dragging': dragging }"
:tabindex="ready ? 0 : -1" role="group" :aria-label="`${label}. Drag or use arrow keys to rotate. Home shows the front; End shows the back.`"
@pointerdown="down" @pointermove="move" @pointerup="up" @pointercancel="up" @lostpointercapture="up" @keydown="key">
<img v-if="!ready && fallback" :src="fallback" :alt="label" class="device-model__fallback" />
<div v-else-if="!ready" class="device-model__placeholder" role="img" :aria-label="label">
<svg viewBox="0 0 200 120" aria-hidden="true"><rect x="31" y="59" width="138" height="46" rx="15"/><path d="M38 65V17Q38 9 45 9T52 17V60M148 60V17Q148 9 155 9T162 17V65M92 87h16"/></svg>
</div>
</div>
<figcaption class="device-model__caption">
<span>{{ failed ? label : 'Drag to explore' }}</span>
<button v-if="ready && !reducedMotion" type="button" :aria-label="paused ? 'Resume automatic rotation' : 'Pause automatic rotation'"
:aria-pressed="paused" @click="paused = !paused">{{ paused ? 'Rotate' : 'Pause' }}</button>
</figcaption>
</figure>
</template>
<style scoped>
.device-model { position: relative; width: 100%; height: 240px; margin: 0; isolation: isolate; }
.device-model::before { content: ''; position: absolute; inset: 14% 9%; background: radial-gradient(ellipse, rgba(110,167,180,.09), transparent 68%); pointer-events: none; z-index: -1; }
.device-model__stage { position: absolute; inset: 0 0 28px; cursor: grab; touch-action: pan-y; border-radius: 16px; }
.device-model__stage.is-dragging { cursor: grabbing; }
.device-model__stage:focus-visible { outline: 1px solid rgba(255,255,255,.5); outline-offset: 2px; }
.device-model__stage :deep(canvas) { display: block; width: 100%; height: 100%; }
.device-model__fallback { width: 100%; height: 100%; object-fit: contain; padding: 22px; }
.device-model__placeholder { height: 100%; display: grid; place-items: center; }
.device-model__placeholder svg { width: min(65%, 280px); fill: rgba(63,126,146,.2); stroke: #619ba9; stroke-width: 2; }
.device-model__caption { position: absolute; inset: auto 0 0; display: flex; justify-content: center; align-items: center; gap: 14px; color: rgba(255,255,255,.4); font-size: 11px; }
.device-model__caption button { padding: 4px 9px; color: rgba(255,255,255,.65); border: 1px solid rgba(255,255,255,.12); border-radius: 20px; background: transparent; cursor: pointer; }
.device-model--viewport { height: 330px; }
@media (min-width: 1024px) { .device-model--viewport { height: var(--device-height); min-height: 260px; } }
</style>
@@ -8,25 +8,11 @@
>
<!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-40 h-40 relative">
<!-- pulsing signal waves behind the board image -->
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
<path d="M130 44 a30 30 0 0 1 0 28" />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<DeviceModel
:model="deviceImage.image.split('/').pop()!.replace('.svg', '')"
:label="deviceImage.label"
:fallback="deviceImage.image"
/>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
@@ -306,6 +292,7 @@ import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type Flas
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
import DeviceModel from '@/components/DeviceModel.vue'
const mesh = useMeshStore()
const appStore = useAppStore()
@@ -344,7 +331,6 @@ function stopProbeProgress(done = false) {
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
@@ -417,7 +403,6 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = ''
probing.value = false
stopProbeProgress()
imageFailed.value = false
openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
@@ -427,7 +412,6 @@ watch([show, devicePath], async ([visible]) => {
}
step.value = 1
error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
@@ -629,17 +613,3 @@ function closeFlashStep() {
step.value = 1
}
</script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>
+311
View File
@@ -0,0 +1,311 @@
import * as THREE from 'three'
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
// Original, procedural display models. Dimensions/layouts follow the existing
// device illustrations and manufacturer references listed in device-models.md.
// These are recognition aids, not mechanical CAD or revision-specific pinouts.
type Kind = 'board' | 'echo' | 'deck' | 'card' | 'case' | 'router'
interface DeviceSpec {
kind: Kind; name: string; w: number; h: number; d: number; color: number
screen?: 'oled' | 'paper'; antenna?: boolean; battery?: boolean; pins?: number
}
export const deviceSpecs: Record<string, DeviceSpec> = {
'heltec-v3': { kind: 'board', name: 'HELTEC V3', w: 2.9, h: 1.25, d: .07, color: 0xe8e7df, screen: 'oled', pins: 18 },
'heltec_v4': { kind: 'board', name: 'HELTEC V4', w: 2.85, h: 1.2, d: .07, color: 0x181d20, screen: 'oled', pins: 18 },
'heltec-wsl-v3': { kind: 'board', name: 'HELTEC WSL', w: 2.85, h: 1, d: .07, color: 0x182225, pins: 18 },
'heltec-mesh-node-t114': { kind: 'board', name: 'HELTEC T114', w: 2.65, h: 1.25, d: .08, color: 0xe6e3dc, screen: 'oled' },
'tlora-t3s3-v1': { kind: 'board', name: 'LILYGO T3-S3', w: 2.8, h: 1.25, d: .07, color: 0x1b2024, screen: 'oled', antenna: true, pins: 16 },
'tlora-v2-1-1_6': { kind: 'board', name: 'LILYGO T-LoRa', w: 2.8, h: 1.2, d: .07, color: 0x25272a, screen: 'oled', antenna: true, pins: 16 },
'tbeam': { kind: 'board', name: 'LILYGO T-Beam', w: 3.7, h: 1.2, d: .08, color: 0x242a2d, screen: 'oled', antenna: true, battery: true, pins: 16 },
'tbeam-s3-core': { kind: 'board', name: 'T-Beam S3', w: 3.35, h: 1.05, d: .08, color: 0x22272a, screen: 'oled', antenna: true, battery: true },
'rak4631': { kind: 'board', name: 'RAK4631', w: 1.4, h: 2.8, d: .08, color: 0x202629, pins: 12 },
'seeed-xiao-s3': { kind: 'board', name: 'XIAO ESP32S3', w: 1.25, h: 1.5, d: .08, color: 0x152c28, pins: 7 },
'wio-tracker-wm1110': { kind: 'board', name: 'Wio WM1110', w: 1.85, h: 2.7, d: .08, color: 0xb32130, pins: 12 },
'rpipicow': { kind: 'board', name: 'Raspberry Pi Pico', w: 3.1, h: 1.2, d: .07, color: 0x16754d, pins: 20 },
'diy': { kind: 'board', name: 'RNode', w: 2.8, h: 1.3, d: .07, color: 0x24272b, pins: 19 },
't-deck': { kind: 'deck', name: 'LILYGO T-Deck', w: 2, h: 3.2, d: .28, color: 0x25262b, screen: 'oled' },
't-echo': { kind: 'echo', name: 'LILYGO', w: 1.7, h: 1.9, d: .5, color: 0xe3e4dc, screen: 'paper', antenna: true },
't-echo_plus': { kind: 'echo', name: 'LILYGO', w: 1.9, h: 2.15, d: .65, color: 0x343839, screen: 'paper', antenna: true },
'tracker-t1000-e': { kind: 'card', name: 'SenseCAP T1000-E', w: 1.8, h: 2.9, d: .17, color: 0xc6c9c5 },
'rak-wismeshtap': { kind: 'case', name: 'WisMesh Tap', w: 2.2, h: 1.7, d: .52, color: 0x33363a, screen: 'oled', antenna: true },
'station-g2': { kind: 'case', name: 'Station G2', w: 1.7, h: 2.85, d: .65, color: 0x32363b, screen: 'oled' },
'nano-g2-ultra': { kind: 'case', name: 'Nano G2 Ultra', w: 1.7, h: 2.8, d: .5, color: 0x36383d, screen: 'oled' },
'thinknode_m1': { kind: 'case', name: 'ThinkNode M1', w: 1.7, h: 2.3, d: .65, color: 0x252c2c, screen: 'paper', antenna: true },
'heltec-v3-case': { kind: 'case', name: 'HELTEC V3', w: 2.8, h: 1.25, d: .48, color: 0x13857e, screen: 'oled', antenna: true },
'heltec-mesh-node-t114-case': { kind: 'case', name: 'HELTEC T114', w: 1.25, h: 2.4, d: .5, color: 0x10a9c4, 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' },
'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x297b8f },
}
export function createDeviceModel(id: string) {
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[] = []
const material = (color: number, roughness = .5, metalness = .1) => {
const m = new THREE.MeshStandardMaterial({ color, roughness, metalness })
materials.push(m); return m
}
const body = material(spec.color, .43)
const black = material(0x10151a, .58)
const dark = material(0x03070a, .72)
const silver = material(0x9ca9af, .3, .86)
const gold = material(0xc9a454, .3, .82)
const white = material(0xdce2de, .66)
const pcb = material(0x193a30, .6)
const glass = material(0x091923, .17, .26)
const accent = material(0x76e6bf, .35)
accent.emissive.set(0x1b704a); accent.emissiveIntensity = .5
const grainCanvas = document.createElement('canvas'); grainCanvas.width = grainCanvas.height = 64
const grainContext = grainCanvas.getContext('2d')!
const pixels = grainContext.createImageData(64, 64)
let seed = 173
for (let i = 0; i < pixels.data.length; i += 4) {
seed = (seed * 1664525 + 1013904223) >>> 0
const value = 200 + seed % 45; pixels.data.set([value, value, value, 255], i)
}
grainContext.putImageData(pixels, 0, 0)
const grain = new THREE.CanvasTexture(grainCanvas)
grain.wrapS = grain.wrapT = THREE.RepeatWrapping; grain.repeat.set(7, 7); textures.push(grain)
body.roughnessMap = grain; body.bumpMap = grain; body.bumpScale = .0018
const geometry = (key: string, build: () => THREE.BufferGeometry) => {
if (!geometries.has(key)) geometries.set(key, build())
return geometries.get(key)!
}
function box(w: number, h: number, d: number, x: number, y: number, z: number, m = body, radius = .025, parent = root) {
const r = Math.min(radius, w / 3, h / 3, d / 3)
const g = geometry(`b${w},${h},${d},${r}`, () => new RoundedBoxGeometry(w, h, d, 2, r))
const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); parent.add(mesh); return mesh
}
function cylinder(r: number, length: number, x: number, y: number, z: number, m = silver, parent = root) {
const g = geometry(`c${r},${length}`, () => new THREE.CylinderGeometry(r, r, length, 20))
const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); parent.add(mesh); return mesh
}
function disk(r: number, x: number, y: number, z: number, m = silver) {
const mesh = cylinder(r, .018, x, y, z, m); mesh.rotation.x = Math.PI / 2; return mesh
}
function label(text: string, w: number, h: number, x: number, y: number, z: number, back = false, color = '#dbe4e3') {
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 96
const ctx = canvas.getContext('2d')!
ctx.fillStyle = color; ctx.font = '500 40px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
ctx.fillText(text, 256, 48, 500)
const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; textures.push(texture)
const m = new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false }); materials.push(m)
const mesh = new THREE.Mesh(geometry(`p${w},${h}`, () => new THREE.PlaneGeometry(w, h)), m)
mesh.position.set(x, y, z); if (back) mesh.rotation.y = Math.PI; root.add(mesh); return mesh
}
function screws(w: number, h: number, z: number) {
for (const x of [-w / 2 + .12, w / 2 - .12]) for (const y of [-h / 2 + .12, h / 2 - .12]) {
disk(.055, x, y, z, silver)
box(.058, .012, .012, x, y, z + Math.sign(z) * .013, dark, .001)
}
}
function antenna(x: number, y: number, length = 1.3) {
cylinder(.085, .2, x, y + .1, 0, gold)
cylinder(.1, .18, x, y + .24, 0, black)
cylinder(.068, length, x, y + .3 + length / 2, 0, black)
for (let i = 0; i < 3; i++) cylinder(.074, .02, x, y + length + .1 + i * .05, 0, body)
}
function screen(w: number, h: number, x: number, y: number, z: number, paper = false) {
box(w + .12, h + .12, .065, x, y, z, black)
box(w, h, .02, x, y, z + .038, paper ? white : glass, .015)
const ink = paper ? '#263834' : '#8ce4d5'
label('MESH', w * .65, h * .28, x, y + h * .14, z + .052, false, ink)
for (let i = 0; i < 4; i++) box(.028, .045 + .035 * i, .008, x + w * .3 + i * .045, y - h * .32, z + .053, paper ? black : accent, .002)
label('LoRa', w * .48, h * .18, x - w * .08, y - h * .2, z + .054, false, ink)
}
function usb(x: number, y: number, z: number, back = false) {
box(.28, .115, .14, x, y, z, silver)
box(.22, .063, .015, x, y, z + (back ? -.077 : .077), dark)
box(.15, .015, .018, x, y, z + (back ? -.084 : .084), black)
}
const { w, h, d } = spec
if (spec.kind === 'router') {
// Beryl AX: two hinged paddles, rear USB-C / WAN / LAN / vertical USB-A.
box(w, h, d, 0, 0, 0, body, .25)
for (const z of [-1.023, 1.023]) box(2.56, .013, .008, 0, -.12, z, black, .002)
label('GL.iNet', .62, .14, 0, .423, .08, false, '#d5e9ed').rotation.x = -Math.PI / 2
box(.22, .032, .022, 0, -.115, d / 2 + .012, white)
for (const side of [-1, 1]) {
const hinge = cylinder(.16, .12, side * 1.57, -.04, .36, body); hinge.rotation.z = Math.PI / 2
const paddle = new THREE.Group(); paddle.position.set(side * 1.62, 0, .36); paddle.rotation.z = side * -.1; root.add(paddle)
box(.27, 1.93, .19, 0, .86, 0, body, .085, paddle)
box(.18, 1.65, .015, 0, .88, .102, body, .005, paddle)
for (let i = 0; i < 24; i++) box(.014, .37, .028, side * 1.497, -.035, -.85 + i * .069, dark, .003)
for (const z of [-.72, .72]) {
cylinder(.14, .08, side * 1.12, -.43, z, black)
for (let i = 0; i < 14; i++) box(.038, .012, .45, side * (.35 + i * .055), -.405, z * .6, dark, .002)
}
}
box(2.46, .55, .022, 0, -.015, -1.031, body, .008)
usb(1, .04, -1.035, true)
label('POWER', .3, .075, 1, .2, -1.057, true)
for (const [x, text] of [[.38, '2.5G WAN'], [-.27, 'LAN']] as const) {
box(.51, .4, .03, x, -.025, -1.055, silver)
box(.45, .32, .03, x, -.025, -1.074, dark)
box(.22, .06, .04, x, -.19, -1.08, dark)
for (let i = 0; i < 8; i++) box(.019, .08, .014, x - .15 + i * .043, .062, -1.095, gold, .001)
label(text, .53, .075, x, .24, -1.06, true)
}
box(.22, .4, .035, -.93, -.04, -1.06, silver)
box(.145, .32, .02, -.93, -.04, -1.086, dark)
box(.038, .26, .022, -.92, -.04, -1.1, material(0x287ecc))
label('USB 3.0', .42, .075, -.93, .24, -1.06, true)
box(.018, .085, .18, -1.508, .14, -.5, black)
box(.02, .05, .05, -1.51, .14, -.77, dark)
const underside = label('GL-MT3000 / BERYL AX', 1.25, .15, 0, -.413, 0)
underside.rotation.x = Math.PI / 2
} else if (spec.kind === 'board') {
box(w, h, d, 0, 0, 0, body, .06)
// Plated through holes, copper traces, solder joints and populated reverse.
const horizontal = w > h; const count = spec.pins ?? 10
for (const side of [-1, 1]) for (let i = 0; i < count; i++) {
const a = ((i + .5) / count - .5) * ((horizontal ? w : h) - .28)
const x = horizontal ? a : side * (w / 2 - .08)
const y = horizontal ? side * (h / 2 - .08) : a
for (const face of [-1, 1]) {
disk(.037, x, y, face * (d / 2 + .008), gold)
disk(.018, x, y, face * (d / 2 + .021), dark)
}
}
const heltec = id.startsWith('heltec')
const shieldX = spec.screen ? w * .28 : w * .05
if (!heltec || !spec.screen) {
box(w * .25, h * .43, .11, shieldX, .05, .095, silver)
label(spec.name.split(' ')[0]!, w * .23, .08, shieldX, .06, .158, false, '#354047')
} else {
box(w * .32, h * .43, .11, shieldX, .05, -.12, silver)
label('ESP32 · LoRa', w * .29, .08, shieldX, .06, -.18, true, '#354047')
}
for (let i = 0; i < 12; i++) {
const x = -w * .38 + (i % 6) * w * .14, y = -h * .28 + Math.floor(i / 6) * h * .56
box(.095, .043, .035, x, y, .065, i % 3 ? black : silver, .003)
box(.15, .023, .008, x, y, -.05, gold, .002)
box(.022, .18, .008, x, y + .07, -.047, pcb, .001)
}
box(w * .28, h * .34, .055, -w * .15, 0, -.08, black)
label(spec.name, w * .65, .12, 0, -h * .31, -.114, true)
// SMD passives with metal end caps, short traces and silkscreen outlines.
for (const face of [-1, 1]) for (let i = 0; i < 26; i++) {
const x = -w * .4 + (i % 13) * w * .065, y = (i < 13 ? -.33 : .33) * h
box(.065, .029, .021, x, y, face * .061, i % 4 ? black : material(0x8b7856), .002)
for (const end of [-1, 1]) box(.013, .032, .024, x + end * .032, y, face * .063, silver, .001)
box(.09, .007, .004, x, y + .03, face * .043, white, .001)
}
if (spec.screen) screen(w * (heltec ? .52 : .36), h * .51, w * (heltec ? .15 : -.15), 0, .145)
else {
box(w * .23, h * .28, .055, -w * .26, .03, .1, black)
for (let i = 0; i < 6; i++) box(.028, h * .32, .015, -w * .36 + i * .04, .03, .085, silver, .001)
}
// USB connector faces outward at the short edge.
const port = new THREE.Group(); root.add(port)
box(.29, .17, .14, 0, 0, 0, silver, .02, port)
box(.22, .11, .015, 0, 0, .078, dark, .018, port)
if (horizontal) { port.position.set((heltec ? -1 : 1) * (w / 2 + .025), 0, .12); port.rotation.y = (heltec ? -1 : 1) * Math.PI / 2 }
else { port.position.set(0, h / 2 + .025, .12); port.rotation.x = -Math.PI / 2 }
disk(.055, -w * .4, .12, .076, gold)
box(.105, .105, .055, -w * .4, -.14, .09, silver)
box(.052, .052, .024, -w * .4, -.14, .13, black)
if (spec.antenna) antenna(w * .27, h / 2, 1.25)
if (spec.battery) {
box(w * .82, .85, .5, 0, 0, -.31, black, .05)
const cell = cylinder(.34, w * .7, 0, 0, -.52, material(0x8461a5)); cell.rotation.z = Math.PI / 2
for (const x of [-w * .36, w * .36]) { const end = cylinder(.29, .05, x, 0, -.52); end.rotation.z = Math.PI / 2 }
}
if (id === 'wio-tracker-wm1110') for (const x of [-.66, .66]) for (let i = 0; i < 4; i++) box(.21, .2, .15, x, -.87 + i * .52, .1, white)
if (id === 'rak4631') {
box(1.08, .96, .035, 0, .45, .18, material(0x187b65))
box(.86, .73, .04, 0, .45, .22, silver)
label('RAK', .52, .19, 0, .56, .246, false, '#126d95')
label('4631', .45, .1, 0, .33, .247, false, '#354047')
for (const x of [-.43, 0, .43]) box(.21, .52, .06, x, -.74, .12, black)
}
} else {
box(w, h, d, 0, 0, 0, body, .17)
// A recessed rear panel preserves the rounded enclosure silhouette.
box(w * .88, h * .91, .025, 0, 0, -d / 2 - .012, body, .008)
screws(w * .94, h * .95, -d / 2 - .031)
label(spec.name, w * .66, .11, 0, -.2, -d / 2 - .045, true)
label('LoRa · wireless', w * .52, .07, 0, -.34, -d / 2 - .045, true)
if (spec.kind === 'deck') {
screen(w * .79, h * .38, 0, h * .19, d / 2 + .015)
const keys = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM']
keys.forEach((row, r) => [...row].forEach((key, i) => {
const x = (i - (row.length - 1) / 2) * .165, y = -.55 - r * .19
box(.146, .15, .07, x, y, d / 2 + .03, black, .02)
label(key, .065, .06, x, y, d / 2 + .069)
}))
box(.63, .13, .065, 0, -1.12, d / 2 + .03, black)
disk(.11, 0, -.28, d / 2 + .04, silver)
disk(.072, 0, -.28, d / 2 + .056, white)
label('LILYGO', .48, .09, 0, 1.47, d / 2 + .02)
usb(0, -h / 2 + .05, -.015)
// Exposed rear PCB on the original T-Deck development board.
box(w * .85, h * .83, .045, 0, 0, -d / 2 - .05, pcb)
box(.7, .65, .1, .35, .7, -d / 2 - .11, silver)
label('ESP32-S3', .55, .08, .35, .7, -d / 2 - .165, true, '#354047')
box(1.35, 1.1, .06, 0, -.65, -d / 2 - .09, black)
for (let i = 0; i < 8; i++) box(.07, .12, .04, -.65 + i * .18, .04, -d / 2 - .09, gold)
} else if (spec.kind === 'echo') {
box(w * .49, .42, d * .85, w * .25, h / 2 + .12, 0, body, .08)
label('LILYGO', w * .37, .13, w * .25, h / 2 + .14, d * .43, false, '#67b6d8')
screen(w * .57, h * .57, 0, -.05, d / 2 + .017, true)
screws(w, h, d / 2 + .024)
antenna(w * .25, h / 2 + .33, 1.15)
for (const y of [-.3, 0, .3]) box(.05, .14, .17, -w / 2 - .02, y, 0, white)
usb(0, -h / 2 + .1, -d / 2, true)
if (id === 't-echo_plus') {
const loop = new THREE.Mesh(geometry('loop', () => new THREE.TorusGeometry(.31, .065, 10, 28, Math.PI)), silver)
loop.position.set(-.25, h / 2 + .22, -.08); root.add(loop)
}
} else if (spec.kind === 'card') {
disk(.31, 0, -.3, d / 2 + .015, gold)
disk(.24, 0, -.3, d / 2 + .029, body)
disk(.06, 0, .92, d / 2 + .018, dark)
label('SenseCAP', 1.03, .17, 0, .35, d / 2 + .021, false, '#59645f')
box(.46, .085, .025, 0, h / 2 - .12, d / 2 + .008, dark)
for (let i = 0; i < 4; i++) disk(.045, -.22 + i * .145, -.95, -d / 2 - .047, gold)
} else {
if (spec.screen) screen(w * .6, Math.min(h * .3, .7), 0, h * -.04, d / 2 + .025, spec.screen === 'paper')
label(spec.name, w * .72, .13, 0, -h * .35, d / 2 + .025)
screws(w, h, d / 2 + .016)
for (let i = 0; i < 7; i++) box(.045, h * .16, .013, (i - 3) * w * .08, h * .3, d / 2 + .012, dark, .004)
usb(0, -h / 2 + .09, -d / 2, true)
if (spec.antenna) antenna(w * .25, h / 2, 1.05)
if (id === 'unknown-new') {
for (const x of [-.48, .48]) disk(.12, x, -.85, d / 2 + .02, accent)
}
}
}
// Batch repeated opaque hardware. Every device owns/disposes its resources.
function batch(parent: THREE.Object3D) {
const groups = new Map<string, THREE.Mesh[]>()
for (const child of [...parent.children]) {
if (child.children.length) batch(child)
if (!(child instanceof THREE.Mesh) || Array.isArray(child.material) || child.material.transparent) continue
const key = child.geometry.uuid + child.material.uuid
const list = groups.get(key) ?? []; list.push(child); groups.set(key, list)
}
for (const meshes of groups.values()) if (meshes.length > 1) {
const first = meshes[0]!
const instance = new THREE.InstancedMesh(first.geometry, first.material, meshes.length)
meshes.forEach((mesh, i) => { mesh.updateMatrix(); instance.setMatrixAt(i, mesh.matrix); parent.remove(mesh) })
parent.add(instance)
}
}
batch(root)
const bounds = new THREE.Box3().setFromObject(root)
root.position.sub(bounds.getCenter(new THREE.Vector3()))
const pivot = new THREE.Group(); pivot.add(root)
const radius = bounds.getBoundingSphere(new THREE.Sphere()).radius
root.traverse(object => { object.updateMatrix(); object.matrixAutoUpdate = false })
return {
pivot, radius, name: spec.name,
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())
},
}
}
+58
View File
@@ -0,0 +1,58 @@
import * as THREE from 'three'
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
import { createDeviceModel } from './deviceModels'
export function createDeviceScene(host: HTMLElement, id: string) {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, powerPreference: 'low-power' })
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75))
renderer.setClearColor(0x000000, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1
renderer.domElement.setAttribute('aria-hidden', 'true')
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(33, 1, .1, 100)
let model = createDeviceModel(id)
scene.add(model.pivot)
model.pivot.rotation.set(id === 'beryl-ax' ? .27 : -.08, -.4, 0)
const studio = new RoomEnvironment()
const pmrem = new THREE.PMREMGenerator(renderer)
const environment = pmrem.fromScene(studio, .04, .1, 100, { size: 128 })
scene.environment = environment.texture; scene.environmentIntensity = .8
studio.dispose(); pmrem.dispose()
scene.add(new THREE.HemisphereLight(0xe4eeff, 0x202b3d, 1.4))
for (const [color, intensity, x, y, z] of [
[0xffffff, 2.2, 3, 5, 6], [0x91c6e8, 1.8, -4, 1, -3], [0xe8c5ac, 1.4, 4, 0, -4],
]) {
const light = new THREE.DirectionalLight(color, intensity)
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
const { width, height } = host.getBoundingClientRect()
if (!width || !height) return
renderer.setSize(width, height, false)
camera.aspect = width / height
const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
camera.position.set(0, 0, model.radius / Math.sin(halfFov) * 1.09)
camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); render()
}
resize()
return {
canvas: renderer.domElement, get rotation() { return model.pivot.rotation }, render, resize,
setModel(next: string) {
const replacement = createDeviceModel(next)
scene.remove(model.pivot); model.dispose(); model = replacement
model.pivot.rotation.set(next === 'beryl-ax' ? .27 : -.08, -.4, 0)
scene.add(model.pivot); resize()
},
dispose() {
if (disposed) return
disposed = true; model.dispose(); environment.dispose(); renderer.dispose()
renderer.forceContextLoss(); renderer.domElement.remove()
},
}
}
+6 -1
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue'
import DeviceModel from '@/components/DeviceModel.vue'
const router = useRouter()
function goBack() {
@@ -465,7 +466,8 @@ onMounted(() => {
</div>
<!-- Connect form -->
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
<template v-if="showConnectForm">
<div class="glass-card p-6 mb-6">
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
<div class="space-y-3">
<div>
@@ -530,6 +532,9 @@ onMounted(() => {
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
</div>
<DeviceModel model="beryl-ax" label="Beryl AX (GL-MT3000) OpenWrt router" fill-viewport />
</template>
<!-- Loading skeleton -->
<template v-else-if="loading">
<div v-for="i in 3" :key="i" class="glass-card p-6 mb-4 animate-pulse">