feat(ui): add dev model browser and soften hardware lighting

This commit is contained in:
yaya
2026-10-09 13:26:41 +01:00
parent 5d64991203
commit a6725c1b69
34 changed files with 77 additions and 4474 deletions
+25 -2
View File
@@ -1,19 +1,25 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { createDeviceScene } from '@/graphics/deviceScene'
import { deviceSpecs } from '@/graphics/deviceModels'
const props = withDefaults(defineProps<{
model: string
label: string
fallback?: string
fillViewport?: boolean
}>(), { fallback: '', fillViewport: false })
/** Development-only gallery controls. Never rendered in production builds. */
devBrowse?: boolean
}>(), { fallback: '', fillViewport: false, devBrowse: 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 currentModel = ref(props.model)
const devModelIds = Object.keys(deviceSpecs)
const devBrowseEnabled = import.meta.env.DEV && props.devBrowse
const dragging = ref(false)
const remainingHeight = ref('360px')
let view: ReturnType<typeof createDeviceScene> | undefined
@@ -30,6 +36,16 @@ let pointer: number | undefined
let lastX = 0
let lastY = 0
function devModelLabel(id: string) { return deviceSpecs[id]?.name ?? id }
function browseModel(offset: number) {
if (!devBrowseEnabled || !view) return
const index = devModelIds.indexOf(currentModel.value)
const next = devModelIds[(index + offset + devModelIds.length) % devModelIds.length]!
currentModel.value = next
view.setModel(next)
view.render()
}
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) {
@@ -99,6 +115,7 @@ function key(event: KeyboardEvent) {
watch(paused, sync)
watch(() => props.model, () => {
generation++; up()
currentModel.value = props.model
if (view) {
try { view.setModel(props.model) } catch { failed.value = true; disposeScene() }
} else { failed.value = false; void init() }
@@ -144,7 +161,12 @@ onBeforeUnmount(() => {
</div>
</div>
<figcaption class="device-model__caption">
<span>{{ failed ? label : 'Drag to explore' }}</span>
<span>{{ failed ? label : (devBrowseEnabled ? devModelLabel(currentModel) : 'Drag to explore') }}</span>
<template v-if="devBrowseEnabled">
<button type="button" aria-label="Previous 3D model" @click="browseModel(-1)">Back</button>
<span class="device-model__counter">{{ devModelIds.indexOf(currentModel) + 1 }} / {{ devModelIds.length }}</span>
<button type="button" aria-label="Next 3D model" @click="browseModel(1)">Next</button>
</template>
<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>
@@ -163,6 +185,7 @@ onBeforeUnmount(() => {
.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__counter { color: rgba(255,255,255,.3); font-variant-numeric: tabular-nums; }
.device-model--viewport { height: 330px; }
@media (min-width: 1024px) { .device-model--viewport { height: var(--device-height); min-height: 260px; } }
</style>
@@ -9,9 +9,9 @@
<!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center">
<DeviceModel
:model="deviceImage.image.split('/').pop()!.replace('.svg', '')"
:model="deviceImage.model"
:label="deviceImage.label"
:fallback="deviceImage.image"
dev-browse
/>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
+1 -1
View File
@@ -35,7 +35,7 @@ export const deviceSpecs: Record<string, DeviceSpec> = {
'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 },
'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x171b20 },
}
export function createDeviceModel(id: string) {
+4 -4
View File
@@ -8,7 +8,7 @@ export function createDeviceScene(host: HTMLElement, id: string) {
renderer.setClearColor(0x000000, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1
renderer.toneMappingExposure = .94
renderer.domElement.setAttribute('aria-hidden', 'true')
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(33, 1, .1, 100)
@@ -18,11 +18,11 @@ export function createDeviceScene(host: HTMLElement, id: string) {
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
scene.environment = environment.texture; scene.environmentIntensity = 1.05
studio.dispose(); pmrem.dispose()
scene.add(new THREE.HemisphereLight(0xe4eeff, 0x202b3d, 1.4))
scene.add(new THREE.HemisphereLight(0xe4eeff, 0x354052, 2.25))
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],
[0xffffff, 1.15, 3, 5, 6], [0x91c6e8, .85, -4, 1, -3], [0xe8c5ac, .65, 4, 0, -4],
]) {
const light = new THREE.DirectionalLight(color, intensity)
light.position.set(x!, y!, z!); scene.add(light)
+35 -38
View File
@@ -1,7 +1,6 @@
/**
* Best-effort mapping from a detected serial port's USB identity to a board
* image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0 —
* github.com/meshtastic/web-flasher, public/img/devices/).
* Best-effort mapping from a detected serial port's USB identity to an
* original procedural Three.js model.
*
* Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB
* product string, so those match precisely. UART-bridge boards only identify
@@ -18,58 +17,56 @@ export interface DetectedDeviceInfo {
}
export interface MeshDeviceImage {
/** Path under public/ */
image: string
/** Procedural model key in graphics/deviceModels.ts. */
model: string
/** Human name shown in the modal */
label: string
/** True when the match is exact (product string), false for chip-level guesses */
exact: boolean
}
const IMG = '/assets/img/mesh-devices'
/** product-string keyword → image (checked in order, case-insensitive) */
const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [
{ match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' },
{ match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' },
{ match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' },
{ match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' },
{ match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' },
{ match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' },
{ match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' },
{ match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' },
{ match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' },
{ match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' },
{ match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' },
{ match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' },
{ match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' },
{ match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' },
{ match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' },
{ match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' },
{ match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' },
{ match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' },
{ match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' },
{ match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' },
{ match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' },
const PRODUCT_MATCHES: Array<{ match: RegExp; model: string; label: string }> = [
{ match: /t-?deck/i, model: 't-deck', label: 'LILYGO T-Deck' },
{ match: /t-?echo\s*plus/i, model: 't-echo_plus', label: 'LILYGO T-Echo Plus' },
{ match: /t-?echo/i, model: 't-echo', label: 'LILYGO T-Echo' },
{ match: /t-?beam\s*s3/i, model: 'tbeam-s3-core', label: 'LILYGO T-Beam S3' },
{ match: /t-?beam/i, model: 'tbeam', label: 'LILYGO T-Beam' },
{ match: /t3.?s3/i, model: 'tlora-t3s3-v1', label: 'LILYGO T3-S3' },
{ match: /t-?lora|tlora/i, model: 'tlora-v2-1-1_6', label: 'LILYGO T-LoRa' },
{ match: /rak.?4631|wisblock/i, model: 'rak4631', label: 'RAK WisBlock 4631' },
{ match: /wismesh|wistap/i, model: 'rak-wismeshtap', label: 'RAK WisMesh Tap' },
{ match: /t1000|tracker.?t1000/i, model: 'tracker-t1000-e', label: 'Seeed Card Tracker T1000-E' },
{ match: /xiao/i, model: 'seeed-xiao-s3', label: 'Seeed XIAO S3' },
{ match: /wio.?tracker|wm1110/i, model: 'wio-tracker-wm1110', label: 'Seeed Wio Tracker' },
{ match: /station\s*g2/i, model: 'station-g2', label: 'B&Q Station G2' },
{ match: /nano\s*g2/i, model: 'nano-g2-ultra', label: 'B&Q Nano G2 Ultra' },
{ match: /t114/i, model: 'heltec-mesh-node-t114', label: 'Heltec Mesh Node T114' },
{ match: /wireless\s*stick|wsl/i, model: 'heltec-wsl-v3', label: 'Heltec Wireless Stick Lite V3' },
{ match: /heltec.*v4/i, model: 'heltec_v4', label: 'Heltec V4' },
{ match: /heltec/i, model: 'heltec-v3', label: 'Heltec LoRa 32 V3' },
{ match: /thinknode/i, model: 'thinknode_m1', label: 'Elecrow ThinkNode' },
{ match: /pico/i, model: 'rpipicow', label: 'Raspberry Pi Pico' },
{ match: /rnode/i, model: 'diy', label: 'RNode' },
]
/** vid:pid → most-likely board (bridge chips = chip-level guess) */
const VIDPID_MATCHES: Record<string, { image: string; label: string; exact: boolean }> = {
const VIDPID_MATCHES: Record<string, { model: string; label: string; exact: boolean }> = {
// Silicon Labs CP210x — Heltec V3 family ships this bridge
'10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false },
'10c4:ea60': { model: 'heltec-v3', label: 'LoRa radio (CP2102 serial)', exact: false },
// WCH CH340 — most T-Beam / T-LoRa boards
'1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false },
'1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false },
'1a86:7523': { model: 'tbeam', label: 'LoRa radio (CH340 serial)', exact: false },
'1a86:55d4': { model: 'tbeam', label: 'LoRa radio (CH9102 serial)', exact: false },
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
'303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false },
'303a:1001': { model: 'tlora-t3s3-v1', label: 'ESP32-S3 LoRa board', exact: false },
// RAK4631 nRF52 native USB
'239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true },
'239a:8029': { model: 'rak4631', label: 'RAK WisBlock 4631', exact: true },
// Raspberry Pi (Pico W)
'2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false },
'2e8a:0005': { model: 'rpipicow', label: 'Raspberry Pi Pico', exact: false },
}
const FALLBACK: MeshDeviceImage = {
image: `${IMG}/unknown-new.svg`,
model: 'unknown-new',
label: 'LoRa mesh radio',
exact: false,
}
@@ -79,7 +76,7 @@ export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): Me
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
if (product) {
for (const m of PRODUCT_MATCHES) {
if (m.match.test(product)) return { image: m.image, label: m.label, exact: true }
if (m.match.test(product)) return { model: m.model, label: m.label, exact: true }
}
}
if (info.vid && info.pid) {
+3 -1
View File
@@ -532,7 +532,8 @@ 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 />
<DeviceModel model="beryl-ax" label="Beryl AX (GL-MT3000) OpenWrt router" fill-viewport dev-browse />
</template>
<!-- Loading skeleton -->
@@ -1009,5 +1010,6 @@ onMounted(() => {
</div>
</template>
</div>
</template>