@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.
- package/QUICKSTART.fa.md +105 -93
- package/README.md +12 -7
- package/build-report.json +63 -58
- package/docs/GUIDE.md +8 -2
- package/docs/RELEASE-NOTES.md +33 -0
- package/examples/evry-website.md +58 -0
- package/package.json +57 -57
- package/src/dom-attachment.d.ts +72 -72
- package/src/dom-free-loader.js +17 -11
- package/src/dom-free-script.js +17 -11
- package/src/dom-free.d.ts +14 -14
- package/src/dom-free.js +62 -38
- package/src/dom-image-surface.js +161 -101
- package/src/dom-image-swap.js +30 -24
- package/src/dom-once.js +39 -34
- package/src/dom-raster-cache.js +33 -33
- package/src/dom-reveal.js +83 -73
- package/src/dom-svg-surface.js +41 -39
- package/src/dom-text-paint-mask.js +24 -0
- package/src/dom-text-surface.js +52 -21
- package/src/font-rasterizer.js +20 -20
- package/src/image-surface.js +161 -157
- package/src/insertion-range.js +29 -29
- package/src/raster-texture-material.js +79 -79
- package/src/raster-texture-mesh.js +35 -35
- package/src/render-owner.js +17 -12
- package/src/runtime-font-engine.js +43 -43
- package/src/text-edit-effect.js +101 -101
- package/src/text-edit-motions.js +15 -15
- package/src/text-effect-options.js +28 -28
- package/src/text-effect-path.js +61 -61
- package/src/text-motion-primitives.js +98 -91
- package/src/text-motion-recipes.js +71 -71
- package/src/triangle-effect.js +195 -194
- package/src/viewport-render-owner.js +322 -316
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
// Shared raster -> UV triangle grid for font masks and RGBA images. No color
|
|
2
|
-
// averaging, alpha quantization or triangle merging. All filtering is on the GPU.
|
|
3
|
-
export function rasterToTextureMesh(rgba,width,height,divisions,{filterRadius=.5,aspect=width/height}={}){
|
|
4
|
-
if(!Number.isInteger(width)||width<1||!Number.isInteger(height)||height<1||rgba.length!==width*height*4)throw RangeError('Invalid raster');
|
|
5
|
-
if(!Number.isInteger(divisions)||divisions<1||divisions>256)throw RangeError('Invalid divisions');
|
|
6
|
-
if(!Number.isFinite(filterRadius)||filterRadius<.5)throw RangeError('Invalid texture filter support');
|
|
7
|
-
if(!Number.isFinite(aspect)||aspect<=0)throw RangeError('Invalid grid aspect');
|
|
8
|
-
const rows=divisions,columns=Math.max(1,Math.round(aspect*rows));
|
|
9
|
-
const cw=width/columns,ch=height/rows,occupied=new Uint8Array(rows*columns);
|
|
10
|
-
// Conservative filter support: half a texel for bilinear filtering by default;
|
|
11
|
-
// image surfaces may request the wider footprint used by minified mipmaps.
|
|
12
|
-
// A thin line or isolated pixel cannot be lost to sparse point sampling.
|
|
13
|
-
for(let y=0;y<height;y++)for(let x=0;x<width;x++){
|
|
14
|
-
if(!rgba[(y*width+x)*4+3])continue;
|
|
15
|
-
const x0=Math.max(0,x-filterRadius),x1=Math.min(width,x+1+filterRadius),y0=Math.max(0,y-filterRadius),y1=Math.min(height,y+1+filterRadius);
|
|
16
|
-
for(let row=Math.floor(y0/ch);row<=Math.min(rows-1,Math.floor(y1/ch));row++)for(let col=Math.floor(x0/cw);col<=Math.min(columns-1,Math.floor(x1/cw));col++){
|
|
17
|
-
const at=row*columns+col;if(occupied[at]===3)continue;
|
|
18
|
-
const left=Math.max(0,x0/cw-col),right=Math.min(1,x1/cw-col),top=Math.max(0,y0/ch-row),bottom=Math.min(1,y1/ch-row);
|
|
19
|
-
const even=(row+col)%2===0;
|
|
20
|
-
if(even?left+top<=1:top<=right)occupied[at]|=1;
|
|
21
|
-
if(even?right+bottom>=1:bottom>=left)occupied[at]|=2;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
let triangleCount=0;for(const bits of occupied)triangleCount+=(bits&1?1:0)+(bits&2?1:0);
|
|
25
|
-
const coordinates=new Float64Array(triangleCount*6),uvs=new Float32Array(triangleCount*6),coverage=new Uint8Array(triangleCount).fill(6);
|
|
26
|
-
let at=0;
|
|
27
|
-
for(let row=0;row<rows;row++)for(let col=0;col<columns;col++){
|
|
28
|
-
const left=col*cw,right=(col+1)*cw,top=row*ch,bottom=(row+1)*ch,even=(row+col)%2===0;
|
|
29
|
-
for(let side=0;side<2;side++)if(occupied[row*columns+col]&(1<<side)){
|
|
30
|
-
const points=even?[right,top,side?right:left,bottom,left,side?bottom:top]:[left,top,side?left:right,bottom,right,side?bottom:top];
|
|
31
|
-
coordinates.set(points,at);for(let i=0;i<6;i++)uvs[at+i]=points[i]/(i%2?height:width);at+=6;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return {coordinates,uvs,coverage,triangleCount};
|
|
35
|
-
}
|
|
1
|
+
// Shared raster -> UV triangle grid for font masks and RGBA images. No color
|
|
2
|
+
// averaging, alpha quantization or triangle merging. All filtering is on the GPU.
|
|
3
|
+
export function rasterToTextureMesh(rgba,width,height,divisions,{filterRadius=.5,aspect=width/height}={}){
|
|
4
|
+
if(!Number.isInteger(width)||width<1||!Number.isInteger(height)||height<1||rgba.length!==width*height*4)throw RangeError('Invalid raster');
|
|
5
|
+
if(!Number.isInteger(divisions)||divisions<1||divisions>256)throw RangeError('Invalid divisions');
|
|
6
|
+
if(!Number.isFinite(filterRadius)||filterRadius<.5)throw RangeError('Invalid texture filter support');
|
|
7
|
+
if(!Number.isFinite(aspect)||aspect<=0)throw RangeError('Invalid grid aspect');
|
|
8
|
+
const rows=divisions,columns=Math.max(1,Math.round(aspect*rows));
|
|
9
|
+
const cw=width/columns,ch=height/rows,occupied=new Uint8Array(rows*columns);
|
|
10
|
+
// Conservative filter support: half a texel for bilinear filtering by default;
|
|
11
|
+
// image surfaces may request the wider footprint used by minified mipmaps.
|
|
12
|
+
// A thin line or isolated pixel cannot be lost to sparse point sampling.
|
|
13
|
+
for(let y=0;y<height;y++)for(let x=0;x<width;x++){
|
|
14
|
+
if(!rgba[(y*width+x)*4+3])continue;
|
|
15
|
+
const x0=Math.max(0,x-filterRadius),x1=Math.min(width,x+1+filterRadius),y0=Math.max(0,y-filterRadius),y1=Math.min(height,y+1+filterRadius);
|
|
16
|
+
for(let row=Math.floor(y0/ch);row<=Math.min(rows-1,Math.floor(y1/ch));row++)for(let col=Math.floor(x0/cw);col<=Math.min(columns-1,Math.floor(x1/cw));col++){
|
|
17
|
+
const at=row*columns+col;if(occupied[at]===3)continue;
|
|
18
|
+
const left=Math.max(0,x0/cw-col),right=Math.min(1,x1/cw-col),top=Math.max(0,y0/ch-row),bottom=Math.min(1,y1/ch-row);
|
|
19
|
+
const even=(row+col)%2===0;
|
|
20
|
+
if(even?left+top<=1:top<=right)occupied[at]|=1;
|
|
21
|
+
if(even?right+bottom>=1:bottom>=left)occupied[at]|=2;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
let triangleCount=0;for(const bits of occupied)triangleCount+=(bits&1?1:0)+(bits&2?1:0);
|
|
25
|
+
const coordinates=new Float64Array(triangleCount*6),uvs=new Float32Array(triangleCount*6),coverage=new Uint8Array(triangleCount).fill(6);
|
|
26
|
+
let at=0;
|
|
27
|
+
for(let row=0;row<rows;row++)for(let col=0;col<columns;col++){
|
|
28
|
+
const left=col*cw,right=(col+1)*cw,top=row*ch,bottom=(row+1)*ch,even=(row+col)%2===0;
|
|
29
|
+
for(let side=0;side<2;side++)if(occupied[row*columns+col]&(1<<side)){
|
|
30
|
+
const points=even?[right,top,side?right:left,bottom,left,side?bottom:top]:[left,top,side?left:right,bottom,right,side?bottom:top];
|
|
31
|
+
coordinates.set(points,at);for(let i=0;i<6;i++)uvs[at+i]=points[i]/(i%2?height:width);at+=6;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {coordinates,uvs,coverage,triangleCount};
|
|
35
|
+
}
|
package/src/render-owner.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {createViewportRenderOwner} from './viewport-render-owner.js';
|
|
2
|
-
|
|
3
|
-
// Shared GPU ownership, independent of component factories. Local 2D
|
|
4
|
-
// presentation canvases preserve DOM stacking and clipping of each surface.
|
|
5
|
-
export function createRenderOwner(THREE,document,{presentation='local',...options}={}){
|
|
6
|
-
if(presentation==='viewport')return createViewportRenderOwner(THREE,document,options);
|
|
7
|
-
if(presentation!=='local')throw TypeError('Invalid canvas presentation');
|
|
1
|
+
import {createViewportRenderOwner} from './viewport-render-owner.js';
|
|
2
|
+
|
|
3
|
+
// Shared GPU ownership, independent of component factories. Local 2D
|
|
4
|
+
// presentation canvases preserve DOM stacking and clipping of each surface.
|
|
5
|
+
export function createRenderOwner(THREE,document,{presentation='local',...options}={}){
|
|
6
|
+
if(presentation==='viewport')return createViewportRenderOwner(THREE,document,options);
|
|
7
|
+
if(presentation!=='local')throw TypeError('Invalid canvas presentation');
|
|
8
8
|
if(!THREE?.WebGLRenderer||!document?.defaultView)throw TypeError('THREE and a window document required');
|
|
9
9
|
let renderer=null,disposed=false,lost=false,w=0,h=0,dpr=0,copies=0,copyMs=0,renderMs=0,created=0;
|
|
10
10
|
const leases=new Set(),handles=new Set();
|
|
@@ -29,11 +29,16 @@ export function createRenderOwner(THREE,document,{presentation='local',...option
|
|
|
29
29
|
}
|
|
30
30
|
render(scene,camera){
|
|
31
31
|
ensure();if(this.dead)throw Error('Presentation disposed');
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Keep a high-water scratch buffer: alternating differently sized leases
|
|
33
|
+
// must not reallocate the GPU drawing buffer on every text frame.
|
|
34
|
+
const pw=this.domElement.width,ph=this.domElement.height;
|
|
35
|
+
if(pw>w||ph>h){w=Math.max(w,pw);h=Math.max(h,ph);renderer.setSize(w,h,false);}
|
|
36
|
+
renderer.setScissorTest(false);renderer.clear(true,true,true);
|
|
37
|
+
renderer.setViewport(0,0,pw,ph);
|
|
38
|
+
renderer.setScissor(0,0,pw,ph);renderer.setScissorTest(true);
|
|
34
39
|
const t=performance.now();renderer.render(scene,camera);renderMs+=performance.now()-t;
|
|
35
40
|
const start=performance.now();this.context.clearRect(0,0,this.domElement.width,this.domElement.height);
|
|
36
|
-
this.context.drawImage(renderer.domElement,0,0);copyMs+=performance.now()-start;copies++;
|
|
41
|
+
this.context.drawImage(renderer.domElement,0,h-ph,pw,ph,0,0,pw,ph);copyMs+=performance.now()-start;copies++;
|
|
37
42
|
}
|
|
38
43
|
dispose(){if(this.dead)return;this.dead=true;leases.delete(this);this.domElement.width=this.domElement.height=0;}
|
|
39
44
|
forceContextLoss(){} // A lease cannot destroy a peer's context.
|
|
@@ -44,8 +49,8 @@ export function createRenderOwner(THREE,document,{presentation='local',...option
|
|
|
44
49
|
const control=factory(host,Namespace,options),destroy=control.destroy;
|
|
45
50
|
handles.add(control);control.destroy=()=>{handles.delete(control);destroy();};return control;
|
|
46
51
|
}
|
|
47
|
-
return {create,
|
|
48
|
-
refresh(){if(disposed)throw Error('Shared owner disposed');for(const control of handles)control.refresh?.();},
|
|
52
|
+
return {create,
|
|
53
|
+
refresh(){if(disposed)throw Error('Shared owner disposed');for(const control of handles)control.refresh?.();},
|
|
49
54
|
stats:()=>({disposed,lost,contexts:renderer&&!disposed?1:0,created,controls:handles.size,leases:leases.size,copies,copyMs,renderMs,geometries:renderer?.info.memory.geometries||0}),
|
|
50
55
|
destroy(){
|
|
51
56
|
if(disposed)return;disposed=true;
|
|
@@ -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
|
}
|
package/src/text-edit-effect.js
CHANGED
|
@@ -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
|
+
}
|
package/src/text-edit-motions.js
CHANGED
|
@@ -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
|
|