@ev-ry/fx 0.1.0-rc.2 → 0.1.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,61 +1,61 @@
1
- import {motionEnvelopeShader} from './motion-envelope.js';
2
- import {additionalTextMotion} from './text-edit-motions.js';
3
-
4
- // One evaluator allows optional rigid facet reflection for every preset. Only
5
- // the selected preset compiles; extra corner evaluations run only with bounce.
6
- export const textEffectPathShader=`${motionEnvelopeShader}
7
- vec3 thdPresetPosition(vec3 p,vec2 dustTarget,vec2 particle,vec4 r,vec4 clock,vec2 travel,float t,float seed,float drift,float pace){
8
- float remain=1.0-t;
9
- vec3 recipeOrigin=p;
10
- vec2 cellSize=vec2(max(dustEm,0.0001));
11
- vec2 cellMin=thdMotionCell(dustTarget,r.xy,dustEm);
12
- #if THD_EDIT_CHARACTER_FRAME == 1
13
- if(dustCharacterFrame.z>0.0&&dustCharacterFrame.w>0.0){
14
- cellMin=dustCharacterFrame.xy+glyphOffset;
15
- cellSize=dustCharacterFrame.zw;
16
- }
17
- #endif
18
- vec2 cellCenter=cellMin+cellSize*0.5;
19
- vec2 cellUV=clamp((dustTarget-cellMin)/cellSize,0.0,1.0);
20
- float across=cellUV.x;
21
- if(clock.y<0.0)across=1.0-across;
22
- {${additionalTextMotion}}
23
- if(dustRecipeDirection!=vec2(1.0,0.0)){
24
- vec2 delta=p.xy-recipeOrigin.xy;
25
- float sine=clock.y*dustRecipeDirection.y;
26
- p.xy=recipeOrigin.xy+vec2(dustRecipeDirection.x*delta.x-sine*delta.y,sine*delta.x+dustRecipeDirection.x*delta.y);
27
- }
28
- if(dustRecipeDepth!=1.0)p.z=recipeOrigin.z+(p.z-recipeOrigin.z)*dustRecipeDepth;
29
- dustGlow*=dustRecipeGlow;
30
- if(dustRecipePalette.x>=0.0)dustGlowColor=dustRecipePalette;
31
- return p;
32
- }
33
- `;
34
- export const textEffectApplicationShader=`
35
- vec3 original=p;
36
- p=thdPresetPosition(original,dustTarget,particle,r,clock,travel,t,seed,drift,pace);
37
- // Regulate the shared triangle centre, preserving facet shape and orientation.
38
- vec3 center=vec3(dustTarget,original.z);
39
- float savedGlow=dustGlow,savedOutline=dustOutline;vec3 savedGlowColor=dustGlowColor;
40
- vec3 moved=thdPresetPosition(center,dustTarget,particle,r,clock,travel,t,seed,drift,pace);
41
- dustGlow=savedGlow;dustOutline=savedOutline;dustGlowColor=savedGlowColor;
42
- vec3 delta=moved-center;
43
- vec3 regulated=thdRegulateMotion(delta,dustEm);
44
- vec3 adjustment=regulated-delta;
45
- #if THD_EDIT_ADJUST == 1
46
- adjustment+=regulated*(dustTransform-vec3(1.0));
47
- #endif
48
- p+=adjustment;
49
- #if THD_EDIT_BOUNCE == 1
50
- if(dustBounce>0.5 && t<1.0){
51
- vec3 a=thdPresetPosition(dustCornerA+vec3(glyphOffset,0.0),dustTarget,particle,r,clock,travel,t,seed,drift,pace)+adjustment;
52
- vec3 b=thdPresetPosition(dustCornerB+vec3(glyphOffset,0.0),dustTarget,particle,r,clock,travel,t,seed,drift,pace)+adjustment;
53
- vec3 c=thdPresetPosition(dustCornerC+vec3(glyphOffset,0.0),dustTarget,particle,r,clock,travel,t,seed,drift,pace)+adjustment;
54
- float centerY=(a.y+b.y+c.y)/3.0;
55
- float low=dustFloor.x+glyphOffset.y+centerY-min(a.y,min(b.y,c.y));
56
- float high=dustCeiling.x+glyphOffset.y-max(a.y,max(b.y,c.y))+centerY;
57
- float span=high-low;
58
- if(span>0.000001)p.y+=low+span-abs(mod(centerY-low,2.0*span)-span)-centerY;
59
- }
60
- #endif
61
- `;
1
+ import {motionEnvelopeShader} from './motion-envelope.js';
2
+ import {additionalTextMotion} from './text-edit-motions.js';
3
+
4
+ // One evaluator allows optional rigid facet reflection for every preset. Only
5
+ // the selected preset compiles; extra corner evaluations run only with bounce.
6
+ export const textEffectPathShader=`${motionEnvelopeShader}
7
+ vec3 thdPresetPosition(vec3 p,vec2 dustTarget,vec2 particle,vec4 r,vec4 clock,vec2 travel,float t,float seed,float drift,float pace){
8
+ float remain=1.0-t;
9
+ vec3 recipeOrigin=p;
10
+ vec2 cellSize=vec2(max(dustEm,0.0001));
11
+ vec2 cellMin=thdMotionCell(dustTarget,r.xy,dustEm);
12
+ #if THD_EDIT_CHARACTER_FRAME == 1
13
+ if(dustCharacterFrame.z>0.0&&dustCharacterFrame.w>0.0){
14
+ cellMin=dustCharacterFrame.xy+glyphOffset;
15
+ cellSize=dustCharacterFrame.zw;
16
+ }
17
+ #endif
18
+ vec2 cellCenter=cellMin+cellSize*0.5;
19
+ vec2 cellUV=clamp((dustTarget-cellMin)/cellSize,0.0,1.0);
20
+ float across=cellUV.x;
21
+ if(clock.y<0.0)across=1.0-across;
22
+ {${additionalTextMotion}}
23
+ if(dustRecipeDirection!=vec2(1.0,0.0)){
24
+ vec2 delta=p.xy-recipeOrigin.xy;
25
+ float sine=clock.y*dustRecipeDirection.y;
26
+ p.xy=recipeOrigin.xy+vec2(dustRecipeDirection.x*delta.x-sine*delta.y,sine*delta.x+dustRecipeDirection.x*delta.y);
27
+ }
28
+ if(dustRecipeDepth!=1.0)p.z=recipeOrigin.z+(p.z-recipeOrigin.z)*dustRecipeDepth;
29
+ dustGlow*=dustRecipeGlow;
30
+ if(dustRecipePalette.x>=0.0)dustGlowColor=dustRecipePalette;
31
+ return p;
32
+ }
33
+ `;
34
+ export const textEffectApplicationShader=`
35
+ vec3 original=p;
36
+ p=thdPresetPosition(original,dustTarget,particle,r,clock,travel,t,seed,drift,pace);
37
+ // Regulate the shared triangle centre, preserving facet shape and orientation.
38
+ vec3 center=vec3(dustTarget,original.z);
39
+ float savedGlow=dustGlow,savedOutline=dustOutline;vec3 savedGlowColor=dustGlowColor;
40
+ vec3 moved=thdPresetPosition(center,dustTarget,particle,r,clock,travel,t,seed,drift,pace);
41
+ dustGlow=savedGlow;dustOutline=savedOutline;dustGlowColor=savedGlowColor;
42
+ vec3 delta=moved-center;
43
+ vec3 regulated=thdRegulateMotion(delta,dustEm);
44
+ vec3 adjustment=regulated-delta;
45
+ #if THD_EDIT_ADJUST == 1
46
+ adjustment+=regulated*(dustTransform-vec3(1.0));
47
+ #endif
48
+ p+=adjustment;
49
+ #if THD_EDIT_BOUNCE == 1
50
+ if(dustBounce>0.5 && t<1.0){
51
+ vec3 a=thdPresetPosition(dustCornerA+vec3(glyphOffset,0.0),dustTarget,particle,r,clock,travel,t,seed,drift,pace)+adjustment;
52
+ vec3 b=thdPresetPosition(dustCornerB+vec3(glyphOffset,0.0),dustTarget,particle,r,clock,travel,t,seed,drift,pace)+adjustment;
53
+ vec3 c=thdPresetPosition(dustCornerC+vec3(glyphOffset,0.0),dustTarget,particle,r,clock,travel,t,seed,drift,pace)+adjustment;
54
+ float centerY=(a.y+b.y+c.y)/3.0;
55
+ float low=dustFloor.x+glyphOffset.y+centerY-min(a.y,min(b.y,c.y));
56
+ float high=dustCeiling.x+glyphOffset.y-max(a.y,max(b.y,c.y))+centerY;
57
+ float span=high-low;
58
+ if(span>0.000001)p.y+=low+span-abs(mod(centerY-low,2.0*span)-span)-centerY;
59
+ }
60
+ #endif
61
+ `;
@@ -1,91 +1,98 @@
1
- // Small GPU building blocks shared by edit motions. All paths run from scattered
2
- // (t=0) to the untouched mesh (t=1); removal only reverses the captured phase.
3
- export const textMotionPrimitives=`
4
- // Text effects live above their resting surface. Reflect inward depth instead
5
- // of letting the opaque control body cut off a still-visible particle. This
6
- // same path is reversed for removal; normal scene depth testing stays enabled.
7
- float thdFrontDepth(float resting,float animated){
8
- return resting+abs(animated-resting);
9
- }
10
- // A fixed one-em cell: longer edits add cells instead of stretching a path.
11
- // All vertices of a triangle choose the cell using their shared centroid.
12
- vec2 thdMotionCell(vec2 target,vec2 origin,float em){
13
- float size=max(em,0.0001);
14
- return origin+floor((target-origin)/size)*size;
15
- }
16
- // Preserve the tuned wind distribution for EVERY preset: no particle cohorts,
17
- // no per-motion lifetime. Time = randomized distance / randomized speed.
18
- vec2 thdParticleTravel(float seed,float drift,float pace,float seconds){
19
- float spread=fract(seed*7.31+drift*13.71);
20
- float distanceJitter=mix(0.8,1.2,spread);
21
- float distance=mix(0.0,7.8,seed)*distanceJitter;
22
- float speed=(7.8*1.2/max(seconds,0.001))*mix(1.0,4.0,pace);
23
- return vec2(distance,distance/speed);
24
- }
25
- float thdMotionPhase(vec4 clock,float travelSeconds,float seconds){
26
- float progress=travelSeconds>0.0?clamp(clock.x*seconds/travelSeconds,0.0,1.0):1.0;
27
- float initial=travelSeconds>0.0?clamp(max(0.0,clock.z-1.0)*seconds/travelSeconds,0.0,1.0):1.0;
28
- return clock.z>0.5?max(0.0,initial-progress):progress;
29
- }
30
- float thdStagger(float t,float order,float spread){
31
- if(t>=1.0)return 1.0;
32
- if(t<=0.0)return 0.0;
33
- float delay=clamp(order,0.0,1.0)*spread;
34
- return clamp((t-delay)/max(1.0-delay,0.001),0.0,1.0);
35
- }
36
- vec2 thdTurn(vec2 v,float angle){
37
- return vec2(v.x*cos(angle)-v.y*sin(angle),v.x*sin(angle)+v.y*cos(angle));
38
- }
39
- vec3 thdFold(vec3 v,vec3 axis,float angle){
40
- return v*cos(angle)+cross(axis,v)*sin(angle)+axis*dot(axis,v)*(1.0-cos(angle));
41
- }
42
- // Linear projected opening, while preserving each preset's initial angle.
43
- // A linear angle looks almost flat too early because its projection is cosine.
44
- float thdOpeningAngle(float progress,float initialAngle){
45
- // Some GPU acos approximations leave a tiny angle at 1. Return exact ends.
46
- if(progress>=1.0)return 0.0;
47
- if(progress<=0.0)return initialAngle;
48
- return acos(clamp(mix(cos(initialAngle),1.0,progress),-1.0,1.0));
49
- }
50
- vec3 thdHinge(vec3 point,vec3 pivot,vec3 axis,float angle){
51
- return pivot+thdFold(point-pivot,axis,angle);
52
- }
53
- vec2 thdCurve(vec2 a,vec2 b,vec2 c,vec2 d,float t){
54
- float q=1.0-t;
55
- return q*q*q*a+3.0*q*q*t*b+3.0*q*t*t*c+t*t*t*d;
56
- }
57
- float thdPulse(float t){return 4.0*t*(1.0-t);}
58
- // Bounded sinusoidal drift anchored at zero, so a shared path joins the text
59
- // exactly without per-effect offsets or a separate deletion implementation.
60
- float thdSway(float progress,float phase,float turns){
61
- return sin(phase+progress*turns*6.2831853)-sin(phase);
62
- }
63
- // Ballistic drop + four diminishing rebounds. One drop time is the unit;
64
- // restitution scales vertical speed at each impact (energy scales by e^2).
65
- // Horizontal friction reduces speed after every impact. Result: height as
66
- // a fraction of the original drop, and accumulated horizontal travel [0,1].
67
- // This evaluates a pose directly, so reverse playback needs no simulation.
68
- vec2 thdGravityBounce(float departure,float restitution,float friction){
69
- float total=1.0,totalX=1.0,speed=restitution,slide=friction;
70
- for(int i=0;i<4;i++){
71
- total+=2.0*speed;totalX+=2.0*speed*slide;
72
- speed*=restitution;slide*=friction;
73
- }
74
- float time=clamp(departure,0.0,1.0)*total;
75
- if(time<=1.0)return vec2(max(0.0,1.0-time*time),time/totalX);
76
- time-=1.0;
77
- float distance=1.0;speed=restitution;slide=friction;
78
- for(int i=0;i<4;i++){
79
- float flight=2.0*speed;
80
- if(time<=flight)return vec2(max(0.0,2.0*speed*time-time*time),(distance+slide*time)/totalX);
81
- time-=flight;distance+=slide*flight;
82
- speed*=restitution;slide*=friction;
83
- }
84
- return vec2(0.0,1.0);
85
- }
86
- float thdSpring(float t){return (1.0-t)*cos(t*10.9955743);}
87
- float thdFall(float t){
88
- if(t<0.64){float f=t/0.64;return 1.0-f*f;}
89
- return 0.14*sin((t-0.64)/0.36*3.1415927);
90
- }
91
- `;
1
+ // Small GPU building blocks shared by edit motions. All paths run from scattered
2
+ // (t=0) to the untouched mesh (t=1); removal only reverses the captured phase.
3
+ export const textMotionPrimitives=`
4
+ // Text effects live above their resting surface. Reflect inward depth instead
5
+ // of letting the opaque control body cut off a still-visible particle. This
6
+ // same path is reversed for removal; normal scene depth testing stays enabled.
7
+ float thdFrontDepth(float resting,float animated){
8
+ return resting+abs(animated-resting);
9
+ }
10
+ // A fixed one-em cell: longer edits add cells instead of stretching a path.
11
+ // All vertices of a triangle choose the cell using their shared centroid.
12
+ vec2 thdMotionCell(vec2 target,vec2 origin,float em){
13
+ float size=max(em,0.0001);
14
+ return origin+floor((target-origin)/size)*size;
15
+ }
16
+ // Preserve the tuned wind distribution for EVERY preset: no particle cohorts,
17
+ // no per-motion lifetime. Time = randomized distance / randomized speed.
18
+ vec2 thdParticleTravel(float seed,float drift,float pace,float seconds){
19
+ float spread=fract(seed*7.31+drift*13.71);
20
+ float distanceJitter=mix(0.8,1.2,spread);
21
+ float distance=mix(0.0,7.8,seed)*distanceJitter;
22
+ float speed=(7.8*1.2/max(seconds,0.001))*mix(1.0,4.0,pace);
23
+ return vec2(distance,distance/speed);
24
+ }
25
+ float thdMotionPhase(vec4 clock,float travelSeconds,float seconds){
26
+ float progress=travelSeconds>0.0?clamp(clock.x*seconds/travelSeconds,0.0,1.0):1.0;
27
+ float initial=travelSeconds>0.0?clamp(max(0.0,clock.z-1.0)*seconds/travelSeconds,0.0,1.0):1.0;
28
+ return clock.z>0.5?max(0.0,initial-progress):progress;
29
+ }
30
+ float thdStagger(float t,float order,float spread){
31
+ if(t>=1.0)return 1.0;
32
+ if(t<=0.0)return 0.0;
33
+ float delay=clamp(order,0.0,1.0)*spread;
34
+ return clamp((t-delay)/max(1.0-delay,0.001),0.0,1.0);
35
+ }
36
+ // Continuous per-particle appearance: sparse at first, progressively denser.
37
+ // Use the existing reversible motion phase so exit follows the same envelope.
38
+ float thdParticleAppearance(float t,float order){
39
+ float start=0.55*sqrt(clamp(order,0.0,1.0));
40
+ float fade=clamp((t-start)/0.25,0.0,1.0);
41
+ return fade*fade;
42
+ }
43
+ vec2 thdTurn(vec2 v,float angle){
44
+ return vec2(v.x*cos(angle)-v.y*sin(angle),v.x*sin(angle)+v.y*cos(angle));
45
+ }
46
+ vec3 thdFold(vec3 v,vec3 axis,float angle){
47
+ return v*cos(angle)+cross(axis,v)*sin(angle)+axis*dot(axis,v)*(1.0-cos(angle));
48
+ }
49
+ // Linear projected opening, while preserving each preset's initial angle.
50
+ // A linear angle looks almost flat too early because its projection is cosine.
51
+ float thdOpeningAngle(float progress,float initialAngle){
52
+ // Some GPU acos approximations leave a tiny angle at 1. Return exact ends.
53
+ if(progress>=1.0)return 0.0;
54
+ if(progress<=0.0)return initialAngle;
55
+ return acos(clamp(mix(cos(initialAngle),1.0,progress),-1.0,1.0));
56
+ }
57
+ vec3 thdHinge(vec3 point,vec3 pivot,vec3 axis,float angle){
58
+ return pivot+thdFold(point-pivot,axis,angle);
59
+ }
60
+ vec2 thdCurve(vec2 a,vec2 b,vec2 c,vec2 d,float t){
61
+ float q=1.0-t;
62
+ return q*q*q*a+3.0*q*q*t*b+3.0*q*t*t*c+t*t*t*d;
63
+ }
64
+ float thdPulse(float t){return 4.0*t*(1.0-t);}
65
+ // Bounded sinusoidal drift anchored at zero, so a shared path joins the text
66
+ // exactly without per-effect offsets or a separate deletion implementation.
67
+ float thdSway(float progress,float phase,float turns){
68
+ return sin(phase+progress*turns*6.2831853)-sin(phase);
69
+ }
70
+ // Ballistic drop + four diminishing rebounds. One drop time is the unit;
71
+ // restitution scales vertical speed at each impact (energy scales by e^2).
72
+ // Horizontal friction reduces speed after every impact. Result: height as
73
+ // a fraction of the original drop, and accumulated horizontal travel [0,1].
74
+ // This evaluates a pose directly, so reverse playback needs no simulation.
75
+ vec2 thdGravityBounce(float departure,float restitution,float friction){
76
+ float total=1.0,totalX=1.0,speed=restitution,slide=friction;
77
+ for(int i=0;i<4;i++){
78
+ total+=2.0*speed;totalX+=2.0*speed*slide;
79
+ speed*=restitution;slide*=friction;
80
+ }
81
+ float time=clamp(departure,0.0,1.0)*total;
82
+ if(time<=1.0)return vec2(max(0.0,1.0-time*time),time/totalX);
83
+ time-=1.0;
84
+ float distance=1.0;speed=restitution;slide=friction;
85
+ for(int i=0;i<4;i++){
86
+ float flight=2.0*speed;
87
+ if(time<=flight)return vec2(max(0.0,2.0*speed*time-time*time),(distance+slide*time)/totalX);
88
+ time-=flight;distance+=slide*flight;
89
+ speed*=restitution;slide*=friction;
90
+ }
91
+ return vec2(0.0,1.0);
92
+ }
93
+ float thdSpring(float t){return (1.0-t)*cos(t*10.9955743);}
94
+ float thdFall(float t){
95
+ if(t<0.64){float f=t/0.64;return 1.0-f*f;}
96
+ return 0.14*sin((t-0.64)/0.36*3.1415927);
97
+ }
98
+ `;
@@ -1,71 +1,71 @@
1
- import {motionPrograms} from './text-motion-programs.js';
2
-
3
- const freeze=value=>{if(value&&typeof value==='object'&&!Object.isFrozen(value)){Object.values(value).forEach(freeze);Object.freeze(value);}return value;};
4
- // Trusted internal operation graphs. Public settings accept bounded values,
5
- // never expressions, GLSL, or executable source.
6
- export const textMotionRecipes=freeze(motionPrograms);
7
- export const textMotionAliases=freeze({});
8
- export const textRecipeControls=freeze({
9
- angle:{label:'جهت حرکت',default:0,min:-180,max:180,step:15,choices:{'0':'اصلی','45':'مورب بالا','-45':'مورب پایین','90':'چرخش بالا','-90':'چرخش پایین','180':'معکوس'}},
10
- depth:{label:'عمق',default:1,min:0,max:2,step:.1},
11
- spin:{label:'پیچش',default:1,min:0,max:2,step:.1},
12
- sway:{label:'موج و نوسان',default:1,min:0,max:2,step:.1},
13
- shape:{label:'تغییر شکل مثلث‌ها',default:1,min:0,max:1.5,step:.1},
14
- glow:{label:'درخشش',default:1,min:0,max:2,step:.1},
15
- palette:{label:'رنگ درخشش',default:'original',choices:{original:'اصلی',cool:'آبی',warm:'گرم',mint:'نعنایی'}},
16
- rebound:{label:'قدرت جهش',default:1,min:.25,max:1.5,step:.05},
17
- pace:{label:'ریتم جهش',default:1,min:.25,max:2,step:.05}
18
- });
19
- export function normalizeTextMotionRecipe(value={},mode='dust-wind'){
20
- if(!value||typeof value!=='object'||Array.isArray(value))throw TypeError('Recipe settings must be an object');
21
- const result=Object.fromEntries(Object.entries(textRecipeControls).map(([key,control])=>[key,control.default]));
22
- result.angle=textMotionAliases[mode]?.angle??(mode==='dust-wind-upward'?45:0);
23
- for(const [key,v] of Object.entries(value)){
24
- if(!Object.hasOwn(textRecipeControls,key))throw TypeError('Unknown recipe setting: '+key);
25
- const control=textRecipeControls[key];
26
- if(key==='palette'){if(typeof v!=='string'||!Object.hasOwn(control.choices,v))throw TypeError('Invalid recipe palette');}
27
- else if(typeof v!=='number'||!Number.isFinite(v)||v<control.min||v>control.max)throw RangeError('Invalid recipe setting: '+key);
28
- result[key]=v;
29
- }
30
- return Object.freeze(result);
31
- }
32
-
33
- // Shared compiler: expressions describe math, operations describe composition,
34
- // uniforms supply the bounded public intent controls.
35
- export function compileTextMotionRecipe(recipe){
36
- if(!recipe||!Array.isArray(recipe.operations)||!recipe.operations.length)throw TypeError('Invalid motion recipe');
37
- const types={face:recipe.ownFace?'vec2':'vec3'};
38
- const lines=recipe.ownFace?[]:['vec3 face=p-vec3(dustTarget,p.z);'];
39
- const expression=value=>{if(typeof value!=='string'||!value||/[;{}#]/.test(value))throw TypeError('Invalid recipe expression');return value;};
40
- const target=value=>{if(typeof value!=='string'||!/^\w+(?:\.[xyzwrgba]{1,4})?$/.test(value))throw TypeError('Invalid recipe target');return value;};
41
- for(const step of recipe.operations){
42
- const v=step.value===undefined?'':expression(step.value);
43
- if(step.op==='let'){
44
- if(!['float','vec2','vec3','vec4','bool'].includes(step.type)||!/^\w+$/.test(step.name))throw TypeError('Invalid recipe declaration');
45
- types[step.name]=step.type;lines.push(`${step.type} ${step.name}=${v};`);
46
- if(step.parameter==='sway')lines.push(`${step.name}*=dustRecipeSway;`);
47
- else if(step.parameter==='rebound')lines.push(`if(dustRecipeRebound!=1.0)${step.name}=clamp(${step.name}*dustRecipeRebound,0.05,0.95);`);
48
- else if(step.parameter)throw TypeError('Unsupported recipe parameter');
49
- }else if(['set','add','subtract','multiply'].includes(step.op)){
50
- lines.push(`${target(step.target)}${{set:'=',add:'+=',subtract:'-=',multiply:'*='}[step.op]}${v};`);
51
- }else if(step.op==='turn')lines.push(`${target(step.target)}=thdTurn(${expression(step.vector)},(${expression(step.angle)})*dustRecipeSpin);`);
52
- else if(step.op==='hinge')lines.push(`${target(step.target)}=thdHinge(${expression(step.point)},${expression(step.pivot)},${expression(step.axis)},(${expression(step.angle)})*dustRecipeShape);`);
53
- else if(step.op==='deform'){
54
- const to=target(step.target),type=to.includes('.')?(to.split('.')[1].length===1?'float':'vec'+to.split('.')[1].length):types[to];
55
- if(!type||type==='bool')throw TypeError('Invalid deformation type');
56
- lines.push(`if(dustRecipeShape==1.0)${to}*=${v};else ${to}*=max(${type}(0.01),mix(${type}(1.0),${type}(${v}),dustRecipeShape));`);
57
- }else throw TypeError('Unsupported recipe operation: '+step.op);
58
- }
59
- return lines.join('\n');
60
- }
61
- export const textRecipeUniformShader=`
62
- uniform vec2 dustRecipeDirection;
63
- uniform float dustRecipeDepth;
64
- uniform float dustRecipeSpin;
65
- uniform float dustRecipeSway;
66
- uniform float dustRecipeShape;
67
- uniform float dustRecipeGlow;
68
- uniform vec3 dustRecipePalette;
69
- uniform float dustRecipeRebound;
70
- uniform float dustRecipePace;
71
- `;
1
+ import {motionPrograms} from './text-motion-programs.js';
2
+
3
+ const freeze=value=>{if(value&&typeof value==='object'&&!Object.isFrozen(value)){Object.values(value).forEach(freeze);Object.freeze(value);}return value;};
4
+ // Trusted internal operation graphs. Public settings accept bounded values,
5
+ // never expressions, GLSL, or executable source.
6
+ export const textMotionRecipes=freeze(motionPrograms);
7
+ export const textMotionAliases=freeze({});
8
+ export const textRecipeControls=freeze({
9
+ angle:{label:'جهت حرکت',default:0,min:-180,max:180,step:15,choices:{'0':'اصلی','45':'مورب بالا','-45':'مورب پایین','90':'چرخش بالا','-90':'چرخش پایین','180':'معکوس'}},
10
+ depth:{label:'عمق',default:1,min:0,max:2,step:.1},
11
+ spin:{label:'پیچش',default:1,min:0,max:2,step:.1},
12
+ sway:{label:'موج و نوسان',default:1,min:0,max:2,step:.1},
13
+ shape:{label:'تغییر شکل مثلث‌ها',default:1,min:0,max:1.5,step:.1},
14
+ glow:{label:'درخشش',default:1,min:0,max:2,step:.1},
15
+ palette:{label:'رنگ درخشش',default:'original',choices:{original:'اصلی',cool:'آبی',warm:'گرم',mint:'نعنایی'}},
16
+ rebound:{label:'قدرت جهش',default:1,min:.25,max:1.5,step:.05},
17
+ pace:{label:'ریتم جهش',default:1,min:.25,max:2,step:.05}
18
+ });
19
+ export function normalizeTextMotionRecipe(value={},mode='dust-wind'){
20
+ if(!value||typeof value!=='object'||Array.isArray(value))throw TypeError('Recipe settings must be an object');
21
+ const result=Object.fromEntries(Object.entries(textRecipeControls).map(([key,control])=>[key,control.default]));
22
+ result.angle=textMotionAliases[mode]?.angle??(mode==='dust-wind-upward'?45:0);
23
+ for(const [key,v] of Object.entries(value)){
24
+ if(!Object.hasOwn(textRecipeControls,key))throw TypeError('Unknown recipe setting: '+key);
25
+ const control=textRecipeControls[key];
26
+ if(key==='palette'){if(typeof v!=='string'||!Object.hasOwn(control.choices,v))throw TypeError('Invalid recipe palette');}
27
+ else if(typeof v!=='number'||!Number.isFinite(v)||v<control.min||v>control.max)throw RangeError('Invalid recipe setting: '+key);
28
+ result[key]=v;
29
+ }
30
+ return Object.freeze(result);
31
+ }
32
+
33
+ // Shared compiler: expressions describe math, operations describe composition,
34
+ // uniforms supply the bounded public intent controls.
35
+ export function compileTextMotionRecipe(recipe){
36
+ if(!recipe||!Array.isArray(recipe.operations)||!recipe.operations.length)throw TypeError('Invalid motion recipe');
37
+ const types={face:recipe.ownFace?'vec2':'vec3'};
38
+ const lines=recipe.ownFace?[]:['vec3 face=p-vec3(dustTarget,p.z);'];
39
+ const expression=value=>{if(typeof value!=='string'||!value||/[;{}#]/.test(value))throw TypeError('Invalid recipe expression');return value;};
40
+ const target=value=>{if(typeof value!=='string'||!/^\w+(?:\.[xyzwrgba]{1,4})?$/.test(value))throw TypeError('Invalid recipe target');return value;};
41
+ for(const step of recipe.operations){
42
+ const v=step.value===undefined?'':expression(step.value);
43
+ if(step.op==='let'){
44
+ if(!['float','vec2','vec3','vec4','bool'].includes(step.type)||!/^\w+$/.test(step.name))throw TypeError('Invalid recipe declaration');
45
+ types[step.name]=step.type;lines.push(`${step.type} ${step.name}=${v};`);
46
+ if(step.parameter==='sway')lines.push(`${step.name}*=dustRecipeSway;`);
47
+ else if(step.parameter==='rebound')lines.push(`if(dustRecipeRebound!=1.0)${step.name}=clamp(${step.name}*dustRecipeRebound,0.05,0.95);`);
48
+ else if(step.parameter)throw TypeError('Unsupported recipe parameter');
49
+ }else if(['set','add','subtract','multiply'].includes(step.op)){
50
+ lines.push(`${target(step.target)}${{set:'=',add:'+=',subtract:'-=',multiply:'*='}[step.op]}${v};`);
51
+ }else if(step.op==='turn')lines.push(`${target(step.target)}=thdTurn(${expression(step.vector)},(${expression(step.angle)})*dustRecipeSpin);`);
52
+ else if(step.op==='hinge')lines.push(`${target(step.target)}=thdHinge(${expression(step.point)},${expression(step.pivot)},${expression(step.axis)},(${expression(step.angle)})*dustRecipeShape);`);
53
+ else if(step.op==='deform'){
54
+ const to=target(step.target),type=to.includes('.')?(to.split('.')[1].length===1?'float':'vec'+to.split('.')[1].length):types[to];
55
+ if(!type||type==='bool')throw TypeError('Invalid deformation type');
56
+ lines.push(`if(dustRecipeShape==1.0)${to}*=${v};else ${to}*=max(${type}(0.01),mix(${type}(1.0),${type}(${v}),dustRecipeShape));`);
57
+ }else throw TypeError('Unsupported recipe operation: '+step.op);
58
+ }
59
+ return lines.join('\n');
60
+ }
61
+ export const textRecipeUniformShader=`
62
+ uniform vec2 dustRecipeDirection;
63
+ uniform float dustRecipeDepth;
64
+ uniform float dustRecipeSpin;
65
+ uniform float dustRecipeSway;
66
+ uniform float dustRecipeShape;
67
+ uniform float dustRecipeGlow;
68
+ uniform vec3 dustRecipePalette;
69
+ uniform float dustRecipeRebound;
70
+ uniform float dustRecipePace;
71
+ `;