@ev-ry/fx 0.1.0-rc.3 → 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,21 +1,21 @@
1
1
  import { documentDirection } from './text-direction.js';
2
2
  import { FontMeshEngine, shapeLine } from './font-mesh-engine.js';
3
3
  import { FontRasterizer } from './font-rasterizer.js';
4
- import { rasterToTriangles } from './mesh-generator-core.js';
5
- import { shapeNativeRuns, nativeRunMetrics } from './native-run-shaping.js';
6
- import { rasterToTextureMesh } from './raster-texture-mesh.js';
7
- import { normalizeTextMeshOptions, textDivisionsForSize } from './text-mesh-density.js';
4
+ import { rasterToTriangles } from './mesh-generator-core.js';
5
+ import { shapeNativeRuns, nativeRunMetrics } from './native-run-shaping.js';
6
+ import { rasterToTextureMesh } from './raster-texture-mesh.js';
7
+ import { normalizeTextMeshOptions, textDivisionsForSize } from './text-mesh-density.js';
8
8
 
9
9
  let familySequence = 0;
10
10
 
11
11
  export class RuntimeFontEngine extends FontMeshEngine {
12
- constructor({ textRendering = 'coverage', divisions = textRendering === 'texture' ? 'auto' : 48, weight = 400, cacheLimit = 512, cacheBudgetBytes = 8 * 1024 * 1024, ...options } = {}) {
13
- super(options);
14
- normalizeTextMeshOptions({textRendering,divisions});
15
- this.divisionMode=divisions;
16
- this.divisions = divisions==='auto'?textDivisionsForSize(16):divisions;
17
- this.displayFontSize=null;this.densityChanges=0;
18
- this.textRendering=textRendering;
12
+ constructor({ textRendering = 'coverage', divisions = textRendering === 'texture' ? 'auto' : 48, weight = 400, cacheLimit = 512, cacheBudgetBytes = 8 * 1024 * 1024, ...options } = {}) {
13
+ super(options);
14
+ normalizeTextMeshOptions({textRendering,divisions});
15
+ this.divisionMode=divisions;
16
+ this.divisions = divisions==='auto'?textDivisionsForSize(16):divisions;
17
+ this.displayFontSize=null;this.densityChanges=0;
18
+ this.textRendering=textRendering;
19
19
  this.weight = weight;
20
20
  if (!Number.isInteger(cacheLimit) || cacheLimit < 0 || !Number.isFinite(cacheBudgetBytes) || cacheBudgetBytes < 0) throw new RangeError('Invalid cache limits');
21
21
  this.cacheLimit = cacheLimit;
@@ -57,26 +57,26 @@ export class RuntimeFontEngine extends FontMeshEngine {
57
57
  this.rasterizer?.cache.clear();
58
58
  }
59
59
 
60
- setDivisions(value) {
61
- normalizeTextMeshOptions({textRendering:this.textRendering,divisions:value});
62
- this.divisionMode=value;
63
- const next=value==='auto'?textDivisionsForSize(this.displayFontSize):value;
64
- if (next === this.divisions) return;
65
- this.divisions = next; this.clearMeshes();
66
- }
67
-
68
- setDisplayFontSize(pixels) {
69
- if(this.divisionMode!=='auto'||!Number.isFinite(pixels)||pixels<=0)return false;
70
- const next=textDivisionsForSize(pixels,this.displayFontSize===null?null:this.divisions);
71
- this.displayFontSize=pixels;
72
- if(next===this.divisions)return false;
73
- this.divisions=next;this.densityChanges++;
74
- // Keep source rasters and GPU texture identity. Replace glyph geometry lazily;
75
- // the scene can display its previous records until preparation commits.
76
- this.meshRevision=(this.meshRevision||0)+1;
77
- this.lineCache.clear();this.cachedLayout=null;
78
- return true;
79
- }
60
+ setDivisions(value) {
61
+ normalizeTextMeshOptions({textRendering:this.textRendering,divisions:value});
62
+ this.divisionMode=value;
63
+ const next=value==='auto'?textDivisionsForSize(this.displayFontSize):value;
64
+ if (next === this.divisions) return;
65
+ this.divisions = next; this.clearMeshes();
66
+ }
67
+
68
+ setDisplayFontSize(pixels) {
69
+ if(this.divisionMode!=='auto'||!Number.isFinite(pixels)||pixels<=0)return false;
70
+ const next=textDivisionsForSize(pixels,this.displayFontSize===null?null:this.divisions);
71
+ this.displayFontSize=pixels;
72
+ if(next===this.divisions)return false;
73
+ this.divisions=next;this.densityChanges++;
74
+ // Keep source rasters and GPU texture identity. Replace glyph geometry lazily;
75
+ // the scene can display its previous records until preparation commits.
76
+ this.meshRevision=(this.meshRevision||0)+1;
77
+ this.lineCache.clear();this.cachedLayout=null;
78
+ return true;
79
+ }
80
80
 
81
81
  setInputLayout(direction, alignment, width = 0) {
82
82
  if (!['auto','ltr','rtl'].includes(direction) || !['left','right','auto'].includes(alignment) || !Number.isFinite(width) || width < 0) throw new TypeError('Invalid input layout');
@@ -140,22 +140,22 @@ export class RuntimeFontEngine extends FontMeshEngine {
140
140
  ensureGlyph(item) {
141
141
  const key = `${item.key}:${item.form}`;
142
142
  this.lastUsed.delete(key); this.lastUsed.set(key, ++this.clock);
143
- const previous=this.records.get(key);
144
- if (previous && (this.textRendering!=='texture'||previous.divisions===this.divisions)) return;
145
- const started = performance.now();
146
- const raster = previous?.rasterSurface?{...previous.rasterSurface,baseline:previous.rasterBaseline,advance:previous.advance,drawOffsetX:previous.drawOffsetX}:this.rasterizer.raster(item.rasterText, item.direction);
147
- const textured=this.textRendering==='texture';
148
- const meshData = textured?rasterToTextureMesh(raster.rgba,raster.width,raster.height,this.divisions):rasterToTriangles(raster.rgba, raster.width, raster.height, this.divisions, true, 10);
143
+ const previous=this.records.get(key);
144
+ if (previous && (this.textRendering!=='texture'||previous.divisions===this.divisions)) return;
145
+ const started = performance.now();
146
+ const raster = previous?.rasterSurface?{...previous.rasterSurface,baseline:previous.rasterBaseline,advance:previous.advance,drawOffsetX:previous.drawOffsetX}:this.rasterizer.raster(item.rasterText, item.direction);
147
+ const textured=this.textRendering==='texture';
148
+ const meshData = textured?rasterToTextureMesh(raster.rgba,raster.width,raster.height,this.divisions):rasterToTriangles(raster.rgba, raster.width, raster.height, this.divisions, true, 10);
149
149
  // Raster canvases can grow for unusual marks; align their real baseline
150
150
  // with the fixed font-wide origin used by selection and caret.
151
151
  const shift = this.fontMetrics.ascent - raster.baseline;
152
152
  if (shift) for (let i = 1; i < meshData.coordinates.length; i += 2) meshData.coordinates[i] += shift;
153
- const byteLength = meshData.coordinates.byteLength + meshData.coverage.byteLength + (textured?meshData.uvs.byteLength+raster.rgba.byteLength:0);
154
- this.cacheBytes += byteLength-(previous?.byteLength||0);
155
- this.records.set(key, { cacheKey: key, byteLength, meshData, width: raster.width, height: this.fontMetrics.height,
156
- baseline: this.fontMetrics.ascent, advance: raster.advance, drawOffsetX: raster.drawOffsetX,
157
- ...(textured?{divisions:this.divisions,rasterBaseline:raster.baseline,rasterSurface:previous?.rasterSurface||{width:raster.width,height:raster.height,rgba:raster.rgba}}:{}) });
158
- this.rasterizer.cache.clear(); // The optional texture is owned/accounted by its glyph record.
153
+ const byteLength = meshData.coordinates.byteLength + meshData.coverage.byteLength + (textured?meshData.uvs.byteLength+raster.rgba.byteLength:0);
154
+ this.cacheBytes += byteLength-(previous?.byteLength||0);
155
+ this.records.set(key, { cacheKey: key, byteLength, meshData, width: raster.width, height: this.fontMetrics.height,
156
+ baseline: this.fontMetrics.ascent, advance: raster.advance, drawOffsetX: raster.drawOffsetX,
157
+ ...(textured?{divisions:this.divisions,rasterBaseline:raster.baseline,rasterSurface:previous?.rasterSurface||{width:raster.width,height:raster.height,rgba:raster.rgba}}:{}) });
158
+ this.rasterizer.cache.clear(); // The optional texture is owned/accounted by its glyph record.
159
159
  this.generated++;
160
160
  this.generationMs += performance.now() - started;
161
161
  }
@@ -1,101 +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
- }
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
+ }
@@ -1,15 +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('');
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('');
@@ -1,29 +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
- `;
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
29
 
@@ -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
+ `;