Compare commits

...

5 Commits

Author SHA1 Message Date
BaekRyang
e08f34caab feat: Add per-area physics configuration
- 영역별 물리 설정 `physics` 옵션을 추가했습니다.
- `useMouseInteraction` 훅에서 영역별 물리 설정을 적용하도록 수정했습니다.
- `ImageDistortion` 컴포넌트에서 `selectedAreaId` prop을 제거하고, 마우스 인터랙션 시 `interactingAreaIndices`를 활용하도록 변경했습니다.
- 셰이더에서 텍스처 좌표가 범위를 벗어날 때 부드럽게 페이드 아웃되도록 수정했습니다.
2025-11-24 14:23:06 +09:00
BaekRyang
61952ce79c feat: Add selectedAreaId prop to ImageDistortion
- 선택된 영역 ID를 ImageDistortion 컴포넌트에 추가했습니다.
- selectedAreaId prop을 통해 특정 영역의 자동 애니메이션을 제외할 수 있습니다.
- ImageDistortion 컴포넌트의 animationCallback 로직을 수정하여 selectedAreaId를 반영했습니다.
2025-11-24 14:09:05 +09:00
BaekRyang
4bdae13f7f feat: Add isDragging hook to mouse interaction
- useMouseInteraction 훅에 isDragging 함수를 추가했습니다.
- ImageDistortion 컴포넌트의 애니메이션 콜백에서 마우스 드래그 상태를 감지하여,
  드래그 중일 때는 자동 애니메이션의 dragVector를 0으로 설정하도록 로직을 개선했습니다.
2025-11-24 13:48:06 +09:00
BaekRyang
f6ad8b11b0 feat: Add motion presets for distortion animations
- 왜곡 애니메이션에 사용할 수 있는 다양한 모션 프리셋(horizontal, vertical, rotate-cw 등)을 추가했습니다.
- `DistortionMovement` 인터페이스에 `preset`과 `strength` 옵션을 추가하여 모션 프리셋을 설정할 수 있도록 변경했습니다.
- `presetToVector` 함수와 `isRotationPreset` 함수를 추가하여 모션 프리셋 로직을 구현했습니다.
- `AnimationLoop` 클래스에서 모션 프리셋을 적용하여 `vectorA`를 계산하도록 수정했습니다.
2025-11-24 13:41:36 +09:00
BaekRyang
bbbb49aa1d feat: Update package version and description
- `package.json` 파일에서 패키지 버전이 1.0.0에서 1.0.1로 업데이트되었습니다.
- 패키지 이름이 `@baekryang/responsive-image-canvas`로 변경되었습니다.
- `publishConfig`에 npm 레지스트리 주소가 추가되었습니다.
- `.gitignore` 파일에 `demo.npmrc` 파일이 추가되어 불필요한 파일이 추적되지 않도록 수정되었습니다.
2025-11-24 13:27:11 +09:00
16 changed files with 571 additions and 196 deletions

2
.gitignore vendored
View File

@ -14,4 +14,4 @@ yarn-error.log*
nul nul
# Demo (템플릿 파일, 실제 데모는 별도 저장소) # Demo (템플릿 파일, 실제 데모는 별도 저장소)
/demo /demo.npmrc

View File

@ -74,12 +74,21 @@ void main() {
// dragVector는 정규화된 좌표(0-1)이므로 바로 사용 // dragVector는 정규화된 좌표(0-1)이므로 바로 사용
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i]; vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion; texCoord += distortion;
// clamp를 루프 내에서 제거: 모든 왜곡을 완전히 누적한 후 마지막에 한 번만 clamp
} }
} }
} }
// 모든 왜곡을 누적한 후 최종적으로 한 번만 clamp // 경계 근처에서 부드럽게 페이드 아웃
texCoord = clamp(texCoord, 0.0, 1.0); // 텍스처 좌표가 0~1 범위를 벗어나면 알파값을 줄여서 자연스럽게 처리
gl_FragColor = texture2D(u_texture, texCoord); vec2 edgeDist = min(texCoord, 1.0 - texCoord);
float edgeFade = smoothstep(0.0, 0.05, min(edgeDist.x, edgeDist.y));
// 범위를 벗어난 좌표는 fract로 래핑하여 반복 효과 (더 자연스러움)
vec2 wrappedCoord = fract(texCoord);
vec4 color = texture2D(u_texture, wrappedCoord);
// 경계에서 페이드 아웃 적용
color.a *= edgeFade;
gl_FragColor = color;
} }

36
dist/index.d.mts vendored
View File

@ -12,18 +12,26 @@ interface Point {
* *
*/ */
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad'; type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad';
/**
*
*/
type MotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
/** /**
* *
*/ */
interface DistortionMovement { interface DistortionMovement {
/** 왜곡 시작 벡터 */ /** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point; vectorA: Point;
/** 왜곡 종료 벡터 */ /** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point; vectorB: Point;
/** 애니메이션 지속 시간 (초) */ /** 애니메이션 지속 시간 (초) */
duration: number; duration: number;
/** 적용할 이징 함수 */ /** 적용할 이징 함수 */
easing: EasingFunction; easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
} }
/** /**
* *
@ -41,6 +49,14 @@ interface DistortionArea {
progress: number; progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */ /** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point; dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
} }
/** /**
* *
@ -376,6 +392,18 @@ declare const DEFAULT_AREA: {
}; };
}; };
/**
*
* @param preset
* @param strength (기본값: 0.1)
* @returns (vectorA)
*/
declare function presetToVector(preset: MotionPreset, strength?: number): Point;
/**
*
*/
declare function isRotationPreset(preset?: MotionPreset): boolean;
/** /**
* Three.js * Three.js
*/ */
@ -537,6 +565,8 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[]; updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[];
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void; updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void; reset: () => void;
isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>;
}; };
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity }; export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, isRotationPreset, presetToVector, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

36
dist/index.d.ts vendored
View File

@ -12,18 +12,26 @@ interface Point {
* *
*/ */
type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad'; type EasingFunction = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'easeInQuad' | 'easeOutQuad';
/**
*
*/
type MotionPreset = 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2';
/** /**
* *
*/ */
interface DistortionMovement { interface DistortionMovement {
/** 왜곡 시작 벡터 */ /** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point; vectorA: Point;
/** 왜곡 종료 벡터 */ /** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point; vectorB: Point;
/** 애니메이션 지속 시간 (초) */ /** 애니메이션 지속 시간 (초) */
duration: number; duration: number;
/** 적용할 이징 함수 */ /** 적용할 이징 함수 */
easing: EasingFunction; easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
} }
/** /**
* *
@ -41,6 +49,14 @@ interface DistortionArea {
progress: number; progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */ /** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point; dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
} }
/** /**
* *
@ -376,6 +392,18 @@ declare const DEFAULT_AREA: {
}; };
}; };
/**
*
* @param preset
* @param strength (기본값: 0.1)
* @returns (vectorA)
*/
declare function presetToVector(preset: MotionPreset, strength?: number): Point;
/**
*
*/
declare function isRotationPreset(preset?: MotionPreset): boolean;
/** /**
* Three.js * Three.js
*/ */
@ -537,6 +565,8 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[]; updateInteraction: (areas: DistortionArea[], deltaTime: number) => DistortionArea[];
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void; updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void; reset: () => void;
isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>;
}; };
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity }; export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, isRotationPreset, presetToVector, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

229
dist/index.js vendored
View File

@ -40,6 +40,8 @@ __export(index_exports, {
SpringPhysics: () => SpringPhysics, SpringPhysics: () => SpringPhysics,
ThreeScene: () => ThreeScene, ThreeScene: () => ThreeScene,
applyEasing: () => applyEasing, applyEasing: () => applyEasing,
isRotationPreset: () => isRotationPreset,
presetToVector: () => presetToVector,
useAnimationFrame: () => useAnimationFrame, useAnimationFrame: () => useAnimationFrame,
useDistortionEditor: () => useDistortionEditor, useDistortionEditor: () => useDistortionEditor,
useMouseInteraction: () => useMouseInteraction, useMouseInteraction: () => useMouseInteraction,
@ -244,6 +246,34 @@ var applyEasing = (progress, easingType) => {
return easingFunctions[easingType](clampedProgress); return easingFunctions[easingType](clampedProgress);
}; };
// src/utils/motionPresets.ts
function presetToVector(preset, strength = 0.1) {
switch (preset) {
case "none":
return { x: 0, y: 0 };
case "horizontal":
return { x: strength, y: 0 };
case "vertical":
return { x: 0, y: strength };
case "rotate-cw":
return { x: strength, y: 0 };
case "rotate-ccw":
return { x: -strength, y: 0 };
case "pulse":
return { x: strength, y: strength };
case "diagonal-1":
return { x: strength * 0.707, y: strength * 0.707 };
// √2/2 ≈ 0.707
case "diagonal-2":
return { x: strength * 0.707, y: -strength * 0.707 };
default:
return { x: 0, y: 0 };
}
}
function isRotationPreset(preset) {
return preset === "rotate-cw" || preset === "rotate-ccw";
}
// src/engine/AnimationLoop.ts // src/engine/AnimationLoop.ts
var AnimationLoop = class { var AnimationLoop = class {
/** /**
@ -254,26 +284,43 @@ var AnimationLoop = class {
static updateAreaDragVectors(areas) { static updateAreaDragVectors(areas) {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
if (movement.duration <= 0) { if (movement.duration <= 0 || movement.preset === "none") {
return { return {
...area, ...area,
dragVector: { x: 0, y: 0 } dragVector: { x: 0, y: 0 }
}; };
} }
let baseVector;
if (movement.preset) {
const strength = movement.strength ?? 0.1;
baseVector = presetToVector(movement.preset, strength);
} else {
baseVector = movement.vectorA;
}
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
let dragVector; let dragVector;
if (easedProgress < 0.5) { if (movement.preset && isRotationPreset(movement.preset)) {
const t = easedProgress * 2; const angle = easedProgress * Math.PI * 2;
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === "rotate-cw" ? 1 : -1;
dragVector = { dragVector = {
x: movement.vectorA.x * t, x: Math.cos(angle * direction) * radius,
y: movement.vectorA.y * t y: Math.sin(angle * direction) * radius
}; };
} else { } else {
const t = (easedProgress - 0.5) * 2; if (easedProgress < 0.5) {
dragVector = { const t = easedProgress * 2;
x: movement.vectorA.x * (1 - t), dragVector = {
y: movement.vectorA.y * (1 - t) x: baseVector.x * t,
}; y: baseVector.y * t
};
} else {
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: baseVector.x * (1 - t),
y: baseVector.y * (1 - t)
};
}
} }
return { return {
...area, ...area,
@ -586,14 +633,21 @@ var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef); const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = (0, import_react3.useState)(/* @__PURE__ */ new Set()); const [interactingAreaIndices, setInteractingAreaIndices] = (0, import_react3.useState)(/* @__PURE__ */ new Set());
const springPhysicsMapRef = (0, import_react3.useRef)(/* @__PURE__ */ new Map()); const springPhysicsMapRef = (0, import_react3.useRef)(/* @__PURE__ */ new Map());
const getSpringPhysics = (0, import_react3.useCallback)((areaIndex) => { const getSpringPhysics = (0, import_react3.useCallback)((areaIndex, area) => {
if (!springPhysicsMapRef.current.has(areaIndex)) { if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics)); const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
} }
return springPhysicsMapRef.current.get(areaIndex); return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]); }, [config.physics]);
const updateInteraction = (0, import_react3.useCallback)((areas, deltaTime) => { const updateInteraction = (0, import_react3.useCallback)((areas, deltaTime) => {
if (!config.enabled) return areas; if (!config.enabled) return areas;
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index);
spring.setConfig(area.physics);
}
});
const mouseState = getState(); const mouseState = getState();
if (mouseState.isDragging && mouseState.position) { if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set(); const currentlyInAreas = /* @__PURE__ */ new Set();
@ -601,13 +655,13 @@ var useMouseInteraction = (containerRef, config) => {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) { if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i); currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) { if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i).reset(); getSpringPhysics(i, areas[i]).reset();
} }
} }
} }
interactingAreaIndices.forEach((areaIndex) => { interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) { if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex).returnToEquilibrium(); getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
} }
}); });
setInteractingAreaIndices(currentlyInAreas); setInteractingAreaIndices(currentlyInAreas);
@ -626,7 +680,7 @@ var useMouseInteraction = (containerRef, config) => {
}; };
} }
currentlyInAreas.forEach((areaIndex) => { currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex); const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) { if (velocityMag >= minVel) {
spring.setTarget(clampedVelocity, velocityMult); spring.setTarget(clampedVelocity, velocityMult);
} else { } else {
@ -649,7 +703,7 @@ var useMouseInteraction = (containerRef, config) => {
}; };
} }
interactingAreaIndices.forEach((areaIndex) => { interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex); const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult); spring.setInitialVelocity(clampedVelocity, velocityMult);
}); });
setInteractingAreaIndices(/* @__PURE__ */ new Set()); setInteractingAreaIndices(/* @__PURE__ */ new Set());
@ -692,10 +746,19 @@ var useMouseInteraction = (containerRef, config) => {
}); });
setInteractingAreaIndices(/* @__PURE__ */ new Set()); setInteractingAreaIndices(/* @__PURE__ */ new Set());
}, []); }, []);
const isDragging = (0, import_react3.useCallback)(() => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
const getInteractingAreaIndices = (0, import_react3.useCallback)(() => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return { return {
updateInteraction, updateInteraction,
updateConfig, updateConfig,
reset reset,
isDragging,
getInteractingAreaIndices
}; };
}; };
@ -870,8 +933,20 @@ var ImageDistortion = ({
const animationCallback = (0, import_react4.useCallback)((deltaTime) => { const animationCallback = (0, import_react4.useCallback)((deltaTime) => {
if (!isReady) return; if (!isReady) return;
setCurrentAreas((prevAreas) => { setCurrentAreas((prevAreas) => {
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || /* @__PURE__ */ new Set();
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas); updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
return area;
});
}
if (mouseInteraction?.enabled) { if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime); updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
} }
@ -990,66 +1065,6 @@ var useDistortionEditor = (initialAreas = []) => {
// src/editor/components/EditorCanvas.tsx // src/editor/components/EditorCanvas.tsx
var import_react6 = require("react"); var import_react6 = require("react");
// src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
}
],
// 원 내부 채우기
circleFillColor: "rgba(255, 200, 0, 0.08)",
// 중심점
centerPoint: {
radius: 5,
fillColor: "rgba(255, 200, 0, 1)",
strokeColor: "rgba(255, 255, 255, 0.8)",
strokeWidth: 2
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: "#00aaff",
strokeColor: "white",
strokeWidth: 2,
labelColor: "#00aaff",
labelFontSize: 11
},
// 영역 외곽선
areaOutline: {
selectedColor: "#00aaff",
unselectedColor: "#888",
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: "rgba(0, 170, 255, 0.08)",
// 선택된 영역 배경 (연한 파란색)
unselectedFillColor: "rgba(136, 136, 136, 0.03)"
// 선택 안된 영역 배경 (연한 회색)
}
};
// src/editor/components/EditorCanvas.tsx
var import_jsx_runtime2 = require("react/jsx-runtime"); var import_jsx_runtime2 = require("react/jsx-runtime");
var EditorCanvas = ({ var EditorCanvas = ({
areas, areas,
@ -1673,6 +1688,64 @@ var DistortionEditor = ({
] }) ] })
] }); ] });
}; };
// src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
}
],
// 원 내부 채우기
circleFillColor: "rgba(255, 200, 0, 0.08)",
// 중심점
centerPoint: {
radius: 5,
fillColor: "rgba(255, 200, 0, 1)",
strokeColor: "rgba(255, 255, 255, 0.8)",
strokeWidth: 2
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: "#00aaff",
strokeColor: "white",
strokeWidth: 2,
labelColor: "#00aaff",
labelFontSize: 11
},
// 영역 외곽선
areaOutline: {
selectedColor: "#00aaff",
unselectedColor: "#888",
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: "rgba(0, 170, 255, 0.08)",
// 선택된 영역 배경 (연한 파란색)
unselectedFillColor: "rgba(136, 136, 136, 0.03)"
// 선택 안된 영역 배경 (연한 회색)
}
};
// Annotate the CommonJS export names for ESM import in node: // Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = { 0 && (module.exports = {
ANIMATION_CONFIG, ANIMATION_CONFIG,
@ -1685,6 +1758,8 @@ var DistortionEditor = ({
SpringPhysics, SpringPhysics,
ThreeScene, ThreeScene,
applyEasing, applyEasing,
isRotationPreset,
presetToVector,
useAnimationFrame, useAnimationFrame,
useDistortionEditor, useDistortionEditor,
useMouseInteraction, useMouseInteraction,

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

227
dist/index.mjs vendored
View File

@ -195,6 +195,34 @@ var applyEasing = (progress, easingType) => {
return easingFunctions[easingType](clampedProgress); return easingFunctions[easingType](clampedProgress);
}; };
// src/utils/motionPresets.ts
function presetToVector(preset, strength = 0.1) {
switch (preset) {
case "none":
return { x: 0, y: 0 };
case "horizontal":
return { x: strength, y: 0 };
case "vertical":
return { x: 0, y: strength };
case "rotate-cw":
return { x: strength, y: 0 };
case "rotate-ccw":
return { x: -strength, y: 0 };
case "pulse":
return { x: strength, y: strength };
case "diagonal-1":
return { x: strength * 0.707, y: strength * 0.707 };
// √2/2 ≈ 0.707
case "diagonal-2":
return { x: strength * 0.707, y: -strength * 0.707 };
default:
return { x: 0, y: 0 };
}
}
function isRotationPreset(preset) {
return preset === "rotate-cw" || preset === "rotate-ccw";
}
// src/engine/AnimationLoop.ts // src/engine/AnimationLoop.ts
var AnimationLoop = class { var AnimationLoop = class {
/** /**
@ -205,26 +233,43 @@ var AnimationLoop = class {
static updateAreaDragVectors(areas) { static updateAreaDragVectors(areas) {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
if (movement.duration <= 0) { if (movement.duration <= 0 || movement.preset === "none") {
return { return {
...area, ...area,
dragVector: { x: 0, y: 0 } dragVector: { x: 0, y: 0 }
}; };
} }
let baseVector;
if (movement.preset) {
const strength = movement.strength ?? 0.1;
baseVector = presetToVector(movement.preset, strength);
} else {
baseVector = movement.vectorA;
}
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
let dragVector; let dragVector;
if (easedProgress < 0.5) { if (movement.preset && isRotationPreset(movement.preset)) {
const t = easedProgress * 2; const angle = easedProgress * Math.PI * 2;
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === "rotate-cw" ? 1 : -1;
dragVector = { dragVector = {
x: movement.vectorA.x * t, x: Math.cos(angle * direction) * radius,
y: movement.vectorA.y * t y: Math.sin(angle * direction) * radius
}; };
} else { } else {
const t = (easedProgress - 0.5) * 2; if (easedProgress < 0.5) {
dragVector = { const t = easedProgress * 2;
x: movement.vectorA.x * (1 - t), dragVector = {
y: movement.vectorA.y * (1 - t) x: baseVector.x * t,
}; y: baseVector.y * t
};
} else {
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: baseVector.x * (1 - t),
y: baseVector.y * (1 - t)
};
}
} }
return { return {
...area, ...area,
@ -537,14 +582,21 @@ var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef); const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = useState(/* @__PURE__ */ new Set()); const [interactingAreaIndices, setInteractingAreaIndices] = useState(/* @__PURE__ */ new Set());
const springPhysicsMapRef = useRef3(/* @__PURE__ */ new Map()); const springPhysicsMapRef = useRef3(/* @__PURE__ */ new Map());
const getSpringPhysics = useCallback2((areaIndex) => { const getSpringPhysics = useCallback2((areaIndex, area) => {
if (!springPhysicsMapRef.current.has(areaIndex)) { if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics)); const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
} }
return springPhysicsMapRef.current.get(areaIndex); return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]); }, [config.physics]);
const updateInteraction = useCallback2((areas, deltaTime) => { const updateInteraction = useCallback2((areas, deltaTime) => {
if (!config.enabled) return areas; if (!config.enabled) return areas;
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index);
spring.setConfig(area.physics);
}
});
const mouseState = getState(); const mouseState = getState();
if (mouseState.isDragging && mouseState.position) { if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set(); const currentlyInAreas = /* @__PURE__ */ new Set();
@ -552,13 +604,13 @@ var useMouseInteraction = (containerRef, config) => {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) { if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i); currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) { if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i).reset(); getSpringPhysics(i, areas[i]).reset();
} }
} }
} }
interactingAreaIndices.forEach((areaIndex) => { interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) { if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex).returnToEquilibrium(); getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
} }
}); });
setInteractingAreaIndices(currentlyInAreas); setInteractingAreaIndices(currentlyInAreas);
@ -577,7 +629,7 @@ var useMouseInteraction = (containerRef, config) => {
}; };
} }
currentlyInAreas.forEach((areaIndex) => { currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex); const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) { if (velocityMag >= minVel) {
spring.setTarget(clampedVelocity, velocityMult); spring.setTarget(clampedVelocity, velocityMult);
} else { } else {
@ -600,7 +652,7 @@ var useMouseInteraction = (containerRef, config) => {
}; };
} }
interactingAreaIndices.forEach((areaIndex) => { interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex); const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult); spring.setInitialVelocity(clampedVelocity, velocityMult);
}); });
setInteractingAreaIndices(/* @__PURE__ */ new Set()); setInteractingAreaIndices(/* @__PURE__ */ new Set());
@ -643,10 +695,19 @@ var useMouseInteraction = (containerRef, config) => {
}); });
setInteractingAreaIndices(/* @__PURE__ */ new Set()); setInteractingAreaIndices(/* @__PURE__ */ new Set());
}, []); }, []);
const isDragging = useCallback2(() => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
const getInteractingAreaIndices = useCallback2(() => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return { return {
updateInteraction, updateInteraction,
updateConfig, updateConfig,
reset reset,
isDragging,
getInteractingAreaIndices
}; };
}; };
@ -821,8 +882,20 @@ var ImageDistortion = ({
const animationCallback = useCallback3((deltaTime) => { const animationCallback = useCallback3((deltaTime) => {
if (!isReady) return; if (!isReady) return;
setCurrentAreas((prevAreas) => { setCurrentAreas((prevAreas) => {
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || /* @__PURE__ */ new Set();
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas); updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
return area;
});
}
if (mouseInteraction?.enabled) { if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime); updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
} }
@ -941,66 +1014,6 @@ var useDistortionEditor = (initialAreas = []) => {
// src/editor/components/EditorCanvas.tsx // src/editor/components/EditorCanvas.tsx
import { useRef as useRef5, useEffect as useEffect4, useState as useState4, useCallback as useCallback5, useMemo } from "react"; import { useRef as useRef5, useEffect as useEffect4, useState as useState4, useCallback as useCallback5, useMemo } from "react";
// src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
}
],
// 원 내부 채우기
circleFillColor: "rgba(255, 200, 0, 0.08)",
// 중심점
centerPoint: {
radius: 5,
fillColor: "rgba(255, 200, 0, 1)",
strokeColor: "rgba(255, 255, 255, 0.8)",
strokeWidth: 2
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: "#00aaff",
strokeColor: "white",
strokeWidth: 2,
labelColor: "#00aaff",
labelFontSize: 11
},
// 영역 외곽선
areaOutline: {
selectedColor: "#00aaff",
unselectedColor: "#888",
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: "rgba(0, 170, 255, 0.08)",
// 선택된 영역 배경 (연한 파란색)
unselectedFillColor: "rgba(136, 136, 136, 0.03)"
// 선택 안된 영역 배경 (연한 회색)
}
};
// src/editor/components/EditorCanvas.tsx
import { jsx as jsx2, jsxs } from "react/jsx-runtime"; import { jsx as jsx2, jsxs } from "react/jsx-runtime";
var EditorCanvas = ({ var EditorCanvas = ({
areas, areas,
@ -1624,6 +1637,64 @@ var DistortionEditor = ({
] }) ] })
] }); ] });
}; };
// src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
}
],
// 원 내부 채우기
circleFillColor: "rgba(255, 200, 0, 0.08)",
// 중심점
centerPoint: {
radius: 5,
fillColor: "rgba(255, 200, 0, 1)",
strokeColor: "rgba(255, 255, 255, 0.8)",
strokeWidth: 2
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: "#00aaff",
strokeColor: "white",
strokeWidth: 2,
labelColor: "#00aaff",
labelFontSize: 11
},
// 영역 외곽선
areaOutline: {
selectedColor: "#00aaff",
unselectedColor: "#888",
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: "rgba(0, 170, 255, 0.08)",
// 선택된 영역 배경 (연한 파란색)
unselectedFillColor: "rgba(136, 136, 136, 0.03)"
// 선택 안된 영역 배경 (연한 회색)
}
};
export { export {
ANIMATION_CONFIG, ANIMATION_CONFIG,
AnimationLoop, AnimationLoop,
@ -1635,6 +1706,8 @@ export {
SpringPhysics, SpringPhysics,
ThreeScene, ThreeScene,
applyEasing, applyEasing,
isRotationPreset,
presetToVector,
useAnimationFrame, useAnimationFrame,
useDistortionEditor, useDistortionEditor,
useMouseInteraction, useMouseInteraction,

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,9 @@
{ {
"name": "responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.0.0", "version": "1.0.1",
"publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
},
"description": "React component for interactive image distortion with GPU-accelerated shaders", "description": "React component for interactive image distortion with GPU-accelerated shaders",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View File

@ -214,10 +214,26 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
if (!isReady) return; if (!isReady) return;
setCurrentAreas((prevAreas) => { setCurrentAreas((prevAreas) => {
// 1. 기존 영역 애니메이션 업데이트 // 현재 인터랙션 중인 영역 인덱스 가져오기
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || new Set<number>();
// 1. 자동 애니메이션 업데이트
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime); let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas); updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
// 인터랙션 중인 영역만 dragVector를 0으로 설정
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
return area;
});
}
// 2. 마우스 인터랙션 적용 (기존 dragVector에 스프링 변위 추가) // 2. 마우스 인터랙션 적용 (기존 dragVector에 스프링 변위 추가)
if (mouseInteraction?.enabled) { if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime); updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);

View File

@ -1,4 +1,5 @@
import { applyEasing } from '../utils/easing'; import { applyEasing } from '../utils/easing';
import { presetToVector, isRotationPreset } from '../utils/motionPresets';
import type {DistortionArea, Point} from "../types"; import type {DistortionArea, Point} from "../types";
/** /**
@ -16,34 +17,56 @@ export class AnimationLoop {
return areas.map((area) => { return areas.map((area) => {
const { progress, movement } = area; const { progress, movement } = area;
// duration이 0이면 애니메이션 없음 (dragVector를 0으로 유지) // duration이 0이거나 프리셋이 'none'이면 애니메이션 없음
if (movement.duration <= 0) { if (movement.duration <= 0 || movement.preset === 'none') {
return { return {
...area, ...area,
dragVector: { x: 0, y: 0 }, dragVector: { x: 0, y: 0 },
}; };
} }
// 프리셋이 설정되어 있으면 프리셋 기반 벡터 사용
let baseVector: Point;
if (movement.preset) {
const strength = movement.strength ?? 0.1;
baseVector = presetToVector(movement.preset, strength);
} else {
// 프리셋 없으면 기존 vectorA 사용 (하위 호환성)
baseVector = movement.vectorA;
}
// 이징 적용 // 이징 적용
const easedProgress = applyEasing(progress, movement.easing); const easedProgress = applyEasing(progress, movement.easing);
// 벡터 간 보간 // 벡터 계산
let dragVector: Point; let dragVector: Point;
if (easedProgress < 0.5) { // 회전 프리셋인 경우 원운동
// 0.0 -> 0.5: 0에서 vectorA로 보간 if (movement.preset && isRotationPreset(movement.preset)) {
const t = easedProgress * 2; const angle = easedProgress * Math.PI * 2;
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === 'rotate-cw' ? 1 : -1;
dragVector = { dragVector = {
x: movement.vectorA.x * t, x: Math.cos(angle * direction) * radius,
y: movement.vectorA.y * t, y: Math.sin(angle * direction) * radius,
}; };
} else { } else {
// 0.5 -> 1.0: vectorA에서 0으로 보간 // 일반 왕복 모션
const t = (easedProgress - 0.5) * 2; if (easedProgress < 0.5) {
dragVector = { // 0.0 -> 0.5: 0에서 baseVector로 보간
x: movement.vectorA.x * (1 - t), const t = easedProgress * 2;
y: movement.vectorA.y * (1 - t), dragVector = {
}; x: baseVector.x * t,
y: baseVector.y * t,
};
} else {
// 0.5 -> 1.0: baseVector에서 0으로 보간
const t = (easedProgress - 0.5) * 2;
dragVector = {
x: baseVector.x * (1 - t),
y: baseVector.y * (1 - t),
};
}
} }
return { return {

View File

@ -34,9 +34,11 @@ export const useMouseInteraction = (
/** /**
* ( ) * ( )
*/ */
const getSpringPhysics = useCallback((areaIndex: number): SpringPhysics => { const getSpringPhysics = useCallback((areaIndex: number, area?: DistortionArea): SpringPhysics => {
if (!springPhysicsMapRef.current.has(areaIndex)) { if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics)); // 영역별 물리 설정이 있으면 사용, 없으면 전역 설정 사용
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
} }
return springPhysicsMapRef.current.get(areaIndex)!; return springPhysicsMapRef.current.get(areaIndex)!;
}, [config.physics]); }, [config.physics]);
@ -47,6 +49,14 @@ export const useMouseInteraction = (
const updateInteraction = useCallback((areas: DistortionArea[], deltaTime: number): DistortionArea[] => { const updateInteraction = useCallback((areas: DistortionArea[], deltaTime: number): DistortionArea[] => {
if (!config.enabled) return areas; if (!config.enabled) return areas;
// 영역별 물리 설정이 변경되었을 수 있으므로 모든 스프링 업데이트
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index)!;
spring.setConfig(area.physics);
}
});
const mouseState = getState(); const mouseState = getState();
// 마우스 클릭/드래그 중이고 위치가 있으면 // 마우스 클릭/드래그 중이고 위치가 있으면
@ -59,7 +69,7 @@ export const useMouseInteraction = (
// 새로 진입한 영역이면 스프링 리셋 // 새로 진입한 영역이면 스프링 리셋
if (!interactingAreaIndices.has(i)) { if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i).reset(); getSpringPhysics(i, areas[i]).reset();
} }
} }
} }
@ -67,7 +77,7 @@ export const useMouseInteraction = (
// 이전에 인터랙션하던 영역에서 벗어났으면 평형으로 복귀 // 이전에 인터랙션하던 영역에서 벗어났으면 평형으로 복귀
interactingAreaIndices.forEach((areaIndex) => { interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) { if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex).returnToEquilibrium(); getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
} }
}); });
@ -93,7 +103,7 @@ export const useMouseInteraction = (
} }
currentlyInAreas.forEach((areaIndex) => { currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex); const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) { if (velocityMag >= minVel) {
// 드래그 중: 마우스 속도를 목표로 설정 // 드래그 중: 마우스 속도를 목표로 설정
@ -124,7 +134,7 @@ export const useMouseInteraction = (
// 모든 인터랙션 영역에 초기 속도 설정 // 모든 인터랙션 영역에 초기 속도 설정
interactingAreaIndices.forEach((areaIndex) => { interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex); const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult); spring.setInitialVelocity(clampedVelocity, velocityMult);
}); });
@ -194,9 +204,26 @@ export const useMouseInteraction = (
setInteractingAreaIndices(new Set()); setInteractingAreaIndices(new Set());
}, []); }, []);
/**
*
*/
const isDragging = useCallback((): boolean => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
/**
*
*/
const getInteractingAreaIndices = useCallback((): Set<number> => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return { return {
updateInteraction, updateInteraction,
updateConfig, updateConfig,
reset, reset,
isDragging,
getInteractingAreaIndices,
}; };
}; };

View File

@ -11,6 +11,7 @@ export { useDistortionEditor } from './editor';
export type { export type {
Point, Point,
EasingFunction, EasingFunction,
MotionPreset,
DistortionMovement, DistortionMovement,
DistortionArea, DistortionArea,
AreaBounds, AreaBounds,
@ -31,6 +32,7 @@ export type {
// 유틸리티 함수 // 유틸리티 함수
export { applyEasing } from './utils/easing'; export { applyEasing } from './utils/easing';
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants'; export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
export { presetToVector, isRotationPreset } from './utils/motionPresets';
// 엔진 클래스 (고급 사용자용) // 엔진 클래스 (고급 사용자용)
export { ThreeScene } from './engine/ThreeScene'; export { ThreeScene } from './engine/ThreeScene';

View File

@ -74,12 +74,21 @@ void main() {
// dragVector는 정규화된 좌표(0-1)이므로 바로 사용 // dragVector는 정규화된 좌표(0-1)이므로 바로 사용
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i]; vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion; texCoord += distortion;
// clamp를 루프 내에서 제거: 모든 왜곡을 완전히 누적한 후 마지막에 한 번만 clamp
} }
} }
} }
// 모든 왜곡을 누적한 후 최종적으로 한 번만 clamp // 경계 근처에서 부드럽게 페이드 아웃
texCoord = clamp(texCoord, 0.0, 1.0); // 텍스처 좌표가 0~1 범위를 벗어나면 알파값을 줄여서 자연스럽게 처리
gl_FragColor = texture2D(u_texture, texCoord); vec2 edgeDist = min(texCoord, 1.0 - texCoord);
float edgeFade = smoothstep(0.0, 0.05, min(edgeDist.x, edgeDist.y));
// 범위를 벗어난 좌표는 fract로 래핑하여 반복 효과 (더 자연스러움)
vec2 wrappedCoord = fract(texCoord);
vec4 color = texture2D(u_texture, wrappedCoord);
// 경계에서 페이드 아웃 적용
color.a *= edgeFade;
gl_FragColor = color;
} }

View File

@ -17,18 +17,35 @@ export type EasingFunction =
| 'easeInQuad' | 'easeInQuad'
| 'easeOutQuad'; | 'easeOutQuad';
/**
*
*/
export type MotionPreset =
| 'none' // 없음 (애니메이션 없음)
| 'horizontal' // 좌우 왕복
| 'vertical' // 상하 왕복
| 'rotate-cw' // 시계방향 회전
| 'rotate-ccw' // 반시계방향 회전
| 'pulse' // 펄스 (확대/축소)
| 'diagonal-1' // 대각선 (좌상→우하)
| 'diagonal-2'; // 대각선 (우상→좌하)
/** /**
* *
*/ */
export interface DistortionMovement { export interface DistortionMovement {
/** 왜곡 시작 벡터 */ /** 모션 프리셋 (vectorA, vectorB 대신 사용) */
preset?: MotionPreset;
/** 왜곡 시작 벡터 (preset 없을 때 사용) */
vectorA: Point; vectorA: Point;
/** 왜곡 종료 벡터 */ /** 왜곡 종료 벡터 (preset 없을 때 사용, 현재는 미사용) */
vectorB: Point; vectorB: Point;
/** 애니메이션 지속 시간 (초) */ /** 애니메이션 지속 시간 (초) */
duration: number; duration: number;
/** 적용할 이징 함수 */ /** 적용할 이징 함수 */
easing: EasingFunction; easing: EasingFunction;
/** 모션 강도 (프리셋 적용 시 벡터 크기 조절용, 기본값: 0.1) */
strength?: number;
} }
/** /**
@ -47,6 +64,14 @@ export interface DistortionArea {
progress: number; progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */ /** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point; dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
} }
/** /**

View File

@ -0,0 +1,53 @@
import type {MotionPreset, Point} from '../types';
/**
*
* @param preset
* @param strength (기본값: 0.1)
* @returns (vectorA)
*/
export function presetToVector(preset: MotionPreset, strength: number = 0.1): Point {
switch (preset) {
case 'none':
// 애니메이션 없음
return {x: 0, y: 0};
case 'horizontal':
// 좌우 왕복
return {x: strength, y: 0};
case 'vertical':
// 상하 왕복
return {x: 0, y: strength};
case 'rotate-cw':
// 시계방향 회전 (원운동의 시작점)
return {x: strength, y: 0};
case 'rotate-ccw':
// 반시계방향 회전 (원운동의 시작점)
return {x: -strength, y: 0};
case 'pulse':
// 펄스 (중심에서 바깥으로)
return {x: strength, y: strength};
case 'diagonal-1':
// 대각선 (좌상→우하)
return {x: strength * 0.707, y: strength * 0.707}; // √2/2 ≈ 0.707
case 'diagonal-2':
// 대각선 (우상→좌하)
return {x: strength * 0.707, y: -strength * 0.707};
default:
return {x: 0, y: 0};
}
}
/**
*
*/
export function isRotationPreset(preset?: MotionPreset): boolean {
return preset === 'rotate-cw' || preset === 'rotate-ccw';
}