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 {
position: fixed;
right: 16px;
top: 60px;
z-index: 600;
width: 280px;
width: 340px;
background: rgba(8, 8, 12, 0.84);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
@ -57,7 +53,7 @@
.stripRow {
display: flex;
align-items: center;
gap: 6px;
gap: 5px;
margin-bottom: 6px;
font-size: 12px;
}
@ -66,37 +62,17 @@
color: rgba(245, 158, 11, 0.9);
font-weight: 600;
min-width: 44px;
flex-shrink: 0;
}
.stripRange {
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 {
.rangeSep {
color: rgba(255, 255, 255, 0.28);
font-size: 11px;
}
.stripDelete {
margin-left: auto;
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 4px;

View File

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

View File

@ -516,6 +516,20 @@
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 {

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
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
// Suspend Cesium's render loop while Three.js ride view is active so both
// renderers don't compete for the GPU simultaneously.
// Suspend Cesium while Three.js ride view is active: stop the render loop AND
// 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(() => {
viewer.useDefaultRenderLoop = !isRideMode
viewer.scene.globe.enabled = !isRideMode
}, [isRideMode, viewer])
// 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 positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
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, {
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3),
viewer.camera.flyToBoundingSphere(padded, {
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-60), 0),
duration: 1.2,
})
}, [challenge, viewer])
@ -324,7 +328,6 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
simResult={simResult}
captureData={terrain.captureData}
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
onRideProgress={setRideCursor}
/>
)}
@ -496,7 +499,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
)}
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
{simResult?.profile && (
{!isRideMode && simResult?.profile && (
<SimulationPlots
profile={simResult.profile}
strips={accel.strips}
@ -504,16 +507,17 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
/>
)}
{/* ── Acceleration strips (right panel) ───────────────────────────── */}
{/* ── Right panel column (acceleration strips + coaster list) ────── */}
{!isRideMode && (
<div className={styles.rightColumn}>
{accel.strips.length > 0 && (
<AccelerationStripsPanel
strips={accel.strips}
onRemoveStrip={accel.removeStrip}
onUpdateStrip={accel.updateStrip}
onUpdateStripFrac={accel.updateStripFrac}
/>
)}
{/* ── Coaster list panel (right) ───────────────────────────────────── */}
{challengeId && (
<CoasterListPanel
challengeId={challengeId}
@ -522,6 +526,8 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
refreshKey={coasterListKey}
/>
)}
</div>
)}
{/* ── Loading overlay ──────────────────────────────────────────────── */}
{!challenge && <div className={styles.loading}>Loading challenge</div>}

View File

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

View File

@ -249,9 +249,20 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
addRail(rideData.rail1)
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 {
scene,
setActivePatch,
warmUpPatches,
disposeAll: () => {
geos.forEach(g => g.dispose())
mats.forEach(m => m.dispose())
@ -322,7 +333,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
rideDataRef.current = rideData
setTotalDuration(rideData.totalDuration)
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
const { scene, setActivePatch, warmUpPatches, disposeAll } = buildScene(captureData, rideData, renderer)
sceneRef.current = scene
setActivePatchRef.current = setActivePatch
activePatchIdxRef.current = 0
@ -345,7 +356,13 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
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)
setActivePatch(0)
return () => {
window.removeEventListener('resize', onResize)

View File

@ -43,6 +43,7 @@ export interface AccelerationStripsHandle {
strips: AccelerationStrip[]
removeStrip: (id: string) => void
updateStrip: (id: string, accel_ms2: number) => void
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
clearStrips: () => void
loadStrips: (strips: AccelerationStrip[]) => void
}
@ -75,6 +76,10 @@ export function useAccelerationStrips(
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(() => {
setStrips([])
}, [])
@ -236,5 +241,5 @@ export function useAccelerationStrips(
}
}, [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) {
setStatus('idle')
return
return () => { abortRef.current = true }
}
abortRef.current = false
@ -224,6 +224,10 @@ export function useTerrainCapture(
console.error('Terrain capture failed:', err)
setStatus('error')
})
return () => {
abortRef.current = true
}
}, [simResult, viewer])
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>
)
}