BaekRyang ecf3e81101 Refactor step easing into independent snapSteps property
- EasingFunction에서 step 옵션을 제거하고 독립적인 snapSteps 속성으로 분리
- AnimationLoop에 snapSteps 기반의 움직임 양자화 로직 구현
- 에디터 파라미터 패널에 움직임 단계 조절 슬라이더 추가
- 기본 설정값에 SNAP_STEPS 추가 및 패키지 버전 업데이트 (1.2.10)
2026-02-25 16:14:46 +09:00

1756 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
ANIMATION_CONFIG: () => ANIMATION_CONFIG,
AnimationLoop: () => AnimationLoop,
AreaList: () => AreaList,
DEFAULT_AREA: () => DEFAULT_AREA,
DEFAULT_EDITOR_CANVAS_STYLE: () => DEFAULT_EDITOR_CANVAS_STYLE,
EditorCanvas: () => EditorCanvas,
ImageDistortion: () => ImageDistortion,
ParameterPanel: () => ParameterPanel,
SHADER_CONFIG: () => SHADER_CONFIG,
ShaderManager: () => ShaderManager,
SpringPhysics: () => SpringPhysics,
ThreeScene: () => ThreeScene,
applyEasing: () => applyEasing,
getRegisteredPresets: () => getRegisteredPresets,
hasPreset: () => hasPreset,
isRotationPreset: () => isRotationPreset,
presetToVector: () => presetToVector,
registerMotionPreset: () => registerMotionPreset,
registerMotionPresets: () => registerMotionPresets,
resetToBuiltInPresets: () => resetToBuiltInPresets,
unregisterMotionPreset: () => unregisterMotionPreset,
useAnimationFrame: () => useAnimationFrame,
useDistortionEditor: () => useDistortionEditor,
useMouseInteraction: () => useMouseInteraction,
useMouseVelocity: () => useMouseVelocity
});
module.exports = __toCommonJS(index_exports);
// src/components/ImageDistortion.tsx
var import_react4 = require("react");
var THREE2 = __toESM(require("three"));
// src/engine/ThreeScene.ts
var THREE = __toESM(require("three"));
var ThreeScene = class {
constructor(container) {
this.container = container;
this.mesh = null;
/**
* 윈도우 리사이즈 핸들러
*/
this.handleResize = () => {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
this.renderer.setSize(width, height);
this.uniforms.u_resolution.value.set(width, height);
if (this.mesh) {
this.render();
}
};
this.scene = new THREE.Scene();
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.container.appendChild(this.renderer.domElement);
this.uniforms = {
u_resolution: { value: new THREE.Vector2() },
u_texture: { value: null },
u_points: { value: new Float32Array(64) },
// 32포인트 × 2(x,y)
u_numAreas: { value: 0 },
u_dragVectors: { value: new Float32Array(16) },
// 8벡터 × 2(x,y)
u_distortionStrengths: { value: new Float32Array(8) },
u_lensEffects: { value: new Float32Array(8) }
};
this.handleResize();
window.addEventListener("resize", this.handleResize);
}
/**
* 셰이더 머티리얼 설정
* @param vertexShader 버텍스 셰이더 소스
* @param fragmentShader 프래그먼트 셰이더 소스
*/
setShaderMaterial(vertexShader, fragmentShader) {
console.log("[ThreeScene] setShaderMaterial \uD638\uCD9C\uB428");
console.log("[ThreeScene] vertexShader \uAE38\uC774:", vertexShader.length);
console.log("[ThreeScene] fragmentShader \uAE38\uC774:", fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader,
fragmentShader
});
console.log("[ThreeScene] ShaderMaterial \uC0DD\uC131\uB428");
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC131\uACF5!");
} catch (e) {
console.error("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC5D0\uB7EC:", e);
}
if (this.mesh) {
this.scene.remove(this.mesh);
}
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
}
/**
* 유니폼 값 업데이트
* @param updates 업데이트할 유니폼 값들
*/
updateUniforms(updates) {
Object.keys(updates).forEach((key) => {
const uniformKey = key;
this.uniforms[uniformKey].value = updates[uniformKey].value;
});
}
/**
* 씬 렌더링
*/
render() {
this.renderer.render(this.scene, this.camera);
}
/**
* 현재 해상도 가져오기
*/
getResolution() {
return {
x: this.uniforms.u_resolution.value.x,
y: this.uniforms.u_resolution.value.y
};
}
/**
* 리소스 정리
*/
dispose() {
window.removeEventListener("resize", this.handleResize);
this.renderer.dispose();
if (this.mesh) {
this.mesh.geometry.dispose();
this.mesh.material.dispose();
}
if (this.container.contains(this.renderer.domElement)) {
this.container.removeChild(this.renderer.domElement);
}
}
};
// src/engine/ShaderManager.ts
var ShaderManager = class {
constructor() {
this.vertexShaderSource = null;
this.fragmentShaderSource = null;
}
/**
* 셰이더 파일들을 비동기로 로드
* @param vertexPath 버텍스 셰이더 파일 경로
* @param fragmentPath 프래그먼트 셰이더 파일 경로
* @returns 로드된 셰이더 소스 코드
*/
async loadShaders(vertexPath, fragmentPath) {
console.log("[ShaderManager] loadShaders \uC2DC\uC791:", { vertexPath, fragmentPath });
try {
console.log("[ShaderManager] fetch \uC2DC\uC791...");
const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath),
fetch(fragmentPath)
]);
console.log("[ShaderManager] fetch \uC644\uB8CC:", {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) {
throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`);
}
if (!fragmentResponse.ok) {
throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`);
}
console.log("[ShaderManager] text() \uBCC0\uD658 \uC2DC\uC791...");
this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text();
console.log("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC644\uB8CC!", {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return {
vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource
};
} catch (error) {
console.error("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4");
}
}
/**
* 버텍스 셰이더 소스 코드 반환
*/
getVertexShader() {
if (!this.vertexShaderSource) {
throw new Error("\uBC84\uD14D\uC2A4 \uC170\uC774\uB354\uAC00 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4");
}
return this.vertexShaderSource;
}
/**
* 프래그먼트 셰이더 소스 코드 반환
*/
getFragmentShader() {
if (!this.fragmentShaderSource) {
throw new Error("\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354\uAC00 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4");
}
return this.fragmentShaderSource;
}
};
// src/utils/easing.ts
var easingFunctions = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => t * (2 - t),
easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3)
};
var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
return easingFunctions[easingType](clampedProgress);
};
// src/utils/motionPresets.ts
var presetRegistry = /* @__PURE__ */ new Map();
var rotationPresets = /* @__PURE__ */ new Set(["rotate-cw", "rotate-ccw"]);
var BUILT_IN_PRESETS = {
"none": () => ({ x: 0, y: 0 }),
"horizontal": (strength) => ({ x: strength, y: 0 }),
"vertical": (strength) => ({ x: 0, y: strength }),
"rotate-cw": (strength) => ({ x: strength, y: 0 }),
"rotate-ccw": (strength) => ({ x: -strength, y: 0 }),
"pulse": (strength) => ({ x: strength, y: strength }),
"diagonal-1": (strength) => ({ x: strength * 0.707, y: strength * 0.707 }),
"diagonal-2": (strength) => ({ x: strength * 0.707, y: -strength * 0.707 })
};
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
function registerMotionPreset(name, definition, options) {
presetRegistry.set(name, definition);
if (options?.isRotation) {
rotationPresets.add(name);
} else {
rotationPresets.delete(name);
}
}
function registerMotionPresets(presets, rotationPresetNames) {
Object.entries(presets).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresetNames?.forEach((name) => rotationPresets.add(name));
}
function unregisterMotionPreset(name) {
rotationPresets.delete(name);
return presetRegistry.delete(name);
}
function getRegisteredPresets() {
return Array.from(presetRegistry.keys());
}
function hasPreset(name) {
return presetRegistry.has(name);
}
function resetToBuiltInPresets() {
presetRegistry.clear();
rotationPresets.clear();
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresets.add("rotate-cw");
rotationPresets.add("rotate-ccw");
}
function presetToVector(preset, strength = 0.1) {
const definition = presetRegistry.get(preset);
if (definition) {
return definition(strength);
}
console.warn(`Unknown motion preset: "${preset}". Falling back to "none".`);
return { x: 0, y: 0 };
}
function isRotationPreset(preset) {
if (!preset) return false;
return rotationPresets.has(preset);
}
// src/engine/AnimationLoop.ts
var AnimationLoop = class {
/**
* 영역들의 드래그 벡터를 현재 진행도에 따라 업데이트
* @param areas 왜곡 영역 배열
* @returns 업데이트된 영역 배열
*/
static updateAreaDragVectors(areas) {
return areas.map((area) => {
const { progress, movement } = area;
if (movement.duration <= 0 || movement.preset === "none") {
return {
...area,
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);
let dragVector;
const snapSteps = area.snapSteps ?? 0;
if (movement.preset && isRotationPreset(movement.preset)) {
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === "rotate-cw" ? 1 : -1;
if (snapSteps > 0) {
const totalAngleSteps = snapSteps * 4;
const rawAngle = progress * Math.PI * 2;
const quantizedAngle = Math.round(rawAngle / (Math.PI * 2) * totalAngleSteps) / totalAngleSteps * Math.PI * 2;
dragVector = {
x: Math.cos(quantizedAngle * direction) * radius,
y: Math.sin(quantizedAngle * direction) * radius
};
} else {
const angle = easedProgress * Math.PI * 2;
dragVector = {
x: Math.cos(angle * direction) * radius,
y: Math.sin(angle * direction) * radius
};
}
} else {
if (snapSteps > 0) {
const oscillation = Math.sin(progress * Math.PI * 2);
const quantized = Math.round(oscillation * snapSteps) / snapSteps;
dragVector = {
x: baseVector.x * quantized,
y: baseVector.y * quantized
};
} else {
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation
};
}
}
return {
...area,
dragVector
};
});
}
/**
* 모든 영역의 진행도를 델타 타임만큼 업데이트
* @param areas 왜곡 영역 배열
* @param deltaTime 델타 타임 (초)
* @returns 업데이트된 영역 배열
*/
static updateProgress(areas, deltaTime) {
return areas.map((area) => {
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1;
return {
...area,
progress: newProgress
};
});
}
};
// src/hooks/useAnimationFrame.ts
var import_react = require("react");
var useAnimationFrame = (callback, isPlaying = true) => {
const requestRef = (0, import_react.useRef)(void 0);
const previousTimeRef = (0, import_react.useRef)(void 0);
(0, import_react.useEffect)(() => {
if (!isPlaying) return;
const animate = (time) => {
if (previousTimeRef.current !== void 0) {
const deltaTime = (time - previousTimeRef.current) / 1e3;
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [callback, isPlaying]);
};
// src/hooks/useMouseInteraction.ts
var import_react3 = require("react");
// src/hooks/useMouseVelocity.ts
var import_react2 = require("react");
var useMouseVelocity = (containerRef) => {
const mouseStateRef = (0, import_react2.useRef)({
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
});
const lastUpdateTimeRef = (0, import_react2.useRef)(Date.now());
const prevVelocityRef = (0, import_react2.useRef)({ x: 0, y: 0 });
const toNormalized = (0, import_react2.useCallback)((clientX, clientY) => {
if (!containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height
};
}, [containerRef]);
const updatePosition = (0, import_react2.useCallback)((clientX, clientY) => {
const now = Date.now();
const deltaTime = (now - lastUpdateTimeRef.current) / 1e3;
lastUpdateTimeRef.current = now;
const normalizedPos = toNormalized(clientX, clientY);
if (!normalizedPos) return;
const state = mouseStateRef.current;
const prevPos = state.position;
let velocity = { x: 0, y: 0 };
if (prevPos && deltaTime > 0) {
velocity = {
x: (normalizedPos.x - prevPos.x) / deltaTime,
y: (normalizedPos.y - prevPos.y) / deltaTime
};
}
const prevVel = prevVelocityRef.current;
let acceleration = { x: 0, y: 0 };
if (deltaTime > 0) {
acceleration = {
x: (velocity.x - prevVel.x) / deltaTime,
y: (velocity.y - prevVel.y) / deltaTime
};
}
mouseStateRef.current = {
position: normalizedPos,
prevPosition: prevPos,
velocity,
acceleration,
isHovering: true,
isDragging: state.isDragging
};
prevVelocityRef.current = velocity;
}, [toNormalized]);
const handleMouseMove = (0, import_react2.useCallback)((e) => {
updatePosition(e.clientX, e.clientY);
}, [updatePosition]);
const handleMouseEnter = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isHovering = true;
}, []);
const handleMouseLeave = (0, import_react2.useCallback)(() => {
mouseStateRef.current = {
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
};
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
const handleMouseDown = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isDragging = true;
}, []);
const handleMouseUp = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isDragging = false;
}, []);
const handleTouchMove = (0, import_react2.useCallback)((e) => {
e.preventDefault();
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchStart = (0, import_react2.useCallback)((e) => {
e.preventDefault();
mouseStateRef.current.isDragging = true;
mouseStateRef.current.isHovering = true;
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchEnd = (0, import_react2.useCallback)(() => {
mouseStateRef.current.isDragging = false;
mouseStateRef.current.isHovering = false;
mouseStateRef.current.position = null;
mouseStateRef.current.prevPosition = null;
mouseStateRef.current.velocity = { x: 0, y: 0 };
mouseStateRef.current.acceleration = { x: 0, y: 0 };
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
(0, import_react2.useEffect)(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseenter", handleMouseEnter);
container.addEventListener("mouseleave", handleMouseLeave);
container.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
container.addEventListener("touchmove", handleTouchMove, { passive: false });
container.addEventListener("touchstart", handleTouchStart, { passive: false });
container.addEventListener("touchend", handleTouchEnd);
container.addEventListener("touchcancel", handleTouchEnd);
return () => {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseenter", handleMouseEnter);
container.removeEventListener("mouseleave", handleMouseLeave);
container.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
container.removeEventListener("touchmove", handleTouchMove);
container.removeEventListener("touchstart", handleTouchStart);
container.removeEventListener("touchend", handleTouchEnd);
container.removeEventListener("touchcancel", handleTouchEnd);
};
}, [containerRef, handleMouseMove, handleMouseEnter, handleMouseLeave, handleMouseDown, handleMouseUp, handleTouchMove, handleTouchStart, handleTouchEnd]);
const getState = (0, import_react2.useCallback)(() => {
return { ...mouseStateRef.current };
}, []);
return {
getState
};
};
// src/engine/SpringPhysics.ts
var SpringPhysics = class {
constructor(config) {
this.config = config;
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 물리 파라미터 업데이트
*/
setConfig(config) {
this.config = { ...this.config, ...config };
}
/**
* 목표 위치 설정 (마우스 속도 기반)
*/
setTarget(velocity, velocityMultiplier = 1) {
this.state.target = {
x: velocity.x * velocityMultiplier,
y: velocity.y * velocityMultiplier
};
}
/**
* 초기 속도 설정 (드래그 방향과 속도를 즉시 반영)
* 드래그 방향으로 즉시 튕기는 효과
*/
setInitialVelocity(velocity, multiplier = 1) {
this.state.velocity = {
x: velocity.x * multiplier,
y: velocity.y * multiplier
};
this.state.target = { x: 0, y: 0 };
}
/**
* 스프링 물리 업데이트 (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime) {
const { stiffness, damping, mass } = this.config;
const { displacement, velocity, target } = this.state;
const dx = displacement.x - target.x;
const dy = displacement.y - target.y;
const springForceX = -stiffness * dx;
const springForceY = -stiffness * dy;
const dampingForceX = -damping * velocity.x;
const dampingForceY = -damping * velocity.y;
const totalForceX = springForceX + dampingForceX;
const totalForceY = springForceY + dampingForceY;
const accelerationX = totalForceX / mass;
const accelerationY = totalForceY / mass;
const newVelocityX = velocity.x + accelerationX * deltaTime;
const newVelocityY = velocity.y + accelerationY * deltaTime;
const newDisplacementX = displacement.x + newVelocityX * deltaTime;
const newDisplacementY = displacement.y + newVelocityY * deltaTime;
this.state = {
displacement: { x: newDisplacementX, y: newDisplacementY },
velocity: { x: newVelocityX, y: newVelocityY },
target
};
const isNearlyZero = (val) => Math.abs(val) < 1e-4;
if (isNearlyZero(this.state.displacement.x) && isNearlyZero(this.state.displacement.y) && isNearlyZero(this.state.velocity.x) && isNearlyZero(this.state.velocity.y)) {
this.reset();
}
return this.state.displacement;
}
/**
* 즉시 충격 적용 (마우스 가속도 기반)
*/
applyImpulse(acceleration, multiplier = 1) {
this.state.velocity.x += acceleration.x * multiplier;
this.state.velocity.y += acceleration.y * multiplier;
}
/**
* 현재 변위 가져오기
*/
getDisplacement() {
return { ...this.state.displacement };
}
/**
* 현재 속도 가져오기
*/
getVelocity() {
return { ...this.state.velocity };
}
/**
* 상태 리셋
*/
reset() {
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 마우스가 멈췄을 때 목표를 0으로 설정 (평형 상태로 복귀)
*/
returnToEquilibrium() {
this.state.target = { x: 0, y: 0 };
}
};
// src/hooks/useMouseInteraction.ts
var isPointInPolygon = (point, polygon) => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
};
var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = (0, import_react3.useState)(/* @__PURE__ */ new Set());
const springPhysicsMapRef = (0, import_react3.useRef)(/* @__PURE__ */ new Map());
const getSpringPhysics = (0, import_react3.useCallback)((areaIndex, area) => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
}
return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]);
const updateInteraction = (0, import_react3.useCallback)((areas, deltaTime) => {
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();
if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set();
for (let i = 0; i < areas.length; i++) {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i, areas[i]).reset();
}
}
}
interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
}
});
setInteractingAreaIndices(currentlyInAreas);
const velocityMult = config.velocityMultiplier || 1;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
const minVel = config.minVelocity || 0.05;
const maxVel = config.maxVelocity || 5;
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) {
spring.setTarget(clampedVelocity, velocityMult);
} else {
spring.returnToEquilibrium();
}
});
} else {
if (interactingAreaIndices.size > 0) {
const velocityMult = config.velocityMultiplier || 1;
const maxVel = config.maxVelocity || 5;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult);
});
setInteractingAreaIndices(/* @__PURE__ */ new Set());
}
}
return areas.map((area, index) => {
const spring = springPhysicsMapRef.current.get(index);
if (!spring) return area;
const springVelocity = spring.getVelocity();
const springDisplacement = spring.getDisplacement();
const isSpringActive = Math.sqrt(springVelocity.x ** 2 + springVelocity.y ** 2) > 1e-3 || Math.sqrt(springDisplacement.x ** 2 + springDisplacement.y ** 2) > 1e-3;
if (!interactingAreaIndices.has(index) && !isSpringActive) {
return area;
}
const displacement = spring.update(deltaTime);
const displacementMag = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
if (displacementMag < 1e-3) {
return area;
}
return {
...area,
dragVector: {
x: area.dragVector.x - displacement.x,
y: area.dragVector.y - displacement.y
}
};
});
}, [config, getState, interactingAreaIndices, getSpringPhysics]);
const updateConfig = (0, import_react3.useCallback)((newConfig) => {
const physicsConfig = newConfig.physics;
if (physicsConfig) {
springPhysicsMapRef.current.forEach((spring) => {
spring.setConfig(physicsConfig);
});
}
}, []);
const reset = (0, import_react3.useCallback)(() => {
springPhysicsMapRef.current.forEach((spring) => {
spring.reset();
});
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 {
updateInteraction,
updateConfig,
reset,
isDragging,
getInteractingAreaIndices
};
};
// src/utils/constants.ts
var SHADER_CONFIG = {
/** 최대 영역 개수 */
MAX_AREAS: 8,
/** 최대 포인트 개수 (8영역 × 4포인트) */
MAX_POINTS: 32,
/** 최대 드래그 벡터 개수 */
MAX_DRAG_VECTORS: 8,
/** 최대 강도 배열 크기 */
MAX_STRENGTHS: 8,
/** 최대 렌즈 효과 배열 크기 */
MAX_LENS_EFFECTS: 8
};
var ANIMATION_CONFIG = {
/** 목표 FPS */
TARGET_FPS: 60,
/** 델타 타임 (약 16.67ms) */
DELTA_TIME: 1 / 60
};
var DEFAULT_AREA = {
/** 기본 왜곡 강도 */
DISTORTION_STRENGTH: 0.5,
/** 기본 애니메이션 지속 시간 (초) */
DURATION: 2,
/** 기본 이징 함수 */
EASING: "easeInOut",
/** 기본 벡터 A */
VECTOR_A: { x: 0.1, y: 0.1 },
/** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 },
/** 기본 렌즈 효과 강도 */
LENS_STRENGTH: 0,
/** 기본 스텝 양자화 단계 수 (0=없음) */
SNAP_STEPS: 0
};
// src/components/ImageDistortion.tsx
var import_jsx_runtime = require("react/jsx-runtime");
var ImageDistortion = ({
imageSrc,
areas,
vertexShaderPath,
fragmentShaderPath,
style,
className,
mouseInteraction
}) => {
const containerRef = (0, import_react4.useRef)(null);
const sceneRef = (0, import_react4.useRef)(null);
const shaderManagerRef = (0, import_react4.useRef)(new ShaderManager());
const textureRef = (0, import_react4.useRef)(null);
const [isReady, setIsReady] = (0, import_react4.useState)(false);
const [imageLoaded, setImageLoaded] = (0, import_react4.useState)(false);
const [currentAreas, setCurrentAreas] = (0, import_react4.useState)(areas);
const mouseInteractionHook = useMouseInteraction(
containerRef,
mouseInteraction || {
enabled: false,
physics: {
stiffness: 100,
damping: 10,
mass: 1,
influenceRadius: 0.2,
maxStrength: 1
}
}
);
(0, import_react4.useEffect)(() => {
setCurrentAreas(areas);
}, [areas]);
(0, import_react4.useEffect)(() => {
if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction);
}
}, [mouseInteraction, mouseInteractionHook]);
(0, import_react4.useEffect)(() => {
console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current);
if (!containerRef.current) {
console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
return;
}
console.log("[ImageDistortion] \uCD08\uAE30\uD654 \uC2DC\uC791");
const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene;
const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl";
const fragPath = fragmentShaderPath || "/shaders/distortion.frag.glsl";
console.log("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC2DC\uB3C4:", { vertPath, fragPath });
shaderManagerRef.current.loadShaders(vertPath, fragPath).then(({ vertex, fragment }) => {
console.log("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC131\uACF5");
scene.setShaderMaterial(vertex, fragment);
setIsReady(true);
}).catch((error) => {
console.error("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
});
return () => {
scene.dispose();
if (textureRef.current) {
textureRef.current.dispose();
}
};
}, [vertexShaderPath, fragmentShaderPath]);
(0, import_react4.useEffect)(() => {
if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady });
return;
}
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false);
const loader = new THREE2.TextureLoader();
loader.load(
imageSrc,
(texture) => {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5!", {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) {
sceneRef.current.updateUniforms({
u_texture: { value: texture }
});
sceneRef.current.render();
console.log("[ImageDistortion] \uD14D\uC2A4\uCC98 \uC5C5\uB370\uC774\uD2B8 \uBC0F \uB80C\uB354\uB9C1 \uC644\uB8CC");
}
},
(progress) => {
console.log(
"[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911...",
Math.round(progress.loaded / progress.total * 100) + "%"
);
},
(error) => {
console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error);
setImageLoaded(false);
}
);
return () => {
if (textureRef.current) {
textureRef.current.dispose();
textureRef.current = null;
}
};
}, [imageSrc, isReady]);
(0, import_react4.useEffect)(() => {
if (!sceneRef.current || !isReady) return;
const resolution = sceneRef.current.getResolution();
const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
currentAreas.forEach((area, areaIndex) => {
area.basePoints.forEach((point, pointIndex) => {
const index = (areaIndex * 4 + pointIndex) * 2;
points[index] = point.x;
points[index + 1] = 1 - point.y;
});
});
const dragVectors = new Float32Array(SHADER_CONFIG.MAX_DRAG_VECTORS * 2);
currentAreas.forEach((area, index) => {
const baseIndex = index * 2;
dragVectors[baseIndex] = area.dragVector.x;
dragVectors[baseIndex + 1] = -area.dragVector.y;
});
const strengths = new Float32Array(SHADER_CONFIG.MAX_STRENGTHS);
currentAreas.forEach((area, index) => {
strengths[index] = area.distortionStrength;
});
const lensEffects = new Float32Array(SHADER_CONFIG.MAX_LENS_EFFECTS);
currentAreas.forEach((area, index) => {
lensEffects[index] = area.lensEffect?.strength ?? 0;
});
sceneRef.current.updateUniforms({
u_numAreas: { value: currentAreas.length },
u_points: { value: points },
u_dragVectors: { value: dragVectors },
u_distortionStrengths: { value: strengths },
u_lensEffects: { value: lensEffects }
});
sceneRef.current.render();
}, [currentAreas, isReady]);
const animationCallback = (0, import_react4.useCallback)((deltaTime) => {
if (!isReady) return;
setCurrentAreas((prevAreas) => {
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || /* @__PURE__ */ new Set();
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
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) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
});
}, [isReady, mouseInteraction, mouseInteractionHook]);
useAnimationFrame(animationCallback, true);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",
{
ref: containerRef,
style: {
width: "100%",
height: "100%",
position: "relative",
...style
},
className,
children: !imageLoaded && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",
{
style: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "20px",
borderRadius: "8px",
zIndex: 999
},
children: "\uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911..."
}
)
}
);
};
// src/editor/components/EditorCanvas.tsx
var import_react6 = require("react");
// src/editor/components/AreaList.tsx
var import_jsx_runtime2 = require("react/jsx-runtime");
var AreaList = ({
areas,
selectedAreaId,
onSelectArea,
onRemoveArea,
onAddArea
}) => {
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "area-list", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "area-list-header", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h3", { children: "\uC65C\uACE1 \uC601\uC5ED" }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"button",
{
onClick: onAddArea,
disabled: areas.length >= 8,
className: "btn-add",
title: areas.length >= 8 ? "\uCD5C\uB300 8\uAC1C \uC601\uC5ED\uAE4C\uC9C0 \uC9C0\uC6D0" : "\uC0C8 \uC601\uC5ED \uCD94\uAC00",
children: "+ \uCD94\uAC00"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "area-list-items", children: areas.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "area-list-empty", children: "\uC601\uC5ED\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. + \uCD94\uAC00 \uBC84\uD2BC\uC744 \uB20C\uB7EC\uC8FC\uC138\uC694." }) : areas.map((area, index) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
"div",
{
className: `area-item ${selectedAreaId === area.id ? "selected" : ""}`,
onClick: () => onSelectArea(area.id),
children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "area-item-info", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "area-item-name", children: [
"\uC601\uC5ED ",
index + 1
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "area-item-strength", children: [
"\uAC15\uB3C4: ",
(area.distortionStrength * 100).toFixed(0),
"%"
] })
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"button",
{
onClick: (e) => {
e.stopPropagation();
onRemoveArea(area.id);
},
className: "btn-remove",
title: "\uC601\uC5ED \uC0AD\uC81C",
children: "\xD7"
}
)
]
},
area.id
)) })
] });
};
// src/editor/components/ParameterPanel.tsx
var import_jsx_runtime3 = require("react/jsx-runtime");
var EASING_OPTIONS = [
{ value: "linear", label: "\uC120\uD615 (Linear)" },
{ value: "easeIn", label: "\uAC00\uC18D (Ease In)" },
{ value: "easeOut", label: "\uAC10\uC18D (Ease Out)" },
{ value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" },
{ value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" },
{ value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }
];
var ParameterPanel = ({ area, onUpdateArea }) => {
if (!area) {
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "parameter-panel", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "parameter-panel-empty", children: "\uC601\uC5ED\uC744 \uC120\uD0DD\uD574\uC8FC\uC138\uC694" }) });
}
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-panel", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h3", { children: "\uD30C\uB77C\uBBF8\uD130 \uD3B8\uC9D1" }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { children: [
"\uC65C\uACE1 \uAC15\uB3C4: ",
(area.distortionStrength * 100).toFixed(0),
"%"
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"input",
{
type: "range",
min: "0",
max: "1",
step: "0.01",
value: area.distortionStrength,
onChange: (e) => onUpdateArea({ distortionStrength: parseFloat(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { children: [
"\uC9C0\uC18D \uC2DC\uAC04: ",
area.movement.duration.toFixed(1),
"\uCD08"
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"input",
{
type: "number",
min: "0.1",
max: "10",
step: "0.1",
value: area.movement.duration,
onChange: (e) => onUpdateArea({
movement: { ...area.movement, duration: parseFloat(e.target.value) }
}),
className: "input-number"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { children: "\uC774\uC9D5 \uD568\uC218" }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"select",
{
value: area.movement.easing,
onChange: (e) => onUpdateArea({
movement: { ...area.movement, easing: e.target.value }
}),
className: "select",
children: EASING_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("option", { value: option.value, children: option.label }, option.value))
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { children: [
"\uB80C\uC988 \uD6A8\uACFC: ",
(area.lensEffect?.strength ?? 0) > 0 ? "\uBCFC\uB85D " : (area.lensEffect?.strength ?? 0) < 0 ? "\uC624\uBAA9 " : "",
((area.lensEffect?.strength ?? 0) * 100).toFixed(0),
"%"
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"input",
{
type: "range",
min: "-1",
max: "1",
step: "0.01",
value: area.lensEffect?.strength ?? 0,
onChange: (e) => onUpdateArea({ lensEffect: { strength: parseFloat(e.target.value) } }),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { children: [
"\uC6C0\uC9C1\uC784 \uB2E8\uACC4: ",
(area.snapSteps ?? 0) === 0 ? "\uC5C6\uC74C" : `${area.snapSteps}\uB2E8\uACC4`
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
"input",
{
type: "range",
min: "0",
max: "5",
step: "1",
value: area.snapSteps ?? 0,
onChange: (e) => onUpdateArea({ snapSteps: parseInt(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "parameter-group", children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "point-coord", children: [
"P",
idx + 1,
": (",
point.x.toFixed(3),
", ",
point.y.toFixed(3),
")"
] }, idx)) })
] })
] });
};
// src/editor/hooks/useDistortionEditor.ts
var import_react5 = require("react");
var useDistortionEditor = (initialAreas = []) => {
const [state, setState] = (0, import_react5.useState)({
selectedAreaId: initialAreas[0]?.id || null,
areas: initialAreas,
editMode: "normal",
draggingPointIndex: null
});
const selectArea = (0, import_react5.useCallback)((areaId) => {
setState((prev) => ({ ...prev, selectedAreaId: areaId }));
}, []);
const addArea = (0, import_react5.useCallback)((area) => {
setState((prev) => ({
...prev,
areas: [...prev.areas, area],
selectedAreaId: area.id
}));
}, []);
const removeArea = (0, import_react5.useCallback)((areaId) => {
setState((prev) => {
const newAreas = prev.areas.filter((a) => a.id !== areaId);
return {
...prev,
areas: newAreas,
selectedAreaId: prev.selectedAreaId === areaId ? newAreas[0]?.id || null : prev.selectedAreaId
};
});
}, []);
const updateArea = (0, import_react5.useCallback)((areaId, updates) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area)
}));
}, []);
const updatePoint = (0, import_react5.useCallback)((areaId, pointIndex, point) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => {
if (area.id === areaId) {
const newPoints = [...area.basePoints];
newPoints[pointIndex] = point;
return { ...area, basePoints: newPoints };
}
return area;
})
}));
}, []);
const startDragging = (0, import_react5.useCallback)((pointIndex) => {
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
}, []);
const stopDragging = (0, import_react5.useCallback)(() => {
setState((prev) => ({ ...prev, draggingPointIndex: null }));
}, []);
const setEditMode = (0, import_react5.useCallback)((mode) => {
setState((prev) => ({ ...prev, editMode: mode }));
}, []);
const getSelectedArea = (0, import_react5.useCallback)(() => {
return state.areas.find((a) => a.id === state.selectedAreaId) || null;
}, [state.areas, state.selectedAreaId]);
return {
state,
selectArea,
addArea,
removeArea,
updateArea,
updatePoint,
startDragging,
stopDragging,
setEditMode,
getSelectedArea
};
};
// 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_runtime4 = require("react/jsx-runtime");
var EditorCanvas = ({
areas,
selectedAreaId,
imageSrc,
width,
height,
onUpdatePoint,
onUpdateArea,
draggingPointIndex,
onStartDragging,
onStopDragging,
style: customStyle,
showEditor = true,
onSelectArea
}) => {
const containerRef = (0, import_react6.useRef)(null);
const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false);
const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null);
const editorStyle = (0, import_react6.useMemo)(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle,
circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels,
centerPoint: {
...DEFAULT_EDITOR_CANVAS_STYLE.centerPoint,
...customStyle?.centerPoint
},
pointHandle: {
...DEFAULT_EDITOR_CANVAS_STYLE.pointHandle,
...customStyle?.pointHandle
},
areaOutline: {
...DEFAULT_EDITOR_CANVAS_STYLE.areaOutline,
...customStyle?.areaOutline
}
}), [customStyle]);
(0, import_react6.useEffect)(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
const selectedArea = areas.find((a) => a.id === selectedAreaId);
const isPointInPolygon2 = (0, import_react6.useCallback)((point, polygon) => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}, []);
const handlePointDown = (0, import_react6.useCallback)(
(pointIndex) => (e) => {
e.preventDefault();
e.stopPropagation();
onStartDragging(pointIndex);
},
[onStartDragging]
);
const handleCanvasDown = (0, import_react6.useCallback)(
(e) => {
if (!showEditor || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y };
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
if (onSelectArea) {
for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
);
const handleMove = (0, import_react6.useCallback)(
(e) => {
if (!showEditor || !selectedArea || !containerRef.current) return;
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea)) {
e.preventDefault();
}
const rect = containerRef.current.getBoundingClientRect();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y));
onUpdatePoint(selectedArea.id, draggingPointIndex, { x: clampedX, y: clampedY });
} else if (isDraggingArea && dragStartPos) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
const newPoints = selectedArea.basePoints.map((point) => ({
x: Math.max(0, Math.min(1, point.x + deltaX)),
y: Math.max(0, Math.min(1, point.y + deltaY))
}));
onUpdateArea(selectedArea.id, { basePoints: newPoints });
setDragStartPos({ x, y });
}
},
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
);
const handleUp = (0, import_react6.useCallback)(() => {
if (draggingPointIndex !== null) {
onStopDragging();
}
if (isDraggingArea) {
setIsDraggingArea(false);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, onStopDragging]);
(0, import_react6.useEffect)(() => {
if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener("mouseup", handleUp);
window.addEventListener("touchend", handleUp);
window.addEventListener("touchcancel", handleUp);
return () => {
window.removeEventListener("mouseup", handleUp);
window.removeEventListener("touchend", handleUp);
window.removeEventListener("touchcancel", handleUp);
};
}
}, [draggingPointIndex, isDraggingArea, handleUp]);
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
const [p0, p1, p2, p3] = points;
const leftX = p0.x * (1 - u) + p1.x * u;
const leftY = p0.y * (1 - u) + p1.y * u;
const rightX = p3.x * (1 - u) + p2.x * u;
const rightY = p3.y * (1 - u) + p2.y * u;
const posX = leftX * (1 - v) + rightX * v;
const posY = leftY * (1 - v) + rightY * v;
return {
x: posX * canvasWidth,
y: posY * canvasHeight
};
};
const drawDistortionCircle = (0, import_react6.useCallback)((ctx, points, canvasWidth, canvasHeight) => {
const segments = 128;
const centerU = 0.5;
const centerV = 0.5;
const circleLevels = editorStyle.circleLevels || [];
circleLevels.forEach((level, index) => {
const levelPoints = [];
for (let i = 0; i <= segments; i++) {
const theta = i / segments * 2 * Math.PI;
const u = centerU - level.radius * Math.sin(theta);
const v = centerV + level.radius * Math.cos(theta);
const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight);
levelPoints.push(pixelPos);
}
ctx.beginPath();
ctx.moveTo(levelPoints[0].x, levelPoints[0].y);
for (let i = 1; i < levelPoints.length; i++) {
ctx.lineTo(levelPoints[i].x, levelPoints[i].y);
}
ctx.closePath();
const baseColor = level.color || "rgba(255, 200, 0, 1)";
const colorWithOpacity = baseColor.replace(/rgba?\(([^)]+)\)/, (_, rgb) => {
const parts = rgb.split(",").map((p) => p.trim());
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${level.opacity})`;
});
ctx.strokeStyle = colorWithOpacity;
ctx.lineWidth = level.lineWidth;
if (level.dashPattern) {
ctx.setLineDash(level.dashPattern);
}
ctx.stroke();
ctx.setLineDash([]);
if (index === 0 && editorStyle.circleFillColor) {
ctx.fillStyle = editorStyle.circleFillColor;
ctx.fill();
}
});
const centerPointStyle = editorStyle.centerPoint || {};
const centerPixel = uvToPixel(centerU, centerV, points, canvasWidth, canvasHeight);
ctx.beginPath();
ctx.arc(centerPixel.x, centerPixel.y, centerPointStyle.radius || 5, 0, 2 * Math.PI);
if (centerPointStyle.fillColor) {
ctx.fillStyle = centerPointStyle.fillColor;
ctx.fill();
}
if (centerPointStyle.strokeColor) {
ctx.strokeStyle = centerPointStyle.strokeColor;
ctx.lineWidth = centerPointStyle.strokeWidth || 2;
ctx.stroke();
}
}, [editorStyle]);
const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing";
return "default";
};
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
"div",
{
ref: containerRef,
className: "editor-canvas",
style: {
width: "100%",
height: "100%",
position: "relative",
cursor: showEditor ? getCursorStyle() : "default",
pointerEvents: showEditor ? "auto" : "none",
touchAction: "none"
// 터치 시 모든 브라우저 동작 비활성화 (스크롤, 줌 등)
},
onMouseDown: showEditor ? handleCanvasDown : void 0,
onMouseMove: showEditor ? handleMove : void 0,
onTouchStart: showEditor ? handleCanvasDown : void 0,
onTouchMove: showEditor ? handleMove : void 0,
children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas }),
showEditor && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"svg",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none"
},
children: areas.map((area) => {
const isSelected = area.id === selectedAreaId;
const points = area.basePoints;
const outlineStyle = editorStyle.areaOutline || {};
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("g", { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"polygon",
{
points: points.map((p) => `${p.x * canvasSize.width},${p.y * canvasSize.height}`).join(" "),
fill: isSelected ? outlineStyle.selectedFillColor || "rgba(0, 170, 255, 0.08)" : outlineStyle.unselectedFillColor || "rgba(136, 136, 136, 0.03)",
stroke: isSelected ? outlineStyle.selectedColor || "#00aaff" : outlineStyle.unselectedColor || "#888",
strokeWidth: isSelected ? outlineStyle.selectedWidth || 2 : outlineStyle.unselectedWidth || 1,
strokeDasharray: isSelected ? "0" : outlineStyle.unselectedDashPattern?.join(",") || "5,5",
opacity: isSelected ? 1 : 0.5
}
) }, area.id);
})
}
),
showEditor && selectedArea && canvasSize.width > 0 && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"canvas",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none"
},
width: canvasSize.width,
height: canvasSize.height,
ref: (canvas) => {
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
drawDistortionCircle(ctx, selectedArea.basePoints, canvasSize.width, canvasSize.height);
}
}
}
}
),
showEditor && selectedArea && selectedArea.basePoints.map((point, index) => {
const handleStyle = editorStyle.pointHandle || {};
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"div",
{
className: `point-handle ${draggingPointIndex === index ? "dragging" : ""}`,
style: {
position: "absolute",
left: `${point.x * 100}%`,
top: `${point.y * 100}%`,
transform: "translate(-50%, -50%)",
width: handleStyle.size || 16,
height: handleStyle.size || 16,
borderRadius: "50%",
backgroundColor: handleStyle.fillColor || "#00aaff",
border: `${handleStyle.strokeWidth || 2}px solid ${handleStyle.strokeColor || "white"}`,
cursor: "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
},
onMouseDown: handlePointDown(index),
onTouchStart: handlePointDown(index),
children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: handleStyle.labelFontSize || 11,
color: handleStyle.labelColor || "#00aaff",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap"
},
children: [
"P",
index + 1
]
}
)
},
index
);
})
]
}
);
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ANIMATION_CONFIG,
AnimationLoop,
AreaList,
DEFAULT_AREA,
DEFAULT_EDITOR_CANVAS_STYLE,
EditorCanvas,
ImageDistortion,
ParameterPanel,
SHADER_CONFIG,
ShaderManager,
SpringPhysics,
ThreeScene,
applyEasing,
getRegisteredPresets,
hasPreset,
isRotationPreset,
presetToVector,
registerMotionPreset,
registerMotionPresets,
resetToBuiltInPresets,
unregisterMotionPreset,
useAnimationFrame,
useDistortionEditor,
useMouseInteraction,
useMouseVelocity
});
//# sourceMappingURL=index.js.map