feat(ui): add interactive 3D LoRa devices and OpenWrt router
This commit is contained in:
@@ -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.
|
||||
Generated
+65
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user