ui tweaks and performance tweaks

This commit is contained in:
munsel 2026-04-24 03:39:20 +02:00
parent 42197bfbc9
commit 3aeff0a7e7
10 changed files with 280 additions and 74 deletions

View File

@ -1,9 +1,5 @@
.panel { .panel {
position: fixed; width: 340px;
right: 16px;
top: 60px;
z-index: 600;
width: 280px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
@ -57,7 +53,7 @@
.stripRow { .stripRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 12px; font-size: 12px;
} }
@ -66,37 +62,17 @@
color: rgba(245, 158, 11, 0.9); color: rgba(245, 158, 11, 0.9);
font-weight: 600; font-weight: 600;
min-width: 44px; min-width: 44px;
flex-shrink: 0;
} }
.stripRange { .rangeSep {
color: rgba(255, 255, 255, 0.38);
font-size: 11px;
min-width: 56px;
}
.accelInput {
width: 50px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 2px 5px;
text-align: right;
}
.accelInput:focus {
outline: none;
border-color: rgba(245, 158, 11, 0.5);
}
.stripUnit {
color: rgba(255, 255, 255, 0.28); color: rgba(255, 255, 255, 0.28);
font-size: 11px; font-size: 11px;
} }
.stripDelete { .stripDelete {
margin-left: auto; margin-left: auto;
flex-shrink: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;

View File

@ -1,16 +1,16 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import type { AccelerationStrip } from '../types/api' import type { AccelerationStrip } from '../types/api'
import { BlenderInput } from '../ui/BlenderInput'
import styles from './AccelerationStripsPanel.module.css' import styles from './AccelerationStripsPanel.module.css'
interface Props { interface Props {
strips: AccelerationStrip[] strips: AccelerationStrip[]
onRemoveStrip: (id: string) => void onRemoveStrip: (id: string) => void
onUpdateStrip: (id: string, accel_ms2: number) => void onUpdateStrip: (id: string, accel_ms2: number) => void
onUpdateStripFrac: (id: string, startFrac: number, endFrac: number) => void
} }
function pct(v: number) { return `${(v * 100).toFixed(0)}%` } export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip, onUpdateStripFrac }: Props) {
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }: Props) {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
// Auto-open when a strip is added // Auto-open when a strip is added
@ -31,19 +31,45 @@ export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }
: strips.map((s, i) => ( : strips.map((s, i) => (
<div key={s.id} className={styles.stripRow}> <div key={s.id} className={styles.stripRow}>
<span className={styles.stripIndex}>Strip {i + 1}</span> <span className={styles.stripIndex}>Strip {i + 1}</span>
<span className={styles.stripRange}>
{pct(s.startFrac)}{pct(s.endFrac)} <BlenderInput
</span> value={s.startFrac * 100}
<input onChange={v => {
type="number" const sf = Math.max(0, Math.min(v / 100, s.endFrac - 0.001))
step={0.5} onUpdateStripFrac(s.id, sf, s.endFrac)
}}
min={0}
max={s.endFrac * 100 - 0.1}
step={0.2}
decimals={1}
suffix="%"
/>
<span className={styles.rangeSep}></span>
<BlenderInput
value={s.endFrac * 100}
onChange={v => {
const ef = Math.min(100, Math.max(v / 100, s.startFrac + 0.001))
onUpdateStripFrac(s.id, s.startFrac, ef)
}}
min={s.startFrac * 100 + 0.1}
max={100}
step={0.2}
decimals={1}
suffix="%"
/>
<BlenderInput
value={s.accel_ms2}
onChange={v => onUpdateStrip(s.id, v)}
min={-50} min={-50}
max={50} max={50}
value={s.accel_ms2} step={0.1}
onChange={e => onUpdateStrip(s.id, Number(e.target.value))} decimals={1}
className={styles.accelInput} suffix="m/s²"
/> />
<span className={styles.stripUnit}>m/s²</span>
<button <button
className={styles.stripDelete} className={styles.stripDelete}
onClick={() => onRemoveStrip(s.id)} onClick={() => onRemoveStrip(s.id)}

View File

@ -516,6 +516,20 @@
margin: 0 4px; margin: 0 4px;
} }
/* ── Right panel column ──────────────────────────────────────────────────────── */
.rightColumn {
position: fixed;
right: 16px;
top: 60px;
z-index: 600;
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 76px);
overflow-y: auto;
}
/* ── Loading overlay ─────────────────────────────────────────────────────────── */ /* ── Loading overlay ─────────────────────────────────────────────────────────── */
.loading { .loading {

View File

@ -118,10 +118,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
// Exit ride mode whenever the sim result changes; clear cursor on exit // Exit ride mode whenever the sim result changes; clear cursor on exit
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult]) useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
// Suspend Cesium's render loop while Three.js ride view is active so both // Suspend Cesium while Three.js ride view is active: stop the render loop AND
// renderers don't compete for the GPU simultaneously. // disable the globe so Cesium's terrain web-workers stop posting decode results
// back to the main thread (those async completions caused periodic ~5 s spikes).
useEffect(() => { useEffect(() => {
viewer.useDefaultRenderLoop = !isRideMode viewer.useDefaultRenderLoop = !isRideMode
viewer.scene.globe.enabled = !isRideMode
}, [isRideMode, viewer]) }, [isRideMode, viewer])
// Refs for simulation result entities (cleared on each new run / unmount) // Refs for simulation result entities (cleared on each new run / unmount)
@ -135,10 +137,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
const coords = challenge.region.coordinates[0] const coords = challenge.region.coordinates[0]
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere()) const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
sphere.radius = Math.max(sphere.radius + 20, 50) // Add 25 % padding so the polygon doesn't butt right up against the frame
// edges, then let Cesium compute the exact range (range = 0 = auto-fit).
const padded = new Cesium.BoundingSphere(sphere.center, sphere.radius * 1.25)
viewer.camera.flyToBoundingSphere(sphere, { viewer.camera.flyToBoundingSphere(padded, {
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3), offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-60), 0),
duration: 1.2, duration: 1.2,
}) })
}, [challenge, viewer]) }, [challenge, viewer])
@ -324,7 +328,6 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
simResult={simResult} simResult={simResult}
captureData={terrain.captureData} captureData={terrain.captureData}
onStop={() => { setIsRideMode(false); setRideCursor(null) }} onStop={() => { setIsRideMode(false); setRideCursor(null) }}
onRideProgress={setRideCursor}
/> />
)} )}
@ -496,7 +499,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
)} )}
{/* ── Simulation profile charts (left panel) ──────────────────────── */} {/* ── Simulation profile charts (left panel) ──────────────────────── */}
{simResult?.profile && ( {!isRideMode && simResult?.profile && (
<SimulationPlots <SimulationPlots
profile={simResult.profile} profile={simResult.profile}
strips={accel.strips} strips={accel.strips}
@ -504,23 +507,26 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
/> />
)} )}
{/* ── Acceleration strips (right panel) ───────────────────────────── */} {/* ── Right panel column (acceleration strips + coaster list) ────── */}
{accel.strips.length > 0 && ( {!isRideMode && (
<AccelerationStripsPanel <div className={styles.rightColumn}>
strips={accel.strips} {accel.strips.length > 0 && (
onRemoveStrip={accel.removeStrip} <AccelerationStripsPanel
onUpdateStrip={accel.updateStrip} strips={accel.strips}
/> onRemoveStrip={accel.removeStrip}
)} onUpdateStrip={accel.updateStrip}
onUpdateStripFrac={accel.updateStripFrac}
{/* ── Coaster list panel (right) ───────────────────────────────────── */} />
{challengeId && ( )}
<CoasterListPanel {challengeId && (
challengeId={challengeId} <CoasterListPanel
currentUsername={currentUsername} challengeId={challengeId}
onLoad={handleLoad} currentUsername={currentUsername}
refreshKey={coasterListKey} onLoad={handleLoad}
/> refreshKey={coasterListKey}
/>
)}
</div>
)} )}
{/* ── Loading overlay ──────────────────────────────────────────────── */} {/* ── Loading overlay ──────────────────────────────────────────────── */}

View File

@ -1,8 +1,4 @@
.panel { .panel {
position: fixed;
right: 16px;
top: 112px; /* 52px header + ~44px topBar + 16px gap */
z-index: 200;
width: 240px; width: 240px;
background: rgba(8, 8, 12, 0.84); background: rgba(8, 8, 12, 0.84);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);

View File

@ -249,9 +249,20 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
addRail(rideData.rail1) addRail(rideData.rail1)
addRail(rideData.rail2) addRail(rideData.rail2)
// Expose a warm-up helper: makes every terrain patch visible so the caller can
// do one render that forces the GPU driver to flush all queued texImage2D /
// generateMipmap commands for every patch at once. Without this, those
// commands are deferred and cause a GPU pipeline stall the first time each
// patch becomes the active (visible) one during the ride — which at high
// coaster speeds can be every few seconds.
function warmUpPatches() {
terrainMeshes.forEach(m => { m.visible = true })
}
return { return {
scene, scene,
setActivePatch, setActivePatch,
warmUpPatches,
disposeAll: () => { disposeAll: () => {
geos.forEach(g => g.dispose()) geos.forEach(g => g.dispose())
mats.forEach(m => m.dispose()) mats.forEach(m => m.dispose())
@ -322,7 +333,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
rideDataRef.current = rideData rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration) setTotalDuration(rideData.totalDuration)
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer) const { scene, setActivePatch, warmUpPatches, disposeAll } = buildScene(captureData, rideData, renderer)
sceneRef.current = scene sceneRef.current = scene
setActivePatchRef.current = setActivePatch setActivePatchRef.current = setActivePatch
activePatchIdxRef.current = 0 activePatchIdxRef.current = 0
@ -345,7 +356,13 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
cameraRef.current.updateMatrixWorld() cameraRef.current.updateMatrixWorld()
} }
// Warm-up: make every terrain patch visible and render once. This forces the
// GPU driver to flush all pending texImage2D / generateMipmap commands for
// every patch right now, so patch-visibility switches during the ride have
// zero GPU stall cost. Then reset to only the first patch.
warmUpPatches()
renderer.render(scene, cameraRef.current) renderer.render(scene, cameraRef.current)
setActivePatch(0)
return () => { return () => {
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)

View File

@ -43,6 +43,7 @@ export interface AccelerationStripsHandle {
strips: AccelerationStrip[] strips: AccelerationStrip[]
removeStrip: (id: string) => void removeStrip: (id: string) => void
updateStrip: (id: string, accel_ms2: number) => void updateStrip: (id: string, accel_ms2: number) => void
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
clearStrips: () => void clearStrips: () => void
loadStrips: (strips: AccelerationStrip[]) => void loadStrips: (strips: AccelerationStrip[]) => void
} }
@ -75,6 +76,10 @@ export function useAccelerationStrips(
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s)) setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
}, []) }, [])
const updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => {
setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s))
}, [])
const clearStrips = useCallback(() => { const clearStrips = useCallback(() => {
setStrips([]) setStrips([])
}, []) }, [])
@ -236,5 +241,5 @@ export function useAccelerationStrips(
} }
}, [viewer]) }, [viewer])
return { strips, removeStrip, updateStrip, clearStrips, loadStrips } return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips }
} }

View File

@ -207,7 +207,7 @@ export function useTerrainCapture(
if (!simResult) { if (!simResult) {
setStatus('idle') setStatus('idle')
return return () => { abortRef.current = true }
} }
abortRef.current = false abortRef.current = false
@ -224,6 +224,10 @@ export function useTerrainCapture(
console.error('Terrain capture failed:', err) console.error('Terrain capture failed:', err)
setStatus('error') setStatus('error')
}) })
return () => {
abortRef.current = true
}
}, [simResult, viewer]) }, [simResult, viewer])
return { status, captureData } return { status, captureData }

View File

@ -0,0 +1,50 @@
.root {
display: inline-flex;
align-items: center;
cursor: ew-resize;
user-select: none;
}
.display {
display: inline-flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 5px;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
min-width: 40px;
text-align: right;
justify-content: flex-end;
white-space: nowrap;
transition: border-color 0.12s, background 0.12s;
}
.root:hover .display {
background: rgba(255, 255, 255, 0.11);
border-color: rgba(245, 158, 11, 0.45);
}
.suffix {
font-size: 10px;
font-weight: 400;
color: rgba(255, 255, 255, 0.35);
margin-left: 2px;
}
.input {
width: 64px;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.6);
border-radius: 5px;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
outline: none;
cursor: text;
text-align: right;
}

112
web/src/ui/BlenderInput.tsx Normal file
View File

@ -0,0 +1,112 @@
import { useRef, useState, useCallback, useEffect } from 'react'
import styles from './BlenderInput.module.css'
interface Props {
value: number
onChange: (v: number) => void
min?: number
max?: number
/** How much the value changes per pixel dragged. Default 0.1 */
step?: number
/** Decimal places shown. Default 1 */
decimals?: number
/** Optional suffix shown after the number, e.g. "%" or "m/s²" */
suffix?: string
}
const DRAG_THRESHOLD = 4 // px before we treat motion as a drag
function clamp(v: number, min: number | undefined, max: number | undefined) {
if (min !== undefined && v < min) return min
if (max !== undefined && v > max) return max
return v
}
export function BlenderInput({ value, onChange, min, max, step = 0.1, decimals = 1, suffix }: Props) {
const [editing, setEditing] = useState(false)
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const dragState = useRef<{
startX: number
startValue: number
moved: boolean
} | null>(null)
// ── Focus the real input when we enter edit mode ────────────────────────────
useEffect(() => {
if (editing) {
inputRef.current?.select()
}
}, [editing])
// ── Mouse-down: begin drag tracking ────────────────────────────────────────
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (editing) return
e.preventDefault()
dragState.current = { startX: e.clientX, startValue: value, moved: false }
function onMove(me: MouseEvent) {
if (!dragState.current) return
const dx = me.clientX - dragState.current.startX
if (Math.abs(dx) >= DRAG_THRESHOLD) {
dragState.current.moved = true
const next = clamp(
dragState.current.startValue + dx * step,
min,
max,
)
onChange(next)
}
}
function onUp() {
if (!dragState.current) return
if (!dragState.current.moved) {
// treat as a click → enter edit mode
setText(value.toFixed(decimals))
setEditing(true)
}
dragState.current = null
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [editing, value, step, min, max, decimals, onChange])
// ── Commit text input ───────────────────────────────────────────────────────
const commit = useCallback(() => {
const parsed = parseFloat(text)
if (!isNaN(parsed)) {
onChange(clamp(parsed, min, max))
}
setEditing(false)
}, [text, min, max, onChange])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') commit()
if (e.key === 'Escape') setEditing(false)
}, [commit])
return (
<span className={styles.root} onMouseDown={handleMouseDown}>
{editing ? (
<input
ref={inputRef}
className={styles.input}
value={text}
onChange={e => setText(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
onClick={e => e.stopPropagation()}
/>
) : (
<span className={styles.display}>
{value.toFixed(decimals)}
{suffix && <span className={styles.suffix}>{suffix}</span>}
</span>
)}
</span>
)
}