BaekRyang 48fdd5e17c Add sprite particle effects and improve lens distortion
- 스프라이트 기반 파티클 이펙트 관리 기능 추가 (SpriteEffectManager)
- 렌즈 왜곡 셰이더 로직 개선 및 픽셀 공간 기준 등방성 확대 적용
- 파티클 최적화를 위한 오브젝트 풀링(SpriteParticlePool) 도입
- DistortionArea 타입에 spriteEffects 설정 필드 추가
- ThreeScene에 씬 객체 접근 기능 및 렌더 순서 제어 추가
- useMouseInteraction 훅에서 마우스 상태 조회 기능 추가
- 버전 1.3.0 업데이트 및 관련 타입 정의 반영
2026-03-10 13:24:12 +09:00

2149 lines
74 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,
SpriteEffectManager: () => SpriteEffectManager,
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 THREE4 = __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.mesh.renderOrder = 0;
this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
}
/**
* Three.js 씬 객체 반환
*/
getScene() {
return this.scene;
}
/**
* 유니폼 값 업데이트
* @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/engine/SpriteEffectManager.ts
var THREE3 = __toESM(require("three"));
// src/engine/SpriteEffectInstance.ts
var THREE2 = __toESM(require("three"));
// src/engine/SpriteParticlePool.ts
var SpriteParticlePool = class {
constructor(maxParticles) {
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
}
/** 비활성 파티클 생성 */
createParticle(index) {
return {
index,
active: false,
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
scale: 1,
rotation: 0,
opacity: 1,
age: 0,
lifetime: 1
};
}
/**
* 비활성 파티클을 활성화하여 반환
* 사용 가능한 파티클이 없으면 null 반환
*/
acquire() {
for (const particle of this.particles) {
if (!particle.active) {
particle.active = true;
particle.age = 0;
return particle;
}
}
return null;
}
/**
* 파티클을 비활성화하여 풀로 반환
*/
release(particle) {
particle.active = false;
}
/**
* 활성 파티클 목록 반환
*/
getActiveParticles() {
return this.particles.filter((p) => p.active);
}
/**
* 활성 파티클 수
*/
getActiveCount() {
let count = 0;
for (const p of this.particles) {
if (p.active) count++;
}
return count;
}
};
// src/engine/SpriteEffectInstance.ts
var randomRange = (min, max) => min + Math.random() * (max - min);
var lerp = (a, b, t) => a + (b - a) * t;
var SpriteEffectInstance = class {
constructor(config) {
this.texture = null;
this.ready = false;
this.emitAccumulator = 0;
this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE2.Group();
this.geometry = new THREE2.PlaneGeometry(1, 1);
const blending = config.blendMode === "additive" ? THREE2.AdditiveBlending : THREE2.NormalBlending;
this.material = new THREE2.MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
blending,
opacity: 0
});
this.meshes = Array.from({ length: config.maxParticles }, () => {
const mesh = new THREE2.Mesh(this.geometry, this.material.clone());
mesh.visible = false;
mesh.renderOrder = 1;
this.group.add(mesh);
return mesh;
});
this.loadTexture(config.spriteUrl);
}
/** 텍스처 로드 */
loadTexture(url) {
const loader = new THREE2.TextureLoader();
loader.load(
url,
(texture) => {
this.texture = texture;
for (const mesh of this.meshes) {
mesh.material.map = texture;
mesh.material.needsUpdate = true;
}
this.ready = true;
},
void 0,
(error) => {
console.error(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC2E4\uD328: ${url}`, error);
}
);
}
/**
* 파티클 1개 방출
* @param center 방출 중심 (정규화 좌표 0-1)
*/
emitOne(center) {
const particle = this.pool.acquire();
if (!particle) return;
const { config } = this;
let px = center.x + (config.emitOffset?.x ?? 0);
let py = center.y + (config.emitOffset?.y ?? 0);
if (config.emitRadius && config.emitRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * config.emitRadius;
px += Math.cos(angle) * radius;
py += Math.sin(angle) * radius;
}
particle.position.x = px;
particle.position.y = py;
const angleRange = config.emitAngle ?? [0, 360];
const angleDeg = randomRange(angleRange[0], angleRange[1]);
const angleRad = angleDeg * Math.PI / 180;
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed;
particle.scale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.rotation = 0;
particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0;
}
/**
* ambient 모드: 매 프레임 누적기 기반 방출
*/
updateAmbientEmit(deltaTime, center) {
if (!this.config.emitRate || this.config.emitRate <= 0) return;
this.emitAccumulator += deltaTime;
const interval = 1 / this.config.emitRate;
while (this.emitAccumulator >= interval) {
this.emitAccumulator -= interval;
this.emitOne(center);
}
}
/**
* touch 모드: 버스트 방출
*/
triggerBurst(center) {
if (!this.ready) return;
const count = this.config.burstCount ?? 1;
for (let i = 0; i < count; i++) {
this.emitOne(center);
}
}
/**
* 매 프레임 업데이트
* @param deltaTime 초 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
update(deltaTime, emitCenter) {
if (!this.ready) return;
if (this.config.trigger === "ambient") {
this.updateAmbientEmit(deltaTime, emitCenter);
}
const overLifetime = this.config.overLifetime;
const activeParticles = this.pool.getActiveParticles();
for (const particle of activeParticles) {
particle.age += deltaTime;
if (particle.age >= particle.lifetime) {
this.pool.release(particle);
this.syncMesh(particle);
continue;
}
const lifeRatio = particle.age / particle.lifetime;
if (overLifetime) {
if (overLifetime.scale) {
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
}
if (overLifetime.opacity) {
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio);
}
if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime;
}
if (overLifetime.velocityDamping !== void 0) {
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
particle.velocity.x *= damping;
particle.velocity.y *= damping;
}
}
particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle);
}
}
/**
* 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전
*/
syncMesh(particle) {
const mesh = this.meshes[particle.index];
if (!mesh) return;
if (!particle.active) {
mesh.visible = false;
return;
}
mesh.visible = true;
mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = 0.1;
mesh.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation;
const mat = mesh.material;
mat.opacity = particle.opacity;
}
/**
* 텍스처 로딩 완료 여부
*/
isReady() {
return this.ready;
}
/**
* 리소스 정리
*/
dispose() {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
this.geometry.dispose();
for (const mesh of this.meshes) {
mesh.material.dispose();
}
this.material.dispose();
while (this.group.children.length > 0) {
this.group.remove(this.group.children[0]);
}
}
};
// src/engine/SpriteEffectManager.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 getAreaCenter = (area) => {
const pts = area.basePoints;
return {
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4
};
};
var SpriteEffectManager = class {
constructor() {
/** 영역ID+이펙트ID → 인스턴스 맵 */
this.instances = /* @__PURE__ */ new Map();
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
this.previousTouchingAreas = /* @__PURE__ */ new Set();
this.effectGroup = new THREE3.Group();
this.effectGroup.renderOrder = 1;
}
/**
* Three.js 씬에 이펙트 그룹 추가
*/
attachToScene(scene) {
scene.add(this.effectGroup);
}
/**
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
*/
syncEffects(areas) {
const activeKeys = /* @__PURE__ */ new Set();
for (const area of areas) {
if (!area.spriteEffects) continue;
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key);
if (this.instances.has(key)) continue;
const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance);
this.effectGroup.add(instance.group);
}
}
for (const [key, instance] of this.instances) {
if (!activeKeys.has(key)) {
instance.dispose();
this.effectGroup.remove(instance.group);
this.instances.delete(key);
}
}
}
/**
* 매 프레임 업데이트
* @param areas 현재 영역 배열
* @param deltaTime 초 단위 프레임 시간
* @param touchState 마우스/터치 상태
*/
update(areas, deltaTime, touchState) {
const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) {
for (const area of areas) {
if (isPointInPolygon(touchState.position, area.basePoints)) {
currentTouchingAreas.add(area.id);
}
}
}
for (const area of areas) {
if (!area.spriteEffects) continue;
const center = getAreaCenter(area);
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key);
if (!instance) continue;
if (effectConfig.trigger === "touch") {
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) {
instance.triggerBurst(touchState.position ?? center);
}
}
instance.update(deltaTime, center);
}
}
this.previousTouchingAreas = currentTouchingAreas;
}
/**
* 리소스 정리
*/
dispose() {
for (const [, instance] of this.instances) {
instance.dispose();
}
this.instances.clear();
this.previousTouchingAreas.clear();
if (this.effectGroup.parent) {
this.effectGroup.parent.remove(this.effectGroup);
}
}
};
// 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 isPointInPolygon2 = (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 (isPointInPolygon2(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,
getMouseState: getState
};
};
// 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 spriteManagerRef = (0, import_react4.useRef)(null);
const currentAreasRef = (0, import_react4.useRef)(areas);
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)(() => {
currentAreasRef.current = currentAreas;
}, [currentAreas]);
(0, import_react4.useEffect)(() => {
if (!sceneRef.current || !isReady) return;
const manager = new SpriteEffectManager();
manager.attachToScene(sceneRef.current.getScene());
spriteManagerRef.current = manager;
return () => {
manager.dispose();
spriteManagerRef.current = null;
};
}, [isReady]);
(0, import_react4.useEffect)(() => {
spriteManagerRef.current?.syncEffects(currentAreas);
}, [currentAreas]);
(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 THREE4.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;
});
if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update(
currentAreasRef.current,
deltaTime,
{
position: mouseState.position ?? null,
isDragging: mouseState.isDragging
}
);
}
}, [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 isPointInPolygon3 = (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 && isPointInPolygon3(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 && isPointInPolygon3(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon3, 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,
SpriteEffectManager,
ThreeScene,
applyEasing,
getRegisteredPresets,
hasPreset,
isRotationPreset,
presetToVector,
registerMotionPreset,
registerMotionPresets,
resetToBuiltInPresets,
unregisterMotionPreset,
useAnimationFrame,
useDistortionEditor,
useMouseInteraction,
useMouseVelocity
});
//# sourceMappingURL=index.js.map