@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.
- package/LICENSE +21 -0
- package/NOTICE.md +13 -0
- package/QUICKSTART.fa.md +94 -0
- package/README.md +116 -0
- package/assets/fonts/Estedad-OFL.txt +93 -0
- package/assets/vendor/bidi-LICENSE.txt +22 -0
- package/assets/vendor/bidi.min.js +1 -0
- package/assets/vendor/three-LICENSE.txt +21 -0
- package/assets/vendor/three.min.js +7 -0
- package/build-report.json +328 -0
- package/docs/GUIDE.md +125 -0
- package/docs/RELEASE-NOTES.md +40 -0
- package/examples/AnimatedTitle.jsx +21 -0
- package/examples/navigation-away.html +1 -0
- package/examples/navigation.html +38 -0
- package/examples/script.html +2 -0
- package/package.json +57 -0
- package/src/dom-attachment.d.ts +78 -0
- package/src/dom-attachment.js +187 -0
- package/src/dom-auto-reveal.d.ts +17 -0
- package/src/dom-auto-reveal.js +53 -0
- package/src/dom-auto-route.js +25 -0
- package/src/dom-free-bootstrap.js +42 -0
- package/src/dom-free-loader.d.ts +13 -0
- package/src/dom-free-loader.js +12 -0
- package/src/dom-free-script.js +12 -0
- package/src/dom-free.d.ts +16 -0
- package/src/dom-free.js +42 -0
- package/src/dom-image-raster.js +22 -0
- package/src/dom-image-surface.js +109 -0
- package/src/dom-image-swap.js +27 -0
- package/src/dom-once.js +34 -0
- package/src/dom-raster-cache.js +33 -0
- package/src/dom-reveal-boot.js +11 -0
- package/src/dom-reveal.js +75 -0
- package/src/dom-rich-text.js +119 -0
- package/src/dom-surface-font.js +50 -0
- package/src/dom-svg-surface.js +43 -0
- package/src/dom-text-fingerprint.js +27 -0
- package/src/dom-text-runs.js +45 -0
- package/src/dom-text-surface.js +254 -0
- package/src/font-mesh-engine.js +368 -0
- package/src/font-rasterizer.js +65 -0
- package/src/hybrid-text-flow.js +45 -0
- package/src/image-preparation-queue.js +16 -0
- package/src/image-source.js +61 -0
- package/src/image-surface.js +162 -0
- package/src/insertion-range.js +29 -0
- package/src/mesh-generator-core.js +149 -0
- package/src/motion-envelope.js +22 -0
- package/src/motion.js +52 -0
- package/src/native-run-shaping.js +60 -0
- package/src/particle-centers.js +50 -0
- package/src/raster-texture-material.js +79 -0
- package/src/raster-texture-mesh.js +35 -0
- package/src/render-owner.js +63 -0
- package/src/runtime-font-engine.js +242 -0
- package/src/runtime-lifecycle.js +26 -0
- package/src/text-direction.js +25 -0
- package/src/text-edit-effect.js +101 -0
- package/src/text-edit-motions.js +15 -0
- package/src/text-effect-options.js +29 -0
- package/src/text-effect-path.js +61 -0
- package/src/text-mesh-density.js +36 -0
- package/src/text-motion-character-centers.js +74 -0
- package/src/text-motion-contour.js +22 -0
- package/src/text-motion-primitives.js +91 -0
- package/src/text-motion-programs.js +1 -0
- package/src/text-motion-recipes.js +71 -0
- package/src/text-scene.js +272 -0
- package/src/triangle-coverage.js +25 -0
- package/src/triangle-effect.js +194 -0
- package/src/triangle-motion-frame.js +38 -0
- package/src/viewport-clip.js +119 -0
- package/src/viewport-render-owner.js +318 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { bidi } from './text-direction.js';
|
|
2
|
+
const segmenter = new Intl.Segmenter('fa', { granularity: 'grapheme' });
|
|
3
|
+
|
|
4
|
+
export function shapeNativeRuns(line, direction) {
|
|
5
|
+
const embedding = bidi.getEmbeddingLevels(line, direction);
|
|
6
|
+
const ranks = new Map(bidi.getReorderedIndices(line, embedding).map((index, rank) => [index, rank]));
|
|
7
|
+
const items = [];
|
|
8
|
+
let cp = 0;
|
|
9
|
+
for (const { segment, index } of segmenter.segment(line)) {
|
|
10
|
+
const level = embedding.levels[index] || 0;
|
|
11
|
+
let run = items.at(-1);
|
|
12
|
+
if (!run || run.level !== level) {
|
|
13
|
+
run = { ch:'', i:cp, utf16:index, level, rank:ranks.get(index) ?? 0, form:0, direction:level & 1 ? 'rtl':'ltr' };
|
|
14
|
+
items.push(run);
|
|
15
|
+
}
|
|
16
|
+
run.ch += segment; run.rank = Math.min(run.rank, ranks.get(index) ?? 0);
|
|
17
|
+
cp += Array.from(segment).length;
|
|
18
|
+
}
|
|
19
|
+
for (const run of items) { run.key = `native:${run.direction}:${run.ch}`; run.rasterText = run.ch; }
|
|
20
|
+
items.sort((a,b)=>a.rank-b.rank);
|
|
21
|
+
return { logical:Array.from(line), items };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function nativeRunMetrics(engine, line, shaping) {
|
|
25
|
+
const {logical}=shaping;
|
|
26
|
+
const items=shaping.items.map(run=>({...run,glyph:engine.records.get(`${run.key}:0`)}));
|
|
27
|
+
const width=items.reduce((sum,item)=>sum+item.glyph.advance,0);
|
|
28
|
+
const boundary=new Array(logical.length+1), cells=[];
|
|
29
|
+
let pen=-width/2;
|
|
30
|
+
for(const item of items){
|
|
31
|
+
item.x=pen;
|
|
32
|
+
const advance=item.glyph.advance;
|
|
33
|
+
engine.rasterizer.configure(item.direction);
|
|
34
|
+
const segments=Array.from(segmenter.segment(item.ch));
|
|
35
|
+
let cp=item.i, previous=item.level&1?pen+advance:pen;
|
|
36
|
+
boundary[cp]=previous;
|
|
37
|
+
for(let i=0;i<segments.length;i++){
|
|
38
|
+
const segment=segments[i];
|
|
39
|
+
const end=segment.index+segment.segment.length;
|
|
40
|
+
// Canvas exposes whole-run advances, not font ligature caret tables.
|
|
41
|
+
// Prefix measures are an experimental approximation inside joined clusters.
|
|
42
|
+
const progress=i===segments.length-1?advance:Math.max(0,Math.min(advance,
|
|
43
|
+
engine.rasterizer.context.measureText(item.ch.slice(0,end)).width));
|
|
44
|
+
const x=item.level&1?pen+advance-progress:pen+progress;
|
|
45
|
+
const length=Array.from(segment.segment).length;
|
|
46
|
+
for(let j=1;j<length;j++)boundary[cp+j]=previous;
|
|
47
|
+
boundary[cp+length]=x;
|
|
48
|
+
cells.push({start:item.utf16+segment.index,end:item.utf16+end,x:Math.min(previous,x),width:Math.abs(x-previous)});
|
|
49
|
+
previous=x;cp+=length;
|
|
50
|
+
}
|
|
51
|
+
pen+=advance;
|
|
52
|
+
}
|
|
53
|
+
boundary[0] ??= 0;
|
|
54
|
+
let offset=0;
|
|
55
|
+
const utf16Offsets=logical.map(ch=>{const at=offset;offset+=ch.length;return at;});utf16Offsets.push(offset);
|
|
56
|
+
const lookup=new Map(utf16Offsets.map((offset,index)=>[offset,index]));
|
|
57
|
+
const stops=Array.from(segmenter.segment(line),s=>s.index);stops.push(line.length);
|
|
58
|
+
const carets=stops.map(index=>({index,x:boundary[lookup.get(index)]})).sort((a,b)=>a.x-b.x||a.index-b.index);
|
|
59
|
+
return {text:line,items,boundary,utf16Offsets,carets,cells,height:engine.fontMetrics.height};
|
|
60
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Pair only complementary axis-aligned grid triangles. Geometry/UVs stay intact.
|
|
2
|
+
export function particleCenters(THREE,geometry,shape){
|
|
3
|
+
const position=geometry.getAttribute('position');
|
|
4
|
+
let cache=geometry.userData.particleCenters;
|
|
5
|
+
if(shape!=='square'&&(!cache||cache.position!==position||cache.version!==position.version)){
|
|
6
|
+
const triangle=new Float32Array(position.count*2);
|
|
7
|
+
for(let i=0;i<position.count;i+=3){
|
|
8
|
+
const x=(position.getX(i)+position.getX(i+1)+position.getX(i+2))/3;
|
|
9
|
+
const y=(position.getY(i)+position.getY(i+1)+position.getY(i+2))/3;
|
|
10
|
+
for(let j=0;j<3;j++){triangle[(i+j)*2]=x;triangle[(i+j)*2+1]=y;}
|
|
11
|
+
}
|
|
12
|
+
cache={position,version:position.version,triangle,square:null,pairs:[]};
|
|
13
|
+
geometry.userData.particleCenters=cache;
|
|
14
|
+
}
|
|
15
|
+
if(!cache||cache.position!==position||cache.version!==position.version||shape==='square'&&!cache.square){
|
|
16
|
+
const cachePairs=[];const triangle=new Float32Array(position.count*2),square=new Float32Array(position.count*2),cells=new Map();
|
|
17
|
+
for(let i=0;i<position.count;i+=3){
|
|
18
|
+
const xs=[0,1,2].map(j=>position.getX(i+j)),ys=[0,1,2].map(j=>position.getY(i+j));
|
|
19
|
+
const x=xs.reduce((a,b)=>a+b)/3,y=ys.reduce((a,b)=>a+b)/3;
|
|
20
|
+
for(let j=0;j<3;j++)triangle.set([x,y],(i+j)*2);
|
|
21
|
+
const left=Math.min(...xs),right=Math.max(...xs),bottom=Math.min(...ys),top=Math.max(...ys);
|
|
22
|
+
if(right===left||top===bottom||xs.some(v=>v!==left&&v!==right)||ys.some(v=>v!==bottom&&v!==top))continue;
|
|
23
|
+
const corners=new Set(xs.map((v,j)=>(v===right?1:0)+(ys[j]===top?2:0)));
|
|
24
|
+
if(corners.size!==3)continue;
|
|
25
|
+
const key=[left,right,bottom,top,position.getZ(i)].join(':');
|
|
26
|
+
const old=cells.get(key);
|
|
27
|
+
if(old&&new Set([...old.corners,...corners]).size===4){
|
|
28
|
+
cells.delete(key);
|
|
29
|
+
for(const at of [old.index,i])for(let j=0;j<3;j++)square.set([(left+right)/2,(bottom+top)/2],(at+j)*2);
|
|
30
|
+
cachePairs.push([old.index,i]);
|
|
31
|
+
}else cells.set(key,{index:i,corners});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
cache={position,version:position.version,triangle,square,pairs:cachePairs};
|
|
35
|
+
const paired=new Set(cachePairs.flat());for(let i=0;i<position.count;i+=3)if(!paired.has(i))square.set(triangle.subarray(i*2,(i+3)*2),i*2);
|
|
36
|
+
geometry.userData.particleCenters=cache;
|
|
37
|
+
}
|
|
38
|
+
if(cache.shape!==shape||!geometry.getAttribute('dustCenter')){
|
|
39
|
+
geometry.setAttribute('dustCenter',new THREE.BufferAttribute(shape==='square'?cache.square:cache.triangle,2));cache.shape=shape;
|
|
40
|
+
}
|
|
41
|
+
if(geometry.getAttribute('dustCornerA')&&cache.cornerShape!==shape){
|
|
42
|
+
for(const name of ['dustCornerA','dustCornerB','dustCornerC']){
|
|
43
|
+
const a=geometry.getAttribute(name);cache[name]??=a.array.slice();a.array.set(cache[name]);
|
|
44
|
+
if(shape==='square')for(const [first,second] of cache.pairs)for(let j=0;j<3;j++)a.array.set(cache[name].subarray(first*3,first*3+3),(second+j)*3);
|
|
45
|
+
a.needsUpdate=true;
|
|
46
|
+
}
|
|
47
|
+
cache.cornerShape=shape;
|
|
48
|
+
}
|
|
49
|
+
return cache;
|
|
50
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Text and image surfaces share one texture per raster (not per triangle).
|
|
2
|
+
// References include deletion ghosts, which can outlive their source scene mesh.
|
|
3
|
+
const surfaces=new WeakMap(),materials=new WeakMap();
|
|
4
|
+
export function attachRasterTexture(THREE,material,raster,{lodBias=raster.mask===false?0:-.75}={}){
|
|
5
|
+
if(!Number.isFinite(lodBias))throw TypeError('A finite raster LOD bias is required');
|
|
6
|
+
let surface=surfaces.get(raster);
|
|
7
|
+
if(!surface){
|
|
8
|
+
const texture=new THREE.DataTexture(raster.rgba,raster.width,raster.height,THREE.RGBAFormat,THREE.UnsignedByteType);
|
|
9
|
+
if(raster.mask===false)texture.colorSpace=THREE.SRGBColorSpace;
|
|
10
|
+
// One bilinear mip fetch while moving; the shader adds the adjacent mip only
|
|
11
|
+
// near the resting pose. Sampling policy never mutates a shared texture.
|
|
12
|
+
texture.minFilter=THREE.LinearMipmapNearestFilter;texture.magFilter=THREE.LinearFilter;texture.generateMipmaps=true;texture.needsUpdate=true;
|
|
13
|
+
surface={texture,refs:0,raster};surfaces.set(raster,surface);
|
|
14
|
+
}
|
|
15
|
+
material.uniforms.rasterMap={value:surface.texture};
|
|
16
|
+
material.uniforms.rasterSize={value:new THREE.Vector2(raster.width,raster.height)};
|
|
17
|
+
material.uniforms.rasterLodBias={value:lodBias};
|
|
18
|
+
return lease(material,surface);
|
|
19
|
+
}
|
|
20
|
+
// Derivatives describe this triangle's current projected texture footprint.
|
|
21
|
+
// Explicit LOD is core in WebGL2 (Three aliases texture2DLodEXT) and optional in
|
|
22
|
+
// WebGL1. Without it, keep a single conventional texture lookup as a safe fallback.
|
|
23
|
+
const rasterSamplingShader=`
|
|
24
|
+
uniform vec2 rasterSize;
|
|
25
|
+
uniform float rasterLodBias;
|
|
26
|
+
varying float vRasterQuality;
|
|
27
|
+
vec4 thdRasterSample(vec2 uv){
|
|
28
|
+
#if __VERSION__ >= 300 || defined(GL_EXT_shader_texture_lod)
|
|
29
|
+
vec2 dx=dFdx(uv*rasterSize),dy=dFdy(uv*rasterSize);
|
|
30
|
+
float footprint=max(max(dot(dx,dx),dot(dy,dy)),0.00000001);
|
|
31
|
+
float lastLevel=floor(log2(max(max(rasterSize.x,rasterSize.y),1.0)));
|
|
32
|
+
float lod=clamp(0.5*log2(footprint)+rasterLodBias,0.0,lastLevel);
|
|
33
|
+
float primary=floor(lod+0.5);
|
|
34
|
+
vec4 sampleColor=texture2DLodEXT(rasterMap,uv,primary);
|
|
35
|
+
float weight=abs(lod-primary)*clamp(vRasterQuality,0.0,1.0);
|
|
36
|
+
if(weight>0.0){
|
|
37
|
+
float adjacent=primary<lod?primary+1.0:primary-1.0;
|
|
38
|
+
sampleColor=mix(sampleColor,texture2DLodEXT(rasterMap,uv,adjacent),weight);
|
|
39
|
+
}
|
|
40
|
+
return sampleColor;
|
|
41
|
+
#else
|
|
42
|
+
return texture2D(rasterMap,uv);
|
|
43
|
+
#endif
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/** Add the shared sampler to text/image shaders that already declare rasterMap
|
|
48
|
+
* and vRasterUV. The default is settled; TriangleEffect supplies each facet's
|
|
49
|
+
* existing phase without another clock, attribute buffer or per-frame JS work. */
|
|
50
|
+
export function applyRasterSamplingShader(material){
|
|
51
|
+
material.defines={...material.defines,THD_RASTER_TEXTURE:1};
|
|
52
|
+
material.extensions={...material.extensions,derivatives:true,shaderTextureLOD:true};
|
|
53
|
+
material.vertexShader='varying float vRasterQuality;\n'+material.vertexShader;
|
|
54
|
+
material.vertexShader=material.vertexShader.replace(/void main\(\)\s*\{/,'void main(){ vRasterQuality=1.0;');
|
|
55
|
+
const main=material.fragmentShader.indexOf('void main');
|
|
56
|
+
material.fragmentShader=material.fragmentShader.slice(0,main)+rasterSamplingShader+material.fragmentShader.slice(main);
|
|
57
|
+
material.fragmentShader=material.fragmentShader.replace('texture2D(rasterMap,vRasterUV)','thdRasterSample(vRasterUV)');
|
|
58
|
+
}
|
|
59
|
+
function lease(material,surface){
|
|
60
|
+
surface.refs++;materials.set(material,surface);let released=false;
|
|
61
|
+
return ()=>{if(released)return;released=true;materials.delete(material);if(--surface.refs===0){surface.texture.dispose();surfaces.delete(surface.raster);}};
|
|
62
|
+
}
|
|
63
|
+
export function retainRasterTexture(source,target){
|
|
64
|
+
const surface=materials.get(source);if(!surface)return null;
|
|
65
|
+
// ShaderMaterial.clone clones texture uniforms. Use the shared source instead
|
|
66
|
+
// of uploading an identical new image for each partial deletion.
|
|
67
|
+
if(target.uniforms.rasterMap.value!==surface.texture)target.uniforms.rasterMap.value.dispose();
|
|
68
|
+
target.uniforms.rasterMap.value=surface.texture;
|
|
69
|
+
return lease(target,surface);
|
|
70
|
+
}
|
|
71
|
+
export function applyRasterTextureShader(material,{mask=true}={}){
|
|
72
|
+
material.vertexShader='attribute vec2 rasterUV; varying vec2 vRasterUV;\n'+material.vertexShader;
|
|
73
|
+
material.vertexShader=material.vertexShader.replace('void main() {','void main() { vRasterUV=rasterUV;');
|
|
74
|
+
material.fragmentShader='uniform sampler2D rasterMap; varying vec2 vRasterUV;\n'+material.fragmentShader;
|
|
75
|
+
material.fragmentShader=material.fragmentShader.replace('float coverage =', 'vec4 rasterSample=texture2D(rasterMap,vRasterUV); float coverage =');
|
|
76
|
+
material.fragmentShader=material.fragmentShader.replace('max(max(vColor.r, vColor.g), vColor.b) * brightness','rasterSample.a * max(max(vColor.r, vColor.g), vColor.b) * min(brightness, 1.0)');
|
|
77
|
+
if(!mask)material.fragmentShader=material.fragmentShader.replace('vec4(tint * min(1.0, diffuse + sheen), coverage)', 'vec4(rasterSample.rgb * tint * min(1.0, diffuse + sheen), coverage)');
|
|
78
|
+
applyRasterSamplingShader(material);
|
|
79
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
if(!THREE?.WebGLRenderer||!document?.defaultView)throw TypeError('THREE and a window document required');
|
|
9
|
+
let renderer=null,disposed=false,lost=false,w=0,h=0,dpr=0,copies=0,copyMs=0,renderMs=0,created=0;
|
|
10
|
+
const leases=new Set(),handles=new Set();
|
|
11
|
+
function broadcast(){lost=true;for(const lease of leases)lease.domElement.dispatchEvent(new document.defaultView.Event('webglcontextlost',{cancelable:true}));}
|
|
12
|
+
function ensure(){
|
|
13
|
+
if(disposed||lost)throw Error('Shared renderer unavailable');
|
|
14
|
+
if(!renderer){renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});created++;renderer.domElement.addEventListener('webglcontextlost',onLoss);}
|
|
15
|
+
}
|
|
16
|
+
function onLoss(event){event.preventDefault();broadcast();}
|
|
17
|
+
const Namespace={...THREE,WebGLRenderer:class{
|
|
18
|
+
constructor(){
|
|
19
|
+
ensure();this.domElement=document.createElement('canvas');this.context=this.domElement.getContext('2d');
|
|
20
|
+
if(!this.context)throw Error('2D presentation unavailable');
|
|
21
|
+
this.width=300;this.height=150;this.ratio=1;this.dead=false;leases.add(this);
|
|
22
|
+
}
|
|
23
|
+
setPixelRatio(value){this.ratio=value;this.setSize(this.width,this.height,false);}
|
|
24
|
+
setSize(width,height){
|
|
25
|
+
this.width=width;this.height=height;
|
|
26
|
+
const pw=Math.floor(width*this.ratio),ph=Math.floor(height*this.ratio);
|
|
27
|
+
if(this.domElement.width!==pw)this.domElement.width=pw;
|
|
28
|
+
if(this.domElement.height!==ph)this.domElement.height=ph;
|
|
29
|
+
}
|
|
30
|
+
render(scene,camera){
|
|
31
|
+
ensure();if(this.dead)throw Error('Presentation disposed');
|
|
32
|
+
if(dpr!==this.ratio){renderer.setPixelRatio(this.ratio);dpr=this.ratio;}
|
|
33
|
+
if(w!==this.width||h!==this.height){renderer.setSize(this.width,this.height,false);w=this.width;h=this.height;}
|
|
34
|
+
const t=performance.now();renderer.render(scene,camera);renderMs+=performance.now()-t;
|
|
35
|
+
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++;
|
|
37
|
+
}
|
|
38
|
+
dispose(){if(this.dead)return;this.dead=true;leases.delete(this);this.domElement.width=this.domElement.height=0;}
|
|
39
|
+
forceContextLoss(){} // A lease cannot destroy a peer's context.
|
|
40
|
+
}};
|
|
41
|
+
function create(factory,host,options){
|
|
42
|
+
if(disposed)throw Error('Shared owner disposed');
|
|
43
|
+
if(host?.ownerDocument!==document)throw TypeError('Host must belong to the owner document');
|
|
44
|
+
const control=factory(host,Namespace,options),destroy=control.destroy;
|
|
45
|
+
handles.add(control);control.destroy=()=>{handles.delete(control);destroy();};return control;
|
|
46
|
+
}
|
|
47
|
+
return {create,
|
|
48
|
+
refresh(){if(disposed)throw Error('Shared owner disposed');for(const control of handles)control.refresh?.();},
|
|
49
|
+
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
|
+
destroy(){
|
|
51
|
+
if(disposed)return;disposed=true;
|
|
52
|
+
const errors=[];
|
|
53
|
+
for(const control of [...handles])try{control.destroy();}catch(error){errors.push(error);}
|
|
54
|
+
if(renderer){
|
|
55
|
+
renderer.domElement.removeEventListener('webglcontextlost',onLoss);
|
|
56
|
+
try{renderer.dispose();}catch(error){errors.push(error);}
|
|
57
|
+
try{renderer.forceContextLoss();}catch(error){errors.push(error);}
|
|
58
|
+
}
|
|
59
|
+
if(errors.length)throw new AggregateError(errors,'Hybrid owner cleanup failed');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { documentDirection } from './text-direction.js';
|
|
2
|
+
import { FontMeshEngine, shapeLine } from './font-mesh-engine.js';
|
|
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';
|
|
8
|
+
|
|
9
|
+
let familySequence = 0;
|
|
10
|
+
|
|
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;
|
|
19
|
+
this.weight = weight;
|
|
20
|
+
if (!Number.isInteger(cacheLimit) || cacheLimit < 0 || !Number.isFinite(cacheBudgetBytes) || cacheBudgetBytes < 0) throw new RangeError('Invalid cache limits');
|
|
21
|
+
this.cacheLimit = cacheLimit;
|
|
22
|
+
this.cacheBudgetBytes = Math.floor(cacheBudgetBytes);
|
|
23
|
+
this.cacheBytes = 0; this.activeBytes = 0; this.evicted = 0;
|
|
24
|
+
this.generated = 0;
|
|
25
|
+
this.generationMs = 0;
|
|
26
|
+
this.lastUsed = new Map();
|
|
27
|
+
this.clock = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async loadFont(source) {
|
|
31
|
+
const started = performance.now();
|
|
32
|
+
let buffer;
|
|
33
|
+
if (source instanceof Blob) buffer = await source.arrayBuffer();
|
|
34
|
+
else {
|
|
35
|
+
const response = await fetch(source);
|
|
36
|
+
if (!response.ok) throw new Error(`بارگذاری فونت ناموفق بود (${response.status}).`);
|
|
37
|
+
buffer = await response.arrayBuffer();
|
|
38
|
+
}
|
|
39
|
+
const family = `THDRuntime${++familySequence}`;
|
|
40
|
+
const nextFace = await new FontFace(family, buffer, { weight: '100 900' }).load();
|
|
41
|
+
if (this.face) document.fonts.delete(this.face);
|
|
42
|
+
this.face = nextFace; document.fonts.add(nextFace);
|
|
43
|
+
this.rasterizer = new FontRasterizer(family, this.weight);
|
|
44
|
+
this.clearMeshes();
|
|
45
|
+
const ascent = this.rasterizer.ascent + 2, descent = this.rasterizer.descent + 2;
|
|
46
|
+
this.fontMetrics = { ascent, descent, height: ascent + descent };
|
|
47
|
+
this.lineAdvance = this.fontMetrics.height * Math.max(1, this.lineHeight);
|
|
48
|
+
this.fontLoadMs = performance.now() - started;
|
|
49
|
+
return { bytes: buffer.byteLength, fontLoadMs: this.fontLoadMs };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clearMeshes() {
|
|
53
|
+
this.meshRevision = (this.meshRevision || 0) + 1;
|
|
54
|
+
this.records.clear(); this.lastUsed.clear(); this.lineCache.clear(); this.cachedLayout = null;
|
|
55
|
+
this.generated = 0; this.generationMs = 0;
|
|
56
|
+
this.cacheBytes = 0; this.activeBytes = 0; this.evicted = 0;
|
|
57
|
+
this.rasterizer?.cache.clear();
|
|
58
|
+
}
|
|
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
|
+
}
|
|
80
|
+
|
|
81
|
+
setInputLayout(direction, alignment, width = 0) {
|
|
82
|
+
if (!['auto','ltr','rtl'].includes(direction) || !['left','right','auto'].includes(alignment) || !Number.isFinite(width) || width < 0) throw new TypeError('Invalid input layout');
|
|
83
|
+
if (this.inputDirection === direction && this.inputAlignment === alignment && this.inputWidth === width) return false;
|
|
84
|
+
this.inputDirection = direction; this.inputAlignment = alignment; this.inputWidth = width;
|
|
85
|
+
this.lineCache.clear(); this.cachedLayout = null;
|
|
86
|
+
this.meshRevision = (this.meshRevision || 0) + 1;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
shape(line) {
|
|
91
|
+
const direction = this.inputDirection === 'auto' ? undefined : this.inputDirection;
|
|
92
|
+
return this.nativeShaping ? shapeNativeRuns(line, direction) : shapeLine(line, direction);
|
|
93
|
+
}
|
|
94
|
+
metricsForLine(line, shaping) {
|
|
95
|
+
const metrics = this.nativeShaping ? nativeRunMetrics(this,line,shaping) : super.layoutLine(line, shaping);
|
|
96
|
+
if (this.inputAlignment) {
|
|
97
|
+
let left = Infinity, right = -Infinity;
|
|
98
|
+
for (const item of metrics.items) {
|
|
99
|
+
left = Math.min(left, item.x); right = Math.max(right, item.x + (item.glyph?.advance || 0));
|
|
100
|
+
}
|
|
101
|
+
if (!metrics.items.length) left = right = 0;
|
|
102
|
+
const rightAligned = this.inputAlignment === 'auto' ? documentDirection(line, 'ltr') === 'rtl' : this.inputAlignment === 'right';
|
|
103
|
+
const shift = rightAligned ? this.inputWidth / 2 - right : -this.inputWidth / 2 - left;
|
|
104
|
+
for (const item of metrics.items) item.x += shift;
|
|
105
|
+
metrics.boundary = metrics.boundary.map(x => x + shift);
|
|
106
|
+
for (const edges of metrics.caretEdges?.values() || []) for (const edge of edges) edge.x += shift;
|
|
107
|
+
for (const caret of metrics.carets) caret.x += shift;
|
|
108
|
+
for (const cell of metrics.cells || []) cell.x += shift;
|
|
109
|
+
}
|
|
110
|
+
return metrics;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
selectionRects(text,start,end) {
|
|
114
|
+
if (!this.nativeShaping) return super.selectionRects(text,start,end);
|
|
115
|
+
if(start>=end)return [];
|
|
116
|
+
const result=[];
|
|
117
|
+
for(const entry of this.layout(text).entries){
|
|
118
|
+
const box=this.fontLineBox(entry.baseline);
|
|
119
|
+
const cells=entry.metrics.cells.filter(c=>c.start+entry.offset<end && c.end+entry.offset>start && c.width>0).sort((a,b)=>a.x-b.x);
|
|
120
|
+
let run;
|
|
121
|
+
for(const cell of cells){
|
|
122
|
+
const x=cell.x*this.scale,right=(cell.x+cell.width)*this.scale;
|
|
123
|
+
if(run && x<=run.x+run.width+1e-6)run.width=Math.max(right,run.x+run.width)-run.x;
|
|
124
|
+
else {run={x,y:box.bottom,width:right-x,height:box.height};result.push(run);}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
layoutLine(line) {
|
|
131
|
+
if (!this.rasterizer) throw new Error('ابتدا فایل فونت را بارگذاری کنید.');
|
|
132
|
+
const shaping = this.shape(line);
|
|
133
|
+
for (const item of shaping.items) {
|
|
134
|
+
if (item.control) continue;
|
|
135
|
+
this.ensureGlyph(item);
|
|
136
|
+
}
|
|
137
|
+
return this.metricsForLine(line, shaping);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ensureGlyph(item) {
|
|
141
|
+
const key = `${item.key}:${item.form}`;
|
|
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);
|
|
149
|
+
// Raster canvases can grow for unusual marks; align their real baseline
|
|
150
|
+
// with the fixed font-wide origin used by selection and caret.
|
|
151
|
+
const shift = this.fontMetrics.ascent - raster.baseline;
|
|
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.
|
|
159
|
+
this.generated++;
|
|
160
|
+
this.generationMs += performance.now() - started;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async prepareText(text, { cancelled = () => false, yieldTask = () => new Promise(resolve => setTimeout(resolve, 0)), budgetMs = 4, buildGeometry = true } = {}) {
|
|
164
|
+
const revision = this.meshRevision;
|
|
165
|
+
const stale = () => cancelled() || revision !== this.meshRevision;
|
|
166
|
+
let started = performance.now(), sliceStart = started, slices = 0, maxSliceMs = 0;
|
|
167
|
+
const nextCache = new Map();
|
|
168
|
+
try {
|
|
169
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
170
|
+
if (stale()) return null;
|
|
171
|
+
let metrics = nextCache.get(line) || this.lineCache.get(line);
|
|
172
|
+
if (!metrics) {
|
|
173
|
+
const shaping = this.shape(line);
|
|
174
|
+
for (const item of shaping.items) {
|
|
175
|
+
if (stale()) return null;
|
|
176
|
+
if (!item.control) this.ensureGlyph(item);
|
|
177
|
+
if (performance.now() - sliceStart >= budgetMs) {
|
|
178
|
+
maxSliceMs = Math.max(maxSliceMs, performance.now() - sliceStart);
|
|
179
|
+
slices++; await yieldTask(); sliceStart = performance.now();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (stale()) return null;
|
|
183
|
+
metrics = this.metricsForLine(line, shaping);
|
|
184
|
+
}
|
|
185
|
+
// Prepare each line's GPU arrays before the atomic scene update.
|
|
186
|
+
if (buildGeometry) this.buildLine(metrics);
|
|
187
|
+
nextCache.set(line, metrics);
|
|
188
|
+
if (performance.now() - sliceStart >= budgetMs) {
|
|
189
|
+
maxSliceMs = Math.max(maxSliceMs, performance.now() - sliceStart);
|
|
190
|
+
slices++; await yieldTask(); sliceStart = performance.now();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (stale()) return null;
|
|
194
|
+
maxSliceMs = Math.max(maxSliceMs, performance.now() - sliceStart);
|
|
195
|
+
this.lineCache = nextCache; this.cachedLayout = null;
|
|
196
|
+
return { elapsedMs: performance.now() - started, slices, maxSliceMs };
|
|
197
|
+
} finally {
|
|
198
|
+
// Cancelled jobs may have populated the glyph cache; trim them too.
|
|
199
|
+
if (revision === this.meshRevision) this.trimCache();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
layout(text) {
|
|
204
|
+
if (this.cachedLayout?.text === text) return this.cachedLayout;
|
|
205
|
+
const result = super.layout(text);
|
|
206
|
+
this.trimCache();
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
trimCache() {
|
|
211
|
+
// Reuse shaped metrics, including unchanged lines; no bidi/layout pass here.
|
|
212
|
+
const active = new Set();
|
|
213
|
+
for (const metrics of this.lineCache.values()) for (const { glyph } of metrics.items) {
|
|
214
|
+
if (glyph?.cacheKey && this.records.get(glyph.cacheKey) === glyph) active.add(glyph.cacheKey);
|
|
215
|
+
}
|
|
216
|
+
this.activeBytes = 0;
|
|
217
|
+
for (const key of active) {
|
|
218
|
+
this.activeBytes += this.records.get(key).byteLength;
|
|
219
|
+
this.lastUsed.delete(key); this.lastUsed.set(key, ++this.clock);
|
|
220
|
+
}
|
|
221
|
+
// Map insertion order is LRU; count remains a guard for zero-byte glyphs.
|
|
222
|
+
for (const key of this.lastUsed.keys()) {
|
|
223
|
+
if (this.cacheBytes <= this.cacheBudgetBytes && this.records.size <= this.cacheLimit) break;
|
|
224
|
+
if (active.has(key)) continue;
|
|
225
|
+
const record = this.records.get(key);
|
|
226
|
+
if (record) { this.cacheBytes -= record.byteLength; this.records.delete(key); this.evicted++; }
|
|
227
|
+
this.lastUsed.delete(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
cacheStats() {
|
|
232
|
+
return { bytes: this.cacheBytes, activeBytes: this.activeBytes,
|
|
233
|
+
inactiveBytes: this.cacheBytes - this.activeBytes, budgetBytes: this.cacheBudgetBytes,
|
|
234
|
+
overBudgetBytes: Math.max(0, this.cacheBytes - this.cacheBudgetBytes), evicted: this.evicted };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
dispose() {
|
|
238
|
+
this.clearMeshes();
|
|
239
|
+
if (this.face) document.fonts.delete(this.face);
|
|
240
|
+
this.face = null; this.rasterizer = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Resource ownership only. No text, font, form or renderer dependencies.
|
|
2
|
+
export function createLifecycle() {
|
|
3
|
+
let disposed = false;
|
|
4
|
+
const cleanups = [];
|
|
5
|
+
function own(cleanup) {
|
|
6
|
+
if (disposed) cleanup(); else cleanups.push(cleanup);
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
get disposed() { return disposed; },
|
|
10
|
+
own,
|
|
11
|
+
listen(target, type, callback, options) {
|
|
12
|
+
if (!target || disposed) return;
|
|
13
|
+
target.addEventListener(type, callback, options);
|
|
14
|
+
own(() => target.removeEventListener(type, callback, options));
|
|
15
|
+
},
|
|
16
|
+
destroy() {
|
|
17
|
+
if (disposed) return;
|
|
18
|
+
disposed = true;
|
|
19
|
+
const errors = [];
|
|
20
|
+
for (const cleanup of cleanups.splice(0).reverse()) {
|
|
21
|
+
try { cleanup(); } catch (error) { errors.push(error); }
|
|
22
|
+
}
|
|
23
|
+
if (errors.length) throw new AggregateError(errors, 'Runtime cleanup failed');
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import bidiFactory from '../assets/vendor/bidi.min.js';
|
|
2
|
+
|
|
3
|
+
// bidi-js 1.0.3, Unicode 13 UAX #9; vendored locally with its MIT license.
|
|
4
|
+
export const bidi = bidiFactory();
|
|
5
|
+
export const isBidiControl = ch => /^[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]$/u.test(ch);
|
|
6
|
+
|
|
7
|
+
// Horizontal keyboard navigation deliberately follows the whole document.
|
|
8
|
+
export function documentDirection(text, fallback = 'rtl') {
|
|
9
|
+
for (const ch of text) {
|
|
10
|
+
const type = bidi.getBidiCharTypeName(ch);
|
|
11
|
+
if (type === 'L') return 'ltr';
|
|
12
|
+
if (type === 'R' || type === 'AL') return 'rtl';
|
|
13
|
+
}
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// UTF-16 logical runs for visual effects; neutral characters inherit resolved bidi levels.
|
|
18
|
+
export function directionalRanges(text,start,end,base='auto'){
|
|
19
|
+
const {levels}=bidi.getEmbeddingLevels(text,base==='auto'?undefined:base),ranges=[];
|
|
20
|
+
for(let i=start;i<end;i++){
|
|
21
|
+
const direction=levels[i]%2?'rtl':'ltr',last=ranges.at(-1);
|
|
22
|
+
if(last?.direction===direction)last.end=i+1;else ranges.push({start:i,end:i+1,direction});
|
|
23
|
+
}
|
|
24
|
+
return ranges;
|
|
25
|
+
}
|