@ev-ry/fx 0.1.0-rc.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +13 -0
  3. package/QUICKSTART.fa.md +94 -0
  4. package/README.md +116 -0
  5. package/assets/fonts/Estedad-OFL.txt +93 -0
  6. package/assets/vendor/bidi-LICENSE.txt +22 -0
  7. package/assets/vendor/bidi.min.js +1 -0
  8. package/assets/vendor/three-LICENSE.txt +21 -0
  9. package/assets/vendor/three.min.js +7 -0
  10. package/build-report.json +328 -0
  11. package/docs/GUIDE.md +125 -0
  12. package/docs/RELEASE-NOTES.md +40 -0
  13. package/examples/AnimatedTitle.jsx +21 -0
  14. package/examples/navigation-away.html +1 -0
  15. package/examples/navigation.html +38 -0
  16. package/examples/script.html +2 -0
  17. package/package.json +57 -0
  18. package/src/dom-attachment.d.ts +78 -0
  19. package/src/dom-attachment.js +187 -0
  20. package/src/dom-auto-reveal.d.ts +17 -0
  21. package/src/dom-auto-reveal.js +53 -0
  22. package/src/dom-auto-route.js +25 -0
  23. package/src/dom-free-bootstrap.js +42 -0
  24. package/src/dom-free-loader.d.ts +13 -0
  25. package/src/dom-free-loader.js +12 -0
  26. package/src/dom-free-script.js +12 -0
  27. package/src/dom-free.d.ts +16 -0
  28. package/src/dom-free.js +42 -0
  29. package/src/dom-image-raster.js +22 -0
  30. package/src/dom-image-surface.js +109 -0
  31. package/src/dom-image-swap.js +27 -0
  32. package/src/dom-once.js +34 -0
  33. package/src/dom-raster-cache.js +33 -0
  34. package/src/dom-reveal-boot.js +11 -0
  35. package/src/dom-reveal.js +75 -0
  36. package/src/dom-rich-text.js +119 -0
  37. package/src/dom-surface-font.js +50 -0
  38. package/src/dom-svg-surface.js +43 -0
  39. package/src/dom-text-fingerprint.js +27 -0
  40. package/src/dom-text-runs.js +45 -0
  41. package/src/dom-text-surface.js +254 -0
  42. package/src/font-mesh-engine.js +368 -0
  43. package/src/font-rasterizer.js +65 -0
  44. package/src/hybrid-text-flow.js +45 -0
  45. package/src/image-preparation-queue.js +16 -0
  46. package/src/image-source.js +61 -0
  47. package/src/image-surface.js +162 -0
  48. package/src/insertion-range.js +29 -0
  49. package/src/mesh-generator-core.js +149 -0
  50. package/src/motion-envelope.js +22 -0
  51. package/src/motion.js +52 -0
  52. package/src/native-run-shaping.js +60 -0
  53. package/src/particle-centers.js +50 -0
  54. package/src/raster-texture-material.js +79 -0
  55. package/src/raster-texture-mesh.js +35 -0
  56. package/src/render-owner.js +63 -0
  57. package/src/runtime-font-engine.js +242 -0
  58. package/src/runtime-lifecycle.js +26 -0
  59. package/src/text-direction.js +25 -0
  60. package/src/text-edit-effect.js +101 -0
  61. package/src/text-edit-motions.js +15 -0
  62. package/src/text-effect-options.js +29 -0
  63. package/src/text-effect-path.js +61 -0
  64. package/src/text-mesh-density.js +36 -0
  65. package/src/text-motion-character-centers.js +74 -0
  66. package/src/text-motion-contour.js +22 -0
  67. package/src/text-motion-primitives.js +91 -0
  68. package/src/text-motion-programs.js +1 -0
  69. package/src/text-motion-recipes.js +71 -0
  70. package/src/text-scene.js +272 -0
  71. package/src/triangle-coverage.js +25 -0
  72. package/src/triangle-effect.js +194 -0
  73. package/src/triangle-motion-frame.js +38 -0
  74. package/src/viewport-clip.js +119 -0
  75. package/src/viewport-render-owner.js +318 -0
@@ -0,0 +1,101 @@
1
+ import {TriangleEffect} from './triangle-effect.js';
2
+ import {insertedRange,remapInsertionRange} from './insertion-range.js';
3
+ import {textEditMotions} from './text-edit-motions.js';
4
+ import {setTextMotionCharacterCenters} from './text-motion-character-centers.js';
5
+ import {retainRasterTexture} from './raster-texture-material.js';
6
+ import {ensureTextMotionContour} from './text-motion-contour.js';
7
+
8
+ // Text-specific ranges and deletion ghosts adapt the shared triangle timeline.
9
+ export class TextEditEffect extends TriangleEffect {
10
+ prepareCharacterCenters(view,characterRectangles){
11
+ const frame=textEditMotions[this.mode].characterFrame;
12
+ if(!textEditMotions[this.mode].characterCenters&&!frame&&!this.settings.bounceLines)return;
13
+ const floor=textEditMotions[this.mode].characterFloor||this.settings.bounceLines;
14
+ if(this.characterView===view&&this.characterVersion===view.version&&this.characterFloor===floor&&this.characterFrame===frame)return;
15
+ this.decorate(view);
16
+ setTextMotionCharacterCenters(this.THREE,view,characterRectangles?.()||[],{floor,frame});
17
+ this.characterView=view;this.characterVersion=view.version;this.characterFloor=floor;this.characterFrame=frame;
18
+ }
19
+ enqueue(before,after,inputType){
20
+ const range=insertedRange(before,after,inputType);if(!range)return false;
21
+ if(this.text!==null&&this.text!==before.text)this.cancel();
22
+ const delta=after.text.length-before.text.length,oldEnd=range.end-delta;
23
+ this.jobs=this.jobs.flatMap(job=>remapInsertionRange(job.range,range.start,oldEnd,delta).map(piece=>({...job,range:piece})));
24
+ this.jobs.push({id:++this.sequence,seed:this.randomSeed(),range,started:null,rectangles:[]});
25
+ this.text=after.text;this.runs++;return true;
26
+ }
27
+ bind(view,rectanglesForRange,em,now,characterRectangles){
28
+ // Retain only seeds/rectangles for the current layout, never a mesh/history.
29
+ // A reshape invalidates old origins; active jobs are rebound below.
30
+ if(this.boundView!==view||this.boundVersion!==view.version)this.settledJobs=[];
31
+ this.boundView=view;this.boundVersion=view.version;
32
+ this.decorate(view);this.uniforms.dustEm.value=em;
33
+ this.prepareCharacterCenters(view,characterRectangles);
34
+ for(const job of this.jobs){job.rectangles=rectanglesForRange(job.range);job.started??=now;}
35
+ this.jobs=this.jobs.filter(job=>job.rectangles.length);
36
+ this.step(now);return this.active;
37
+ }
38
+ remove(before,after,range,view,rectangles,em,now,characterRectangles){
39
+ const exitMode=this.exitMode,samePath=exitMode===this.mode;
40
+ // Preserve the last visible timeline and random paths, including arrivals
41
+ // that settled in this layout. Initial/programmatic text has no prior path.
42
+ const settled=this.boundView===view&&this.boundVersion===view.version?this.settledJobs:[];
43
+ const interrupted=[...this.jobs,...settled].filter(job=>job.rectangles.some(r=>rectangles.some(s=>r.x<s.x+s.width&&r.x+r.width>s.x&&r.y<s.y+s.height&&r.y+r.height>s.y))).map(job=>({...job,started:null,fromAge:job.started===null?0:Math.max(0,Math.min(1,((this.lastTime??now)-job.started)/this.duration))}));
44
+ const delta=after.text.length-before.text.length;
45
+ this.jobs=this.jobs.flatMap(job=>remapInsertionRange(job.range,range.start,range.end,delta).map(piece=>({...job,range:piece})));
46
+ this.settledJobs=[];
47
+ this.text=after.text;
48
+ if(!rectangles.length||!view.scene.parent)return false;
49
+ this.decorate(view);
50
+ this.prepareCharacterCenters(view,characterRectangles);
51
+ // A different removal preset starts from the complete retained facets. Its
52
+ // metadata must use the original full character, before filtering deletions.
53
+ if(!samePath){
54
+ const motion=textEditMotions[exitMode];
55
+ if(motion.characterCenters||motion.characterFrame||this.settings.bounceLines)
56
+ setTextMotionCharacterCenters(this.THREE,view,characterRectangles?.()||[],{floor:motion.characterFloor||this.settings.bounceLines,frame:motion.characterFrame});
57
+ // Extra attributes are shared by both modes; invalidate the arrival cache
58
+ // so a subsequent layout bind can refresh any requested metadata.
59
+ this.characterView=null;
60
+ }
61
+ const THREE=this.THREE,group=new THREE.Group(),sharedGroups=new Map();
62
+ group.position.copy(view.scene.position);group.quaternion.copy(view.scene.quaternion);group.scale.copy(view.scene.scale);
63
+ for(const [key,{mesh}] of view.sharedGroups){
64
+ // Retain only departing triangles, not another complete copy of the paragraph.
65
+ const original=mesh.geometry,position=original.getAttribute('position'),offsets=original.getAttribute('glyphOffset');
66
+ if(textEditMotions[exitMode].contour)ensureTextMotionContour(THREE,original);
67
+ const names=['position','color','delay'];if(original.getAttribute('dustEdge'))names.push('dustEdge');
68
+ if(original.getAttribute('rasterUV'))names.push('rasterUV');
69
+ if(original.getAttribute('dustCharacterSide'))names.push('dustCharacterSide');
70
+ if(original.getAttribute('dustCharacterFrame'))names.push('dustCharacterFrame');
71
+ if(original.getAttribute('dustFloor'))names.push('dustFloor');
72
+ if(original.getAttribute('dustCeiling'))names.push('dustCeiling');
73
+ for(const name of ['dustCornerA','dustCornerB','dustCornerC'])if(original.getAttribute(name))names.push(name);
74
+ const values=Object.fromEntries([...names,'glyphOffset'].map(name=>[name,[]]));
75
+ for(let instance=0;instance<original.instanceCount;instance++){
76
+ const ox=offsets.getX(instance),oy=offsets.getY(instance),box=original.boundingBox;
77
+ if(box&&!rectangles.some(r=>box.max.x+ox>=r.x&&box.min.x+ox<=r.x+r.width&&box.max.y+oy>=r.y&&box.min.y+oy<=r.y+r.height))continue;
78
+ for(let i=0;i<position.count;i+=3){
79
+ const x=(position.getX(i)+position.getX(i+1)+position.getX(i+2))/3+ox,y=(position.getY(i)+position.getY(i+1)+position.getY(i+2))/3+oy;
80
+ if(!rectangles.some(r=>x>=r.x&&x<=r.x+r.width&&y>=r.y&&y<=r.y+r.height))continue;
81
+ for(let j=0;j<3;j++){
82
+ for(const name of names){const a=original.getAttribute(name);for(let c=0;c<a.itemSize;c++)values[name].push(a.array[(i+j)*a.itemSize+c]);}
83
+ values.glyphOffset.push(ox,oy);
84
+ }
85
+ }
86
+ }
87
+ if(!values.position.length)continue;
88
+ const geometry=new THREE.BufferGeometry();for(const [name,array] of Object.entries(values))geometry.setAttribute(name,new THREE.Float32BufferAttribute(array,name==='glyphOffset'?2:original.getAttribute(name).itemSize));
89
+ const material=mesh.material.clone(),source=mesh.material.userData.dustSource||mesh.material;
90
+ const releaseTexture=retainRasterTexture(mesh.material,material);
91
+ material.vertexShader=source.vertexShader;material.fragmentShader=source.fragmentShader;
92
+ delete material.userData.dustWind;delete material.userData.dustSource;
93
+ for(const name of Object.keys(material.uniforms))if(name.startsWith('dust'))delete material.uniforms[name];
94
+ const copy=new THREE.Mesh(geometry,material);copy.frustumCulled=false;group.add(copy);sharedGroups.set(key,{mesh:copy,releaseTexture});
95
+ }
96
+ const effect=new TextEditEffect(THREE,{departing:true,mode:exitMode,settings:this.settings});
97
+ effect.jobs=[...(samePath?interrupted:[]),{id:0,seed:this.randomSeed(),range,started:null,fromAge:1,rectangles}];effect.decorate({sharedGroups});effect.uniforms.dustEm.value=em;
98
+ const release=()=>{group.removeFromParent();for(const {mesh,releaseTexture} of sharedGroups.values()){mesh.geometry.dispose();mesh.material.dispose();releaseTexture?.();}effect.dispose();};
99
+ view.scene.parent.add(group);this.ghosts.push({effect,release});this.exits++;this.active=true;return true;
100
+ }
101
+ }
@@ -0,0 +1,15 @@
1
+ // Public catalogue contains metadata only. Paths come from shared recipes.
2
+ import {compileTextMotionRecipe,textMotionRecipes,textMotionAliases} from './text-motion-recipes.js';
3
+ export const DEFAULT_TEXT_EFFECT_DURATION=2000;
4
+ const definitions=[{"key":"dust-wind","id":0,"label":"باد و گردوخاک","description":"ذرات با باد می‌آیند و هنگام حذف همان مسیر را برمی‌گردند. تنظیم قبلی باد حفظ شده است."},{"key":"smoke","id":4,"label":"دود و پراکندگی","description":"قطعه‌ها هنگام حذف رو به بالا می‌پیچند، ریز و محو می‌شوند. تایپ همان حرکت را برعکس می‌کند."},{"key":"melt","id":10,"label":"ذوب و انجماد","description":"هنگام حذف، وجه‌ها کش می‌آیند و مثل قطره پایین می‌روند؛ ورود متن، جمع‌شدن و انجماد همان قطره‌هاست."},{"key":"drifting-snow","id":28,"label":"برف معلق","description":"مثلث‌ها مثل دانه‌های برف با چرخش و نوسان جانبی آرام پایین می‌آیند و می‌نشینند؛ حذف، همان حرکت را برمی‌گرداند."}];
5
+ const catalogue=Object.assign(Object.create(null),Object.fromEntries(definitions.map(({key,id,label,description,contour=false,characterCenters=false,characterFloor=false,characterFrame=false})=>[key,Object.freeze({id,label,description,contour,characterCenters,characterFloor,characterFrame,duration:DEFAULT_TEXT_EFFECT_DURATION})])));
6
+ for(const [key,alias] of Object.entries(textMotionAliases))Object.defineProperty(catalogue,key,{value:catalogue[alias.base],enumerable:false});
7
+ export const textEditMotions=Object.freeze(catalogue);
8
+ export const isInputEffect=value=>typeof value==='string'&&(value==='none'||Object.hasOwn(textEditMotions,value));
9
+ export const additionalTextMotion=definitions.map(({id,key})=>`
10
+ #if THD_EDIT_MODE == ${id}
11
+ {
12
+ ${compileTextMotionRecipe(textMotionRecipes[key])}
13
+ }
14
+ #endif
15
+ `).join('');
@@ -0,0 +1,29 @@
1
+ // Public intent controls; the engine derives particle ranges and timing from them.
2
+ import {normalizeTextMotionRecipe,textMotionAliases} from './text-motion-recipes.js';
3
+ import {textEditMotions} from './text-edit-motions.js';
4
+ export const TEXT_EFFECT_DEFAULTS=Object.freeze({particleShape:'triangle',exitEffect:'same',duration:2000,motion:1,formation:0,chaos:0.5,horizontal:1,vertical:1,flipX:false,flipY:false,exitFlipX:false,exitFlipY:false,bounceLines:false,seed:null,recipe:normalizeTextMotionRecipe()});
5
+ export const cloneTextEffectOptions=value=>({...value,...(value.recipe?{recipe:{...value.recipe}}:{})});
6
+ const limits={duration:[200,8000],motion:[0.25,2],formation:[-1,1],chaos:[0,1],horizontal:[0,2],vertical:[0,2],seed:[0,4294967295]};
7
+ export function normalizeTextEffectOptions(value={},mode='dust-wind'){
8
+ if(!value||typeof value!=='object'||Array.isArray(value))throw TypeError('Effect settings must be an object');
9
+ for(const [key,v] of Object.entries(value)){
10
+ if(!Object.hasOwn(TEXT_EFFECT_DEFAULTS,key))throw TypeError('Unknown effect setting: '+key);
11
+ if(key==='exitEffect'){if(v!=='same'&&(typeof v!=='string'||!Object.keys(textEditMotions).includes(v)))throw TypeError('Invalid exitEffect');continue;}
12
+ if(key==='particleShape'){if(!['triangle','square'].includes(v))throw TypeError('Invalid particleShape');continue;}
13
+ if(key==='recipe'){normalizeTextMotionRecipe(v,mode);continue;}
14
+ if(key==='seed'&&v===null)continue;
15
+ if(limits[key]){const [lo,hi]=limits[key];if(typeof v!=='number'||!Number.isFinite(v)||v<lo||v>hi||(key==='seed'&&!Number.isInteger(v)))throw RangeError('Invalid effect setting: '+key);}
16
+ else if(typeof v!=='boolean')throw TypeError(key+' must be boolean');
17
+ }
18
+ return Object.freeze({...TEXT_EFFECT_DEFAULTS,bounceLines:textMotionAliases[mode]?.bounceLines??(mode==='dust-wind-upward'),...value,recipe:normalizeTextMotionRecipe(value.recipe,mode)});
19
+ }
20
+
21
+ export const textEffectOptionShader=`
22
+ // The distance/speed ratio retains the wind distribution at formation=0.
23
+ // A continuous exponent adjusts arrival spread; no fixed particle cohorts.
24
+ float thdFormationSeconds(float seconds,float duration,float formation){
25
+ if(formation==0.0)return seconds;
26
+ return duration*pow(clamp(seconds/duration,0.0,1.0),exp2(formation));
27
+ }
28
+ `;
29
+
@@ -0,0 +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
+ `;
@@ -0,0 +1,36 @@
1
+ import {DIVISIONS} from './mesh-generator-core.js';
2
+
3
+ // Density is measured in CSS pixels, independent of DPR, text length and color.
4
+ // One nominal 16px em uses 16 rows. Above that, square-root density growth
5
+ // makes a full 2D grid grow linearly with font size, not its square.
6
+ export function normalizeTextMeshOptions({textRendering='texture',divisions=textRendering==='texture'?'auto':36}={}){
7
+ if(!['coverage','texture'].includes(textRendering))throw TypeError('Invalid textRendering');
8
+ const valid=textRendering==='texture'?(divisions==='auto'||Number.isInteger(divisions)&&divisions>=1&&divisions<=256):DIVISIONS.includes(divisions);
9
+ if(!valid)throw TypeError('Invalid text mesh divisions');
10
+ return {textRendering,divisions};
11
+ }
12
+
13
+ export function textDivisionsForSize(fontSize,current=null){
14
+ if(!Number.isFinite(fontSize)||fontSize<=0)return current??16;
15
+ // Small text never retains a denser grid through hysteresis. One row is the
16
+ // minimum valid grid, including for subpixel em sizes.
17
+ if(fontSize<16)return Math.max(1,Math.floor(fontSize));
18
+ const target=Math.min(72,16*Math.sqrt(fontSize/16));
19
+ if(current>=16&&current<=72&&Math.abs(target-current)<=Math.max(1,current*.10))return current;
20
+ return Math.min(72,2*Math.round(target/2));
21
+ }
22
+
23
+ // A transformed local em projected into the current viewport, in CSS pixels.
24
+ // Reuse scratch vectors: this helper is evaluated on requested frames only.
25
+ export function createProjectedTextSize(THREE){
26
+ const origin=new THREE.Vector3(),x=new THREE.Vector3(),y=new THREE.Vector3();
27
+ return (object,camera,canvas,em)=>{
28
+ const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return 0;
29
+ object.updateWorldMatrix(true,false);camera.updateMatrixWorld(true);
30
+ origin.set(0,0,0).applyMatrix4(object.matrixWorld).project(camera);
31
+ if(origin.z< -1||origin.z>1)return 0;
32
+ x.set(em,0,0).applyMatrix4(object.matrixWorld).project(camera);
33
+ y.set(0,em,0).applyMatrix4(object.matrixWorld).project(camera);
34
+ return Math.max(Math.hypot((x.x-origin.x)*width/2,(x.y-origin.y)*height/2),Math.hypot((y.x-origin.x)*width/2,(y.y-origin.y)*height/2));
35
+ };
36
+ }
@@ -0,0 +1,74 @@
1
+ // Optional per-facet position and frame within a measured grapheme. Whole lines
2
+ // may share one mesh, so its bounding-box center is not a character center.
3
+ // Prepared on layout changes, never in the render loop. Ghosts retain this data.
4
+ export function setTextMotionCharacterCenters(THREE,view,rectangles,{floor=false,frame=false}={}){
5
+ const rows=new Map();
6
+ for(const r of rectangles){
7
+ if(!(r.width>0&&r.height>0))continue;
8
+ const key=r.y+':'+r.height;
9
+ if(!rows.has(key))rows.set(key,{bottom:r.y,top:r.y+r.height,cells:[]});
10
+ rows.get(key).cells.push({left:r.x,right:r.x+r.width,mid:r.x+r.width/2});
11
+ }
12
+ const ordered=[...rows.values()].sort((a,b)=>a.bottom-b.bottom);
13
+ for(const row of ordered)row.cells.sort((a,b)=>a.left-b.left||a.right-b.right);
14
+ for(const {mesh} of view.sharedGroups?.values()||[]){
15
+ const g=mesh.geometry,centers=g.getAttribute('dustCenter'),offset=g.getAttribute('glyphOffset');
16
+ if(!centers||!offset||!centers.count)continue;
17
+ const ox=offset.getX(0),oy=offset.getY(0),values=new Float32Array(centers.count);
18
+ const frames=frame?new Float32Array(centers.count*4):null;
19
+ const floors=floor?new Float32Array(centers.count*2):null,position=g.getAttribute('position');
20
+ const ceilings=floor?new Float32Array(centers.count*4):null;
21
+ const box=g.boundingBox||(g.computeBoundingBox(),g.boundingBox);
22
+ const fallbackMid=(box.min.x+box.max.x)/2,fallbackWidth=box.max.x-box.min.x;
23
+ // Identical instanced glyphs use the same local character layout. Row choice
24
+ // is made using their first instance; offsets are not baked into the result.
25
+ const baseline=oy+(box.min.y+box.max.y)/2;
26
+ let row=ordered.find(r=>baseline>=r.bottom&&baseline<=r.top);
27
+ if(!row&&ordered.length)row=ordered.reduce((a,b)=>Math.abs((a.bottom+a.top)/2-baseline)<Math.abs((b.bottom+b.top)/2-baseline)?a:b);
28
+ for(let i=0;i<centers.count;i+=3){
29
+ const x=centers.getX(i)+ox,cells=row?.cells;let cell;
30
+ if(cells?.length){
31
+ let lo=0,hi=cells.length;
32
+ while(lo<hi){const mid=(lo+hi)>>>1;if(cells[mid].left<=x)lo=mid+1;else hi=mid;}
33
+ cell=cells[Math.max(0,lo-1)];
34
+ if(x>cell.right&&lo<cells.length&&Math.abs(cells[lo].mid-x)<Math.abs(cell.mid-x))cell=cells[lo];
35
+ }
36
+ const side=cell?(x-cell.mid)/Math.max((cell.right-cell.left)/2,.000001):(centers.getX(i)-fallbackMid)/Math.max(fallbackWidth/2,.000001);
37
+ values.fill(Math.max(-1,Math.min(1,side)),i,i+3);
38
+ if(frames){
39
+ // A single measured grapheme/line frame, not a square grid through its
40
+ // ink. Keep it local so identical instances and deletion ghosts agree.
41
+ const bounds=[cell?cell.left-ox:box.min.x,row?row.bottom-oy:box.min.y,
42
+ Math.max(cell?cell.right-cell.left:fallbackWidth,.000001),
43
+ Math.max(row?row.top-row.bottom:box.max.y-box.min.y,.000001)];
44
+ for(let j=0;j<3;j++)frames.set(bounds,(i+j)*4);
45
+ }
46
+ if(floors){
47
+ // Contact uses the lowest vertex, not the centroid. Keep facets rigid,
48
+ // and preserve the measured font-line bottom through partial deletion.
49
+ const support=centers.getY(i)-Math.min(position.getY(i),position.getY(i+1),position.getY(i+2));
50
+ for(let j=0;j<3;j++){floors[(i+j)*2]=row?row.bottom-oy:box.min.y;floors[(i+j)*2+1]=support;}
51
+ const left=Math.min(position.getX(i),position.getX(i+1),position.getX(i+2))-centers.getX(i);
52
+ const right=Math.max(position.getX(i),position.getX(i+1),position.getX(i+2))-centers.getX(i);
53
+ const upper=Math.max(position.getY(i),position.getY(i+1),position.getY(i+2))-centers.getY(i);
54
+ for(let j=0;j<3;j++)ceilings.set([row?row.top-oy:box.max.y,left,right,upper],(i+j)*4);
55
+ }
56
+ }
57
+ const existing=g.getAttribute('dustCharacterSide');
58
+ if(existing?.count===values.length){existing.array.set(values);existing.needsUpdate=true;}
59
+ else g.setAttribute('dustCharacterSide',new THREE.BufferAttribute(values,1));
60
+ if(frames){
61
+ const previous=g.getAttribute('dustCharacterFrame');
62
+ if(previous?.count===centers.count){previous.array.set(frames);previous.needsUpdate=true;}
63
+ else g.setAttribute('dustCharacterFrame',new THREE.BufferAttribute(frames,4));
64
+ }
65
+ if(floors){
66
+ const previous=g.getAttribute('dustFloor');
67
+ if(previous?.count===centers.count){previous.array.set(floors);previous.needsUpdate=true;}
68
+ else g.setAttribute('dustFloor',new THREE.BufferAttribute(floors,2));
69
+ const ceiling=g.getAttribute('dustCeiling');
70
+ if(ceiling?.count===centers.count){ceiling.array.set(ceilings);ceiling.needsUpdate=true;}
71
+ else g.setAttribute('dustCeiling',new THREE.BufferAttribute(ceilings,4));
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,22 @@
1
+ // Optional contour distances on the existing de-indexed triangle mesh. No font
2
+ // rasterization, geometry copies or frame-time work. Opposite-edge distances
3
+ // interpolate into a continuous border, excluding internal shared diagonals.
4
+ export function ensureTextMotionContour(THREE,geometry){
5
+ if(geometry.getAttribute('dustEdge'))return geometry.getAttribute('dustEdge');
6
+ const positions=geometry.getAttribute('position'),edges=new Map(),keys=[];
7
+ for(let i=0;i<positions.count;i++)keys.push(`${positions.getX(i)},${positions.getY(i)}`);
8
+ const edgeKey=(a,b)=>keys[a]<keys[b]?keys[a]+'|'+keys[b]:keys[b]+'|'+keys[a];
9
+ for(let i=0;i<positions.count;i+=3)for(let k=0;k<3;k++){
10
+ const key=edgeKey(i+(k+1)%3,i+(k+2)%3);edges.set(key,(edges.get(key)||0)+1);
11
+ }
12
+ const distances=new Float32Array(positions.count*3).fill(1e6);
13
+ for(let i=0;i<positions.count;i+=3)for(let k=0;k<3;k++){
14
+ const a=i+(k+1)%3,b=i+(k+2)%3;
15
+ if(edges.get(edgeKey(a,b))!==1)continue;
16
+ const dx=positions.getX(b)-positions.getX(a),dy=positions.getY(b)-positions.getY(a),length=Math.hypot(dx,dy);
17
+ if(length===0)continue;
18
+ const altitude=Math.abs(dx*(positions.getY(i+k)-positions.getY(a))-dy*(positions.getX(i+k)-positions.getX(a)))/length;
19
+ for(let vertex=0;vertex<3;vertex++)distances[(i+vertex)*3+k]=vertex===k?altitude:0;
20
+ }
21
+ const attribute=new THREE.BufferAttribute(distances,3);geometry.setAttribute('dustEdge',attribute);return attribute;
22
+ }
@@ -0,0 +1,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
+ 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
+ `;
@@ -0,0 +1 @@
1
+ export const motionPrograms={"dust-wind":{"channels":["angle","depth","shape","sway"],"operations":[{"op":"let","type":"vec2","name":"windOrigin","value":"p.xy"},{"op":"let","type":"float","name":"windReach","value":"0.5"},{"op":"let","type":"float","name":"amplitude","value":"0.06+0.24*drift"},{"op":"let","type":"vec2","name":"face","value":"p.xy-dustTarget"},{"op":"deform","target":"face","value":"vec2(mix(1.0,2.4,remain),mix(1.0,0.32,remain))"},{"op":"set","target":"p.xy","value":"dustTarget+face"},{"op":"add","target":"p.x","value":"windReach*clock.y*dustEm*travel.x*remain"},{"op":"let","type":"float","name":"gust","value":"sin(t*12.0+particle.y*2.0+clock.w*6.283)","parameter":"sway"},{"op":"let","type":"float","name":"eddy","value":"sin(seed*6.283+t*(18.0+seed*12.0))","parameter":"sway"},{"op":"add","target":"p.y","value":"windReach*1.5*dustEm*remain*((drift-0.5)*0.55+amplitude*(gust+eddy*0.3))"},{"op":"add","target":"p.z","value":"windReach*dustEm*remain*(seed-0.5)*0.12"}],"ownFace":true},"smoke":{"channels":["angle","depth","sway","shape","spin"],"operations":[{"op":"let","type":"float","name":"phase","value":"drift*6.2831853"},{"op":"let","type":"float","name":"curl","value":"sin(remain*7.0+phase)-sin(phase)","parameter":"sway"},{"op":"deform","target":"face","value":"mix(1.0,0.65,remain)"},{"op":"turn","target":"face.xy","vector":"face.xy","angle":"(seed-0.5)*remain*6.0"},{"op":"set","target":"p","value":"vec3(dustTarget,p.z)+face"},{"op":"add","target":"p.x","value":"dustEm*remain*((drift-0.5)*1.2+clock.y*0.22+curl*0.22)"},{"op":"add","target":"p.y","value":"dustEm*remain*(0.2+seed*0.85)+dustEm*remain*curl*0.08"},{"op":"add","target":"p.z","value":"dustEm*remain*(seed-0.5)*0.28"}]},"drifting-snow":{"channels":["angle","depth","sway","shape","spin","glow","palette"],"operations":[{"op":"let","type":"float","name":"phase","value":"drift*6.2831853"},{"op":"let","type":"float","name":"sway","value":"thdSway(remain,phase,0.65+0.6*seed)","parameter":"sway"},{"op":"deform","target":"face","value":"1.0-0.35*remain"},{"op":"turn","target":"face.xy","vector":"face.xy","angle":"remain*((seed-0.5)*2.0+sway*0.6)"},{"op":"turn","target":"face.xz","vector":"face.xz","angle":"remain*0.75*sin(phase+remain*8.0)"},{"op":"set","target":"p","value":"vec3(dustTarget,p.z)+face"},{"op":"add","target":"p.x","value":"clock.y*dustEm*remain*(0.18+0.08*seed+sway*(0.18+0.12*drift))"},{"op":"add","target":"p.y","value":"dustEm*(remain*(0.75+0.85*pace)+0.07*thdPulse(t)*sin(phase+remain*7.0))"},{"op":"add","target":"p.z","value":"dustEm*remain*0.04"},{"op":"set","target":"dustGlow","value":"0.16*thdPulse(t)*remain"},{"op":"set","target":"dustGlowColor","value":"vec3(0.88,0.96,1.0)"}]},"melt":{"channels":["angle","depth","shape","glow","palette"],"operations":[{"op":"let","type":"float","name":"local","value":"thdStagger(t,seed,0.25)"},{"op":"let","type":"float","name":"flow","value":"(1.0-local)*(1.0-local)"},{"op":"deform","target":"face.xy","value":"vec2(1.0-0.76*flow,1.0+2.6*flow)"},{"op":"set","target":"p","value":"vec3(dustTarget,p.z)+face"},{"op":"subtract","target":"p.y","value":"dustEm*flow*(0.35+0.65*drift)"},{"op":"add","target":"p.x","value":"dustEm*flow*0.08*sin(particle.y*4.0+seed*6.2831853)"},{"op":"add","target":"p.z","value":"dustEm*flow*0.035"},{"op":"set","target":"dustGlow","value":"0.13*thdPulse(local)"}]}};
@@ -0,0 +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
+ `;