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
125 changes: 123 additions & 2 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'
import { useAppStore } from '@shared/stores/appStore'
import type { GenerationJob } from '@shared/stores/appStore'
import { useApi } from '@shared/hooks/useApi'
import { ColorPicker } from '@shared/components/ui'
import GenerationHUD from './components/GenerationHUD'
import Viewer3D from './components/Viewer3D'
import WorkflowPanel from './components/WorkflowPanel'
Expand Down Expand Up @@ -122,6 +123,92 @@ function DecimatePopover({
)
}

// ---------------------------------------------------------------------------
// Light popover
// ---------------------------------------------------------------------------

export interface LightSettings {
ambientIntensity: number
ambientColor: string
mainIntensity: number
mainColor: string
fillIntensity: number
fillColor: string
}

export const DEFAULT_LIGHT_SETTINGS: LightSettings = {
ambientIntensity: 1.2,
ambientColor: '#ffffff',
mainIntensity: 1.5,
mainColor: '#ffffff',
fillIntensity: 0.6,
fillColor: '#ffffff',
}

function LightPopover({
settings,
onChange,
onClose,
}: {
settings: LightSettings
onChange: (s: LightSettings) => void
onClose: () => void
}) {
function lightRow(
label: string,
colorKey: keyof LightSettings,
intensityKey: keyof LightSettings,
max: number,
) {
const intensity = settings[intensityKey] as number
const color = settings[colorKey] as string
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<ColorPicker
value={color}
onChange={(c) => onChange({ ...settings, [colorKey]: c })}
/>
<span className="text-[10px] text-zinc-400 flex-1">{label}</span>
<span className="text-[10px] text-zinc-500 font-mono">{intensity.toFixed(1)}</span>
</div>
<input
type="range"
min={0}
max={max}
step={0.1}
value={intensity}
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">
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">Lighting</p>
<button
onClick={() => onChange(DEFAULT_LIGHT_SETTINGS)}
className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
>
Reset
</button>
</div>
{lightRow('Ambient', 'ambientColor', 'ambientIntensity', 3)}
{lightRow('Sun', 'mainColor', 'mainIntensity', 4)}
{lightRow('Fill', 'fillColor', 'fillIntensity', 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"
>
Close
</button>
</div>
)
}

// ---------------------------------------------------------------------------
// Smooth popover
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -191,7 +278,8 @@ function SmoothPopover({
export default function GeneratePage(): JSX.Element {
const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle')
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH)
const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | null>(null)
const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | 'light' | null>(null)
const [lightSettings, setLightSettings] = useState<LightSettings>(DEFAULT_LIGHT_SETTINGS)
const [decimating, setDecimating] = useState(false)
const [smoothing, setSmoothing] = useState(false)
const [importing, setImporting] = useState(false)
Expand Down Expand Up @@ -499,13 +587,46 @@ export default function GeneratePage(): JSX.Element {
/>
)}
</div>

</>
)}

{/* Light — always visible, pushed to the right */}
<div className="relative ml-auto">
<button
onClick={() => setOpenPanel((p) => (p === 'light' ? null : 'light'))}
title="Lighting"
className={`flex items-center justify-center w-7 h-7 rounded-lg border transition-colors
${openPanel === 'light'
? 'bg-zinc-700 border-zinc-600 text-zinc-200'
: 'bg-zinc-800 border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<circle cx="12" cy="12" r="4" />
<line x1="12" y1="2" x2="12" y2="5" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
<line x1="2" y1="12" x2="5" y2="12" />
<line x1="19" y1="12" x2="22" y2="12" />
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
</svg>
</button>
{openPanel === 'light' && (
<LightPopover
settings={lightSettings}
onChange={setLightSettings}
onClose={() => setOpenPanel(null)}
/>
)}
</div>
</div>

{/* Viewer area */}
<div className="flex-1 relative overflow-hidden">
<Viewer3D />
<Viewer3D lightSettings={lightSettings} />
<GenerationHUD />

{/* Free memory — overlay top-left */}
Expand Down
10 changes: 6 additions & 4 deletions src/areas/generate/components/Viewer3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ THREE.Mesh.prototype.raycast = acceleratedRaycast
import { useGeneration } from '@shared/hooks/useGeneration'
import { useAppStore } from '@shared/stores/appStore'
import { ViewerToolbar, type ViewMode } from './ViewerToolbar'
import type { LightSettings } from '../GeneratePage'
import { DEFAULT_LIGHT_SETTINGS } from '../GeneratePage'

// ---------------------------------------------------------------------------
// Procedural textures
Expand Down Expand Up @@ -331,7 +333,7 @@ function EmptyState(): JSX.Element {
// Viewer3D
// ---------------------------------------------------------------------------

export default function Viewer3D(): JSX.Element {
export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { lightSettings?: LightSettings }): JSX.Element {
const { currentJob } = useGeneration()
const apiUrl = useAppStore((s) => s.apiUrl)

Expand Down Expand Up @@ -403,9 +405,9 @@ export default function Viewer3D(): JSX.Element {

{modelUrl && currentJob ? (
<Suspense fallback={null}>
<hemisphereLight args={['#ffffff', '#444466', 1.2]} />
<directionalLight position={[5, 8, 5]} intensity={1.5} castShadow />
<directionalLight position={[-4, 2, -4]} intensity={0.6} />
<hemisphereLight args={[lightSettings.ambientColor, '#444466', lightSettings.ambientIntensity]} />
<directionalLight position={[5, 8, 5]} color={lightSettings.mainColor} intensity={lightSettings.mainIntensity} castShadow />
<directionalLight position={[-4, 2, -4]} color={lightSettings.fillColor} intensity={lightSettings.fillIntensity} />
<MeshModel
url={modelUrl}
jobId={currentJob.id}
Expand Down
45 changes: 45 additions & 0 deletions src/shared/components/ui/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useRef } from 'react'

interface ColorPickerProps {
value: string
onChange: (color: string) => void
size?: 'sm' | 'md'
}

export function ColorPicker({ value, onChange, size = 'sm' }: ColorPickerProps): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)

const dim = size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'

return (
<button
type="button"
onClick={() => inputRef.current?.click()}
className={`${dim} rounded border border-zinc-600 hover:border-zinc-400 transition-colors shrink-0 relative overflow-hidden`}
style={{ backgroundColor: value }}
title={value}
>
{/* Checkerboard behind transparent colors */}
<span
className="absolute inset-0 -z-10"
style={{
backgroundImage:
'linear-gradient(45deg, #555 25%, transparent 25%),' +
'linear-gradient(-45deg, #555 25%, transparent 25%),' +
'linear-gradient(45deg, transparent 75%, #555 75%),' +
'linear-gradient(-45deg, transparent 75%, #555 75%)',
backgroundSize: '6px 6px',
backgroundPosition: '0 0, 0 3px, 3px -3px, -3px 0',
}}
/>
<input
ref={inputRef}
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute opacity-0 w-0 h-0 pointer-events-none"
tabIndex={-1}
/>
</button>
)
}
1 change: 1 addition & 0 deletions src/shared/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Tooltip } from './Tooltip'
export { FieldLabel } from './FieldLabel'
export { ConfirmModal } from './ConfirmModal'
export { ColorPicker } from './ColorPicker'