Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ Thumbs.db

# TypeScript incremental build output
*.tsbuildinfo

# Local dev helpers
install.bat
launch.bat
24 changes: 24 additions & 0 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,27 @@ function LightPopover({
)
}

function plainRow(label: string, intensityKey: keyof LightSettings, max: number) {
const value = (settings[intensityKey] as number) ?? (DEFAULT_LIGHT_SETTINGS[intensityKey] as number)
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-400 flex-1">{label}</span>
<span className="text-[10px] text-zinc-500 font-mono">{value.toFixed(2)}</span>
</div>
<input
type="range"
min={0}
max={max}
step={0.05}
value={value}
onChange={(e) => onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })}
className="w-full h-1.5 accent-violet-500 cursor-pointer"
/>
</div>
)
}

return (
<div className="absolute top-full right-0 mt-1 z-50 bg-zinc-900 border border-zinc-700/60 rounded-xl p-3 flex flex-col gap-3 min-w-[220px] shadow-xl">
<div className="flex items-center justify-between">
Expand All @@ -228,6 +249,9 @@ function LightPopover({
</div>
{lightRow('Sun', 'mainColor', 'mainIntensity', 4)}
{lightRow('Fill', 'fillColor', 'fillIntensity', 2)}
{plainRow('Ambient', 'ambientIntensity', 1.5)}
{plainRow('Exposure', 'exposure', 3)}
{plainRow('Environment', 'envIntensity', 2)}
<button
onClick={onClose}
className="mt-1 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
Expand Down
68 changes: 48 additions & 20 deletions src/areas/generate/components/Viewer3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { ReactNode, ErrorInfo, MutableRefObject } from 'react'
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'
import type { ThreeEvent } from '@react-three/fiber'
import { Environment, GizmoHelper, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei'
import { EffectComposer, Outline, Select, Selection } from '@react-three/postprocessing'
import { EffectComposer, Outline, Select, Selection, ToneMapping } from '@react-three/postprocessing'
import { ToneMappingMode } from 'postprocessing'
import * as THREE from 'three'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh'
Expand Down Expand Up @@ -80,6 +81,16 @@ function CanvasCapture({
return null
}

// Live-applies the tone-mapping exposure from the Lighting popover. `gl` props are
// only read at Canvas creation, so exposure must be pushed onto the renderer here.
function RendererSync({ exposure }: { exposure: number }): null {
const gl = useThree((s) => s.gl)
useEffect(() => {
gl.toneMappingExposure = exposure
}, [gl, exposure])
return null
}

// ---------------------------------------------------------------------------
// ModelErrorBoundary — catches useGLTF load failures (e.g. 404)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -922,6 +933,34 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo
pendingTransform.current = null
}, [modelUrl])

// Memoise the post-processing stack so its children stay referentially stable.
// @react-three/postprocessing rebuilds (recompiles) all EffectPasses whenever the
// <EffectComposer> children identity changes; without this, every Viewer3D re-render
// (e.g. dragging a Lighting slider) recompiles the outline/tone-mapping shaders and
// flashes a white, un-tone-mapped frame. The Outline still tracks selection through
// the <Selection> context, so nothing here needs to depend on render state.
const postProcessing = useMemo(() => (
<EffectComposer
autoClear={false}
multisampling={SELECTION_OUTLINE_MULTISAMPLING}
resolutionScale={SELECTION_OUTLINE_RESOLUTION_SCALE}
frameBufferType={THREE.HalfFloatType}
>
<Outline
blur={SELECTION_OUTLINE_BLUR}
edgeStrength={SELECTION_OUTLINE_EDGE_STRENGTH}
visibleEdgeColor={SELECTION_OUTLINE_VISIBLE_COLOR}
hiddenEdgeColor={SELECTION_OUTLINE_HIDDEN_COLOR}
xRay={false}
/>
{/* Tone mapping must live INSIDE the composer: while mounted it forces
gl.toneMapping = NoToneMapping, so the Lighting "Exposure" control
(RendererSync → gl.toneMappingExposure) only takes effect through
this effect's NeutralToneMapping shader. */}
<ToneMapping mode={ToneMappingMode.NEUTRAL} />
</EffectComposer>
), [])


return (
<ModelErrorBoundary resetKey={modelUrl} fallback={<ModelLoadError />}>
Expand All @@ -938,9 +977,9 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo
<Canvas
onPointerMissed={() => setSelected(false)}
camera={{ position: [0, 1.5, 4], fov: 45 }}
dpr={1}
dpr={[1, 2]}
gl={{
antialias: false,
antialias: true,
preserveDrawingBuffer: true,
outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.NeutralToneMapping,
Expand All @@ -949,30 +988,19 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo
>
<color attach="background" args={['#18181b']} />
<CanvasCapture domRef={canvasRef} />
<ambientLight intensity={0.3} />
<RendererSync exposure={lightSettings.exposure ?? DEFAULT_LIGHT_SETTINGS.exposure} />
<ambientLight intensity={lightSettings.ambientIntensity ?? DEFAULT_LIGHT_SETTINGS.ambientIntensity} />
<Environment background={false}>
<Lightformer intensity={2} position={[0, 4, 4]} scale={8} />
<Lightformer intensity={0.5} position={[-4, 2, -4]} scale={6} />
<Lightformer intensity={0.3} position={[4, 1, -4]} scale={6} />
<Lightformer intensity={2 * (lightSettings.envIntensity ?? DEFAULT_LIGHT_SETTINGS.envIntensity)} position={[0, 4, 4]} scale={8} />
<Lightformer intensity={0.5 * (lightSettings.envIntensity ?? DEFAULT_LIGHT_SETTINGS.envIntensity)} position={[-4, 2, -4]} scale={6} />
<Lightformer intensity={0.3 * (lightSettings.envIntensity ?? DEFAULT_LIGHT_SETTINGS.envIntensity)} position={[4, 1, -4]} scale={6} />
</Environment>

<gridHelper args={[10, 20, '#3f3f46', '#27272a']} />

{modelUrl && currentJob ? (
<Selection enabled={selected}>
<EffectComposer
autoClear={false}
multisampling={SELECTION_OUTLINE_MULTISAMPLING}
resolutionScale={SELECTION_OUTLINE_RESOLUTION_SCALE}
>
<Outline
blur={SELECTION_OUTLINE_BLUR}
edgeStrength={SELECTION_OUTLINE_EDGE_STRENGTH}
visibleEdgeColor={SELECTION_OUTLINE_VISIBLE_COLOR}
hiddenEdgeColor={SELECTION_OUTLINE_HIDDEN_COLOR}
xRay={false}
/>
</EffectComposer>
{postProcessing}
<Suspense fallback={null}>
<directionalLight position={[5, 8, 5]} color={lightSettings.mainColor} intensity={lightSettings.mainIntensity} castShadow />
<directionalLight position={[-4, 2, -4]} color={lightSettings.fillColor} intensity={lightSettings.fillIntensity} />
Expand Down
14 changes: 12 additions & 2 deletions src/shared/stores/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export interface LightSettings {
mainColor: string
fillIntensity: number
fillColor: string
ambientIntensity: number
exposure: number
envIntensity: number
}

export interface AppToast {
Expand All @@ -59,10 +62,17 @@ const DEFAULT_OPTIONS: GenerationOptions = {
}

export const DEFAULT_LIGHT_SETTINGS: LightSettings = {
mainIntensity: 1.5,
// Matches the offline debug renderer's flat studio rig: two soft directional
// lights (key ~0.8 / fill ~0.35) + high ambient (0.45) that lifts dark albedo
// (black cat) out of "void" shadows, NO IBL (envIntensity 0), neutral exposure.
// All live-adjustable from the Lighting popover (Reset returns here).
mainIntensity: 0.8,
mainColor: '#ffffff',
fillIntensity: 0.6,
fillIntensity: 0.35,
fillColor: '#ffffff',
ambientIntensity: 0.45,
exposure: 1.0,
envIntensity: 0.0,
}

interface AppState {
Expand Down