diff --git a/src-tauri/src/relight.rs b/src-tauri/src/relight.rs index 202cf422c8..7309a19e58 100644 --- a/src-tauri/src/relight.rs +++ b/src-tauri/src/relight.rs @@ -275,7 +275,7 @@ pub fn apply_relight<'a>( let strength = get("intensity", 60.0).max(0.0) / 100.0 * 4.0 / luma.max(1e-3); let radius = 0.05 + get("radius", 30.0) / 100.0 * 1.45; let angle = get("angle", 135.0).to_radians(); - let elevation = get("elevation", 60.0).clamp(0.0, 90.0).to_radians(); + let elevation = get("elevation", 60.0).clamp(-180.0, 180.0).to_radians(); let (plane_x, plane_y) = ( angle.cos() * elevation.cos(), -angle.sin() * elevation.cos(), diff --git a/src/components/adjustments/Effects.tsx b/src/components/adjustments/Effects.tsx index 889e4d548a..96aa7709b2 100644 --- a/src/components/adjustments/Effects.tsx +++ b/src/components/adjustments/Effects.tsx @@ -670,8 +670,8 @@ export default function EffectsPanel({ handleLightChange('elevation', parseInt(e.target.value, 10)) @@ -680,7 +680,6 @@ export default function EffectsPanel({ suffix="°" value={activeLight.elevation} onDragStateChange={onDragStateChange} - fillOrigin="min" /> )} diff --git a/src/components/panel/editor/ImageCanvas.tsx b/src/components/panel/editor/ImageCanvas.tsx index 9e6eab08b0..d6d549e7b7 100644 --- a/src/components/panel/editor/ImageCanvas.tsx +++ b/src/components/panel/editor/ImageCanvas.tsx @@ -14,7 +14,6 @@ import { GuideOrientation, RelightLight, createRelightLight, - getRelightLightColor, } from '../../../utils/adjustments'; import { Mask, SubMask, SubMaskMode, ToolType } from '../right/Masks'; import { AppSettings, BrushSettings, Invokes, SelectedImage } from '../../ui/AppProperties'; @@ -24,6 +23,7 @@ import { useTranslation } from 'react-i18next'; import { useEditorStore } from '../../../store/useEditorStore'; import type { OverlayMode } from '../right/CropPanel'; import CompositionOverlays from './overlays/CompositionOverlays'; +import RelightLightShape, { RelightBasis } from './overlays/RelightLightShape'; import { calculateStraightenAngle } from '../../../utils/cropUtils'; import { toast } from 'react-toastify'; import { @@ -2199,6 +2199,40 @@ const ImageCanvas = memo( [mapCanvasPointToUv, setAdjustments], ); + const aimRelightLight = useCallback( + (id: string, angle: number, elevation: number) => { + setAdjustments((prev: Adjustments) => ({ + ...prev, + relightLights: (prev.relightLights || []).map((l: RelightLight) => + l.id === id ? { ...l, angle, elevation } : l, + ), + })); + }, + [setAdjustments], + ); + + const relightBasis = useMemo((): RelightBasis => { + const width = selectedImage?.width || 1920; + const height = selectedImage?.height || 1080; + const longSide = Math.max(width, height); + const step = 0.01; + const origin = mapRelightUvToCanvas({ x: 0.5, y: 0.5 }); + const px = mapRelightUvToCanvas({ x: 0.5 + (step * longSide) / width, y: 0.5 }); + const py = mapRelightUvToCanvas({ x: 0.5, y: 0.5 + (step * longSide) / height }); + return { + ex: { x: (px.x - origin.x) / step, y: (px.y - origin.y) / step }, + ey: { x: (py.x - origin.x) / step, y: (py.y - origin.y) / step }, + }; + }, [mapRelightUvToCanvas, selectedImage?.width, selectedImage?.height]); + + const showRelightLights = + isRelightPickerActive || + (!!adjustments.relightEnabled && + (adjustments.relightLights?.length ?? 0) > 0 && + !isCropping && + !isMasking && + !isAiEditing); + useEffect(() => { if (!isRelightPickerActive) return; @@ -3507,7 +3541,7 @@ const ImageCanvas = memo( - {(isMasking || isAiEditing || isWbPickerActive || isRelightPickerActive) && ( + {(isMasking || isAiEditing || isWbPickerActive || showRelightLights) && (
)} - {isRelightPickerActive && - (adjustments.relightLights || []) - .filter((light: RelightLight) => light.type !== 'directional') - .map((light: RelightLight) => { - const pos = mapRelightUvToCanvas(light); - return ( - setEditor({ activeRelightLightId: light.id })} - onTouchStart={() => setEditor({ activeRelightLightId: light.id })} - onDragStart={() => setEditor({ isSliderDragging: true })} - onDragEnd={() => setEditor({ isSliderDragging: false })} - onDragMove={(e: any) => - moveRelightLight(light.id, { x: e.target.x(), y: e.target.y() }) - } - onMouseEnter={(e: any) => (e.target.getStage().container().style.cursor = 'move')} - onMouseLeave={(e: any) => (e.target.getStage().container().style.cursor = '')} - /> - ); - })} + {showRelightLights && + (adjustments.relightLights || []).map((light: RelightLight) => ( + setEditor({ activeRelightLightId: light.id })} + onMove={(pos) => moveRelightLight(light.id, pos)} + onAim={(angle, elevation) => aimRelightLight(light.id, angle, elevation)} + onDragStateChange={(isDragging) => setEditor({ isSliderDragging: isDragging })} + onHoverChange={setIsMaskHovered} + onTouchInteraction={() => setIsMaskTouchInteracting(true)} + /> + ))} {isBrushActive && cursorPreview.visible && (!isCloneOrHealActive || diff --git a/src/components/panel/editor/overlays/RelightLightShape.tsx b/src/components/panel/editor/overlays/RelightLightShape.tsx new file mode 100644 index 0000000000..23991db3a1 --- /dev/null +++ b/src/components/panel/editor/overlays/RelightLightShape.tsx @@ -0,0 +1,261 @@ +import { Arrow, Circle, Line } from 'react-konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import { RelightLight, getRelightLightColor } from '../../../../utils/adjustments'; + +interface Point { + x: number; + y: number; +} + +type Vec3 = [number, number, number]; + +export interface RelightBasis { + ex: Point; + ey: Point; +} + +interface RelightLightShapeProps { + light: RelightLight; + pos: Point; + basis: RelightBasis; + zoomScale: number; + isActive: boolean; + onSelect(): void; + onMove(pos: Point): void; + onAim(angle: number, elevation: number): void; + onDragStateChange(isDragging: boolean): void; + onHoverChange(isHovered: boolean): void; + onTouchInteraction(): void; +} + +const ACCENT = '#0ea5e9'; +const RING_SEGMENTS = 48; +const DIRECTIONAL_RAYS = 6; + +const toRad = (deg: number) => (deg * Math.PI) / 180; +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const getAim = (type: RelightLight['type'], angle: number, elevation: number): Vec3 => { + const a = toRad(angle); + const e = toRad(clamp(elevation, -180, 180)); + const sign = type === 'directional' ? -1 : 1; + return [sign * Math.cos(a) * Math.cos(e), -sign * Math.sin(a) * Math.cos(e), Math.sin(e)]; +}; + +export default function RelightLightShape({ + light, + pos, + basis, + zoomScale, + isActive, + onSelect, + onMove, + onAim, + onDragStateChange, + onHoverChange, + onTouchInteraction, +}: RelightLightShapeProps) { + const { ex, ey } = basis; + const px = (screenPx: number) => screenPx / zoomScale; + const unit = Math.hypot(ex.x, ex.y) || 1; + const color = getRelightLightColor(light); + const stroke = isActive ? ACCENT : 'white'; + + const project = (v: Vec3): Point => ({ + x: pos.x + v[0] * ex.x + v[1] * ey.x, + y: pos.y + v[0] * ex.y + v[1] * ey.y, + }); + + const handleProps = (cursor: string) => ({ + draggable: true, + onMouseDown: onSelect, + onTouchStart: () => { + onTouchInteraction(); + onSelect(); + }, + onDragStart: () => onDragStateChange(true), + onDragEnd: () => onDragStateChange(false), + onMouseEnter: (e: KonvaEventObject) => { + const stage = e.target.getStage(); + if (stage) stage.container().style.cursor = cursor; + onHoverChange(true); + }, + onMouseLeave: (e: KonvaEventObject) => { + const stage = e.target.getStage(); + if (stage) stage.container().style.cursor = ''; + onHoverChange(false); + }, + }); + + const positionHandle = ( + ) => onMove({ x: e.target.x(), y: e.target.y() })} + /> + ); + + if (light.type === 'point') return positionHandle; + + const isSpot = light.type === 'spot'; + const aim = getAim(light.type, light.angle, light.elevation); + const planar = Math.hypot(aim[0], aim[1]); + const u: Vec3 = planar > 1e-4 ? [aim[1] / planar, -aim[0] / planar, 0] : [1, 0, 0]; + const v: Vec3 = [-aim[2] * u[1], aim[2] * u[0], aim[0] * u[1] - aim[1] * u[0]]; + + const ringPoint = (dist: number, radius: number, t: number): Point => { + const c = Math.cos(t) * radius; + const s = Math.sin(t) * radius; + return project([aim[0] * dist + c * u[0] + s * v[0], aim[1] * dist + c * u[1] + s * v[1], 0]); + }; + + const ring = (dist: number, radius: number, from: number, to: number): number[] => { + const points: number[] = []; + const segments = Math.max(2, Math.round((RING_SEGMENTS * (to - from)) / (2 * Math.PI))); + for (let i = 0; i <= segments; i++) { + const p = ringPoint(dist, radius, from + ((to - from) * i) / segments); + points.push(p.x, p.y); + } + return points; + }; + + const lineProps = { + stroke, + strokeWidth: px(1.5), + shadowColor: 'black', + shadowBlur: 3, + shadowOpacity: 0.7, + listening: false, + perfectDrawEnabled: false, + }; + const dash = [px(4), px(4)]; + + const halfAngle = toRad(5 + (clamp(light.cone, 0, 100) / 100) * 80); + const slant = 0.06 + (0.05 + (clamp(light.radius, 0, 100) / 100) * 1.45) * 0.25; + const ringDist = isSpot ? slant * Math.cos(halfAngle) : 0; + const ringRadius = isSpot ? slant * Math.sin(halfAngle) : px(20) / unit; + const aimLength = isSpot ? slant : px(64) / unit; + const aimTip = project([aim[0] * aimLength, aim[1] * aimLength, 0]); + + const handleAimDrag = (e: KonvaEventObject) => { + const det = ex.x * ey.y - ex.y * ey.x; + if (Math.abs(det) < 1e-9) return; + const wx = e.target.x() - pos.x; + const wy = e.target.y() - pos.y; + const bx = (wx * ey.y - wy * ey.x) / det / aimLength; + const by = (ex.x * wy - ex.y * wx) / det / aimLength; + const length = Math.hypot(bx, by); + const sign = isSpot ? 1 : -1; + const depth = (aim[2] < 0 ? -1 : 1) * Math.sqrt(1 - Math.min(1, length) ** 2); + const elevation = Math.round((Math.asin(depth) * 180) / Math.PI); + const angle = + length > 1e-3 + ? Math.round((((Math.atan2(-sign * by, sign * bx) * 180) / Math.PI) % 360) + 360) % 360 + : light.angle; + const snapped = getAim(light.type, angle, elevation); + e.target.position(project([snapped[0] * aimLength, snapped[1] * aimLength, 0])); + onAim(angle, elevation); + }; + + const sides: number[][] = []; + if (isSpot) { + if (Math.abs(aim[2]) >= Math.cos(halfAngle) - 1e-3) { + for (let i = 0; i < 4; i++) { + const p = ringPoint(ringDist, ringRadius, (i * Math.PI) / 2); + sides.push([pos.x, pos.y, p.x, p.y]); + } + } else { + const ax = aimTip.x - pos.x; + const ay = aimTip.y - pos.y; + let minAngle = Infinity; + let maxAngle = -Infinity; + let minPoint = pos; + let maxPoint = pos; + for (let i = 0; i < RING_SEGMENTS; i++) { + const p = ringPoint(ringDist, ringRadius, (i * 2 * Math.PI) / RING_SEGMENTS); + const dx = p.x - pos.x; + const dy = p.y - pos.y; + const a = Math.atan2(ax * dy - ay * dx, ax * dx + ay * dy); + if (a < minAngle) { + minAngle = a; + minPoint = p; + } + if (a > maxAngle) { + maxAngle = a; + maxPoint = p; + } + } + sides.push([pos.x, pos.y, minPoint.x, minPoint.y], [pos.x, pos.y, maxPoint.x, maxPoint.y]); + } + } + + const rays: Array<{ from: Point; to: Point }> = []; + if (!isSpot) { + for (let i = 0; i < DIRECTIONAL_RAYS; i++) { + const from = ringPoint(0, ringRadius, (i * 2 * Math.PI) / DIRECTIONAL_RAYS); + rays.push({ from, to: { x: from.x + aimTip.x - pos.x, y: from.y + aimTip.y - pos.y } }); + } + } + const facesViewer = aim[2] < 0; + const showArrows = Math.hypot(aimTip.x - pos.x, aimTip.y - pos.y) > px(6); + const innerRadius = ringDist * Math.tan(halfAngle * (1 - clamp(light.feather, 0, 100) / 100)); + + return ( + <> + + + + {isSpot && innerRadius > 1e-4 && innerRadius < ringRadius - 1e-4 && ( + + )} + {sides.map((points, i) => ( + + ))} + {rays.map(({ from, to }, i) => + showArrows ? ( + + ) : ( + + ), + )} + + + {positionHandle} + + ); +}