Commit 95d68bd2 by James Yuan

feat: 朝下聚光灯提亮、夜间清空气,并支持模拟器 exe 打包

关自动曝光后去掉浓雾与填充点光,仅用朝下 spotlight 并大幅提高亮度;
同步屏幕标题到 document.title,并为 Modbus 模拟器补充 PyInstaller 构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 86a44534
...@@ -12,7 +12,31 @@ ...@@ -12,7 +12,31 @@
支持主站写:`FC05`(线圈)、`FC06`(单寄存器)、`FC16`(多寄存器)。照明 `KA*` / `开关设定` 写入后会 sticky 保持,不被自动刷新覆盖。 支持主站写:`FC05`(线圈)、`FC06`(单寄存器)、`FC16`(多寄存器)。照明 `KA*` / `开关设定` 写入后会 sticky 保持,不被自动刷新覆盖。
## 安装与运行 ## Windows 可执行文件(推荐)
在 Windows 上打包:
```powershell
cd tools/modbus_sim
powershell -ExecutionPolicy Bypass -File .\build-exe.ps1
# 产物:dist\jh-modbus-sim.exe
```
110 部署目录:`D:\apps\jh-lab\modbus_sim\`
```text
jh-modbus-sim.exe # 单文件 exe
protocol\ # 协议 CSV(须与 exe 同级)
start-sim.bat # 已监听则跳过,否则最小化启动
```
启动:双击 `start-sim.bat`,或:
```bat
jh-modbus-sim.exe --csv-root protocol --host 0.0.0.0 --port-102 1502 --port-103 1503 --port-104 1504
```
## 安装与运行(源码)
```bash ```bash
cd tools/modbus_sim cd tools/modbus_sim
......
# Build jh-modbus-sim.exe on Windows (run on 110 or any Win amd64).
# Requires: D:\apps\jh-lab\python-full or PATH python with pip.
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
$PyCandidates = @(
'D:\apps\jh-lab\python-full\python.exe',
'D:\apps\jh-lab\python\python.exe',
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
) | Where-Object { $_ -and (Test-Path $_) }
$Py = $PyCandidates | Select-Object -First 1
if (-not $Py) { throw 'No python.exe found' }
Write-Host "[py] $Py"
& $Py -m pip install -q --upgrade pip pyinstaller
if ($LASTEXITCODE -ne 0) { throw 'pip install pyinstaller failed' }
$Dist = Join-Path $Root 'dist'
$Work = Join-Path $Root 'build'
Remove-Item -Recurse -Force $Dist, $Work -ErrorAction SilentlyContinue
& $Py -m PyInstaller `
--noconfirm --clean --onefile --console `
--name jh-modbus-sim `
--distpath $Dist `
--workpath $Work `
--specpath $Work `
(Join-Path $Root 'simulate.py')
if ($LASTEXITCODE -ne 0) { throw 'PyInstaller failed' }
$Exe = Join-Path $Dist 'jh-modbus-sim.exe'
if (-not (Test-Path $Exe)) { throw "missing $Exe" }
Write-Host "[ok] $Exe size=$((Get-Item $Exe).Length)"
...@@ -146,13 +146,20 @@ def parse_points_csv(path: Path, gateway: str) -> list[PointDef]: ...@@ -146,13 +146,20 @@ def parse_points_csv(path: Path, gateway: str) -> list[PointDef]:
return pts return pts
def app_dir() -> Path:
"""源码目录,或 PyInstaller 打包后的 exe 所在目录。"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def find_csv_root(explicit: str | None) -> Path: def find_csv_root(explicit: str | None) -> Path:
if explicit: if explicit:
p = Path(explicit).expanduser().resolve() p = Path(explicit).expanduser().resolve()
if not p.is_dir(): if not p.is_dir():
raise SystemExit(f"--csv-root 不是目录: {p}") raise SystemExit(f"--csv-root 不是目录: {p}")
return p return p
here = Path(__file__).resolve().parent here = app_dir()
candidates = [ candidates = [
here / "protocol", here / "protocol",
here.parent.parent.parent / "设备对接" / "澳门镜湖医院协议20261019" / "澳门镜湖医院协议20261019", here.parent.parent.parent / "设备对接" / "澳门镜湖医院协议20261019" / "澳门镜湖医院协议20261019",
......
...@@ -7,20 +7,23 @@ import type { ScreenMode } from '@/data/screenModePois' ...@@ -7,20 +7,23 @@ import type { ScreenMode } from '@/data/screenModePois'
import type { SensorSnapshot } from '@/types' import type { SensorSnapshot } from '@/types'
/** N 电:白光;E 电:淡绿(线性 RGB) */ /** N 电:白光;E 电:淡绿(线性 RGB) */
const COLOR_N: [number, number, number, number] = [1, 1, 1, 1] const COLOR_N: [number, number, number, number] = [1, 1, 0.96, 1]
const COLOR_E: [number, number, number, number] = [0.45, 1, 0.55, 1] const COLOR_E: [number, number, number, number] = [0.55, 1, 0.62, 1]
const SPOT_PREFIX = 'spot:' const SPOT_PREFIX = 'spot:'
/** 灯具挂高:相对设施打点 z(与场景单位一致),略抬高便于朝地板打出光锥 */ /** 灯具挂高:相对设施打点 z,抬高后朝下覆盖房间 */
const SPOT_HEIGHT = 3.6 const SPOT_HEIGHT = 4.2
/** candela;夜晚压暗后需更高才能在地面形成明显光斑 */ /**
const SPOT_INTENSITY_ON = 90000 * candela。关自动曝光后场景偏暗;再 ×10 保证整间足够亮。
/** 衰减半径:覆盖房间尺度 */ * 仅用朝下 spotlight,不用 point light。
const SPOT_RADIUS = 55 */
/** 内/外锥角(度):收窄后更像聚光而非点光 */ const SPOT_INTENSITY_ON = 4_200_000
const SPOT_INNER_CONE = 18 /** 衰减半径:覆盖整间实验室尺度 */
const SPOT_OUTER_CONE = 42 const SPOT_RADIUS = 160
/** pitch=-90:光轴垂直朝地板(与 SDK 喷水默认仰角一致) */ /** 内/外锥角(度):加宽,单盏 spotlight 照亮整间 */
const SPOT_INNER_CONE = 55
const SPOT_OUTER_CONE = 95
/** pitch=-90:光轴垂直朝地板 */
const SPOT_PITCH_DOWN = -90 const SPOT_PITCH_DOWN = -90
type BridgeLike = { type BridgeLike = {
...@@ -29,7 +32,7 @@ type BridgeLike = { ...@@ -29,7 +32,7 @@ type BridgeLike = {
updateLight: (l: Partial<RtengineLight> & { id: string }) => void updateLight: (l: Partial<RtengineLight> & { id: string }) => void
removeLight: (id: string) => void removeLight: (id: string) => void
setLightVisible: (id: string, visible: boolean) => void setLightVisible: (id: string, visible: boolean) => void
applyDayNight: (night: boolean) => void applyDayNight: (night: boolean, opts?: { denseFog?: boolean }) => void
} }
function isLightMode(mode: ScreenMode) { function isLightMode(mode: ScreenMode) {
...@@ -47,17 +50,23 @@ function lightsOf(snap: { sensors?: SensorSnapshot[] }, mode: ScreenMode): Senso ...@@ -47,17 +50,23 @@ function lightsOf(snap: { sensors?: SensorSnapshot[] }, mode: ScreenMode): Senso
return [] return []
} }
function basePose(s: SensorSnapshot) {
const z = Number(s.scene?.z) || 51
return {
x: Number(s.scene?.x) || 0,
y: Number(s.scene?.y) || 0,
z: z + SPOT_HEIGHT,
}
}
function toSpot(s: SensorSnapshot, on: boolean): RtengineLight { function toSpot(s: SensorSnapshot, on: boolean): RtengineLight {
const isE = s.category === 'lightE' const isE = s.category === 'lightE'
const z = Number(s.scene?.z) || 51 const pose = basePose(s)
return { return {
id: spotId(s.id), id: spotId(s.id),
name: s.name, name: s.name,
// 强制聚光灯(非点光),朝地板照射
type: 'spot', type: 'spot',
x: Number(s.scene?.x) || 0, ...pose,
y: Number(s.scene?.y) || 0,
z: z + SPOT_HEIGHT,
yaw: 0, yaw: 0,
pitch: SPOT_PITCH_DOWN, pitch: SPOT_PITCH_DOWN,
roll: 0, roll: 0,
...@@ -67,7 +76,8 @@ function toSpot(s: SensorSnapshot, on: boolean): RtengineLight { ...@@ -67,7 +76,8 @@ function toSpot(s: SensorSnapshot, on: boolean): RtengineLight {
outerConeAngle: SPOT_OUTER_CONE, outerConeAngle: SPOT_OUTER_CONE,
color: isE ? COLOR_E : COLOR_N, color: isE ? COLOR_E : COLOR_N,
useTemperature: false, useTemperature: false,
castShadows: true, // 关阴影,避免暗部吃光;关自动曝光时更明显
castShadows: false,
visible: on, visible: on,
tag0: 'facility-spot', tag0: 'facility-spot',
tag1: s.category, tag1: s.category,
...@@ -76,8 +86,8 @@ function toSpot(s: SensorSnapshot, on: boolean): RtengineLight { ...@@ -76,8 +86,8 @@ function toSpot(s: SensorSnapshot, on: boolean): RtengineLight {
} }
/** /**
* 照明模式:夜晚压暗 + 每盏设施 spotlight。 * 照明模式:夜晚压暗(无雾)+ 每盏设施朝下 spotlight。
* 开 → 显示灯;关 → 隐藏灯(setLightVisible)。 * 开 → 显示灯;关 → 隐藏灯。
*/ */
export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) { export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) {
const modeStore = useScreenModeStore() const modeStore = useScreenModeStore()
...@@ -106,38 +116,47 @@ export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) ...@@ -106,38 +116,47 @@ export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>)
function ensureNight(want: boolean, force = false) { function ensureNight(want: boolean, force = false) {
if (!force && want === nightOn) return if (!force && want === nightOn) return
// 照明模式:黑底但空气清澈,灯光才能照亮整间房
bridge.applyDayNight(want) bridge.applyDayNight(want)
nightOn = want nightOn = want
} }
/** 开:确保灯存在并显示;关:隐藏 */ function upsertLight(
function applyOne(s: SensorSnapshot, on: boolean, recreate: boolean) { light: RtengineLight,
const id = spotId(s.id) on: boolean,
const spot = toSpot(s, on) recreate: boolean,
if (recreate || !activeSpotIds.includes(id)) { bag: string[],
bridge.addLight(spot) ): string[] {
if (!activeSpotIds.includes(id)) activeSpotIds.push(id) const id = light.id
if (recreate || !bag.includes(id)) {
bridge.addLight(light)
if (!bag.includes(id)) bag = [...bag, id]
} else { } else {
// 增量更新也带上 type/朝向/锥角,避免引擎回落成点光
bridge.updateLight({ bridge.updateLight({
id, id,
type: 'spot', type: light.type,
intensity: spot.intensity, intensity: light.intensity,
color: spot.color, color: light.color,
visible: on, visible: on,
x: spot.x, x: light.x,
y: spot.y, y: light.y,
z: spot.z, z: light.z,
yaw: spot.yaw, yaw: light.yaw,
pitch: spot.pitch, pitch: light.pitch,
roll: spot.roll, roll: light.roll,
attenuationRadius: spot.attenuationRadius, attenuationRadius: light.attenuationRadius,
innerConeAngle: spot.innerConeAngle, innerConeAngle: light.innerConeAngle,
outerConeAngle: spot.outerConeAngle, outerConeAngle: light.outerConeAngle,
castShadows: light.castShadows,
}) })
} }
// 用户要求:开=显示 light,关=隐藏 light
bridge.setLightVisible(id, on) bridge.setLightVisible(id, on)
return bag
}
/** 开:确保朝下 spotlight 存在并显示;关:隐藏 */
function applyOne(s: SensorSnapshot, on: boolean, recreate: boolean) {
activeSpotIds = upsertLight(toSpot(s, on), on, recreate, activeSpotIds)
knownOn.set(s.id, on) knownOn.set(s.id, on)
} }
...@@ -169,7 +188,6 @@ export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) ...@@ -169,7 +188,6 @@ export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>)
return return
} }
// 仅开关变化:显示 / 隐藏,不整表重建
const keep = new Set(sensors.map((s) => s.id)) const keep = new Set(sensors.map((s) => s.id))
for (const s of sensors) { for (const s of sensors) {
const on = s.on === true const on = s.on === true
...@@ -214,7 +232,6 @@ export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) ...@@ -214,7 +232,6 @@ export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>)
) )
return { return {
/** 默认增量同步(显隐);传 true 时重建全部 spotlight */
syncLightEffects: (force = false) => sync(force), syncLightEffects: (force = false) => sync(force),
clearSpots, clearSpots,
} }
......
...@@ -670,10 +670,12 @@ export function useRtengineBridge(opts: { ...@@ -670,10 +670,12 @@ export function useRtengineBridge(opts: {
payload.type = l.type payload.type = l.type
payload.lightType = l.type payload.lightType = l.type
payload.kind = l.type payload.kind = l.type
payload.LightType = l.type
} }
if (l.intensity != null) payload.intensity = l.intensity if (l.intensity != null) payload.intensity = l.intensity
if (l.visible != null) payload.visible = l.visible if (l.visible != null) payload.visible = l.visible
if (l.color) payload.color = l.color if (l.color) payload.color = l.color
if (l.castShadows != null) payload.castShadows = l.castShadows
if (l.attenuationRadius != null) payload.attenuationRadius = l.attenuationRadius if (l.attenuationRadius != null) payload.attenuationRadius = l.attenuationRadius
if (l.innerConeAngle != null) { if (l.innerConeAngle != null) {
payload.innerConeAngle = l.innerConeAngle payload.innerConeAngle = l.innerConeAngle
...@@ -798,12 +800,16 @@ export function useRtengineBridge(opts: { ...@@ -798,12 +800,16 @@ export function useRtengineBridge(opts: {
setSunParam(intensity, intensity < 0.2 ? 0.7 : 0) setSunParam(intensity, intensity < 0.2 ? 0.7 : 0)
} }
function applyDayNight(night: boolean) { /**
* 昼夜切换。
* 夜间默认「关太阳、空气清澈」:关自动曝光后浓雾会吃掉灯光,照明模式必须透光。
* opts.denseFog=true 才恢复旧的浓雾全黑(一般不用)。
*/
function applyDayNight(night: boolean, opts?: { denseFog?: boolean }) {
if (night) { if (night) {
// 尽量全黑:午夜 + 浓雾阴天 + 太阳强度归零,只留 spotlight
setSolarTime(NIGHT_SOLAR_TIME) setSolarTime(NIGHT_SOLAR_TIME)
if (opts?.denseFog) {
setWeather(5, { setWeather(5, {
// 5=雾;比阴天更能压掉环境光
fog: 1, fog: 1,
fogDensity: 1, fogDensity: 1,
fogIntensity: 1, fogIntensity: 1,
...@@ -811,10 +817,21 @@ export function useRtengineBridge(opts: { ...@@ -811,10 +817,21 @@ export function useRtengineBridge(opts: {
wind: 0, wind: 0,
transition: 0.08, transition: 0.08,
}) })
// intensity=0, fog=1, HSV.v=0 → 尽量全黑
setSunParam(0, 1, 0, 0, 0, 1) setSunParam(0, 1, 0, 0, 0, 1)
setFog(1) setFog(1)
// 部分版本 intensity 映射云量;再压一轮太阳时确保仍是深夜 } else {
// 深夜无日照 + 无雾:场景黑底,由 spotlight 照亮房间
setWeather(1, {
fog: 0,
fogDensity: 0,
fogIntensity: 0,
cloudCoverage: 0.85,
wind: 0,
transition: 0.1,
})
setSunParam(0, 0, 0, 0, 0, 1)
setFog(0)
}
setSolarTime(NIGHT_SOLAR_TIME) setSolarTime(NIGHT_SOLAR_TIME)
} else { } else {
setSolarTime(DAY_SOLAR_TIME) setSolarTime(DAY_SOLAR_TIME)
......
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { api } from '@/api/http' import { api } from '@/api/http'
export const DEFAULT_SCREEN_TITLE = '分子实验室三维可视化管理平台' export const DEFAULT_SCREEN_TITLE = '分子实验室三维可视化管理平台'
...@@ -8,12 +8,27 @@ const title = ref(DEFAULT_SCREEN_TITLE) ...@@ -8,12 +8,27 @@ const title = ref(DEFAULT_SCREEN_TITLE)
let pollTimer: number | undefined let pollTimer: number | undefined
let channel: BroadcastChannel | null = null let channel: BroadcastChannel | null = null
let subscribers = 0 let subscribers = 0
let bootstrapped = false
function applyDocumentTitle(next: string) {
if (typeof document === 'undefined') return
const v = String(next || '').trim() || DEFAULT_SCREEN_TITLE
if (document.title !== v) document.title = v
}
function setTitle(next: string) {
const v = String(next || '').trim() || DEFAULT_SCREEN_TITLE
title.value = v
applyDocumentTitle(v)
}
watch(title, (v) => applyDocumentTitle(v), { immediate: true })
async function fetchTitle() { async function fetchTitle() {
try { try {
const data = await api<{ screenTitle?: string }>('/api/settings') const data = await api<{ screenTitle?: string }>('/api/settings')
const next = String(data.screenTitle || '').trim() const next = String(data.screenTitle || '').trim()
if (next) title.value = next if (next) setTitle(next)
} catch { } catch {
/* 大屏允许匿名;失败保留当前值 */ /* 大屏允许匿名;失败保留当前值 */
} }
...@@ -24,14 +39,23 @@ function ensureChannel() { ...@@ -24,14 +39,23 @@ function ensureChannel() {
channel = new BroadcastChannel(SCREEN_TITLE_CHANNEL) channel = new BroadcastChannel(SCREEN_TITLE_CHANNEL)
channel.onmessage = (ev) => { channel.onmessage = (ev) => {
const next = String(ev.data?.screenTitle || '').trim() const next = String(ev.data?.screenTitle || '').trim()
if (next) title.value = next if (next) setTitle(next)
} }
} }
/** 应用启动时拉一次,保证任意路由的浏览器页签标题与设置一致 */
export function bootstrapScreenTitle() {
if (bootstrapped) return
bootstrapped = true
ensureChannel()
applyDocumentTitle(title.value)
void fetchTitle()
}
/** 设置页保存后广播,同浏览器已打开的大屏立即刷新 */ /** 设置页保存后广播,同浏览器已打开的大屏立即刷新 */
export function publishScreenTitle(screenTitle: string) { export function publishScreenTitle(screenTitle: string) {
const next = String(screenTitle || '').trim() || DEFAULT_SCREEN_TITLE const next = String(screenTitle || '').trim() || DEFAULT_SCREEN_TITLE
title.value = next setTitle(next)
try { try {
ensureChannel() ensureChannel()
channel?.postMessage({ screenTitle: next }) channel?.postMessage({ screenTitle: next })
...@@ -42,6 +66,7 @@ export function publishScreenTitle(screenTitle: string) { ...@@ -42,6 +66,7 @@ export function publishScreenTitle(screenTitle: string) {
/** /**
* 大屏标题:挂载时拉取,定时轮询;同页/跨页保存经 BroadcastChannel 即时更新。 * 大屏标题:挂载时拉取,定时轮询;同页/跨页保存经 BroadcastChannel 即时更新。
* 同步更新浏览器 document.title。
*/ */
export function useScreenTitle(opts?: { pollMs?: number }) { export function useScreenTitle(opts?: { pollMs?: number }) {
const pollMs = opts?.pollMs ?? 5000 const pollMs = opts?.pollMs ?? 5000
......
...@@ -3,8 +3,10 @@ import { createPinia } from 'pinia' ...@@ -3,8 +3,10 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import i18n from './i18n' import i18n from './i18n'
import { bootstrapScreenTitle } from './composables/useScreenTitle'
import './styles/tokens.css' import './styles/tokens.css'
import './styles/screen.css' import './styles/screen.css'
import './styles/tops-chrome.css' import './styles/tops-chrome.css'
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app') createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
bootstrapScreenTitle()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment