@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,65 @@
|
|
|
1
|
+
// Reuse a single canvas. A common vertical origin keeps all joining forms aligned.
|
|
2
|
+
export class FontRasterizer {
|
|
3
|
+
constructor(family, weight = 200) {
|
|
4
|
+
this.canvas = document.createElement('canvas');
|
|
5
|
+
this.context = this.canvas.getContext('2d', { willReadFrequently: true });
|
|
6
|
+
this.font = `${weight} 200px "${family}"`;
|
|
7
|
+
this.configure();
|
|
8
|
+
const metric = this.context.measureText('آبپچگژهمیABCgj');
|
|
9
|
+
this.ascent = Math.ceil(Math.max(metric.fontBoundingBoxAscent || 0, metric.actualBoundingBoxAscent || 0));
|
|
10
|
+
this.descent = Math.ceil(Math.max(metric.fontBoundingBoxDescent || 0, metric.actualBoundingBoxDescent || 0));
|
|
11
|
+
this.cache = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
configure(direction = 'ltr') {
|
|
15
|
+
this.context.font = this.font;
|
|
16
|
+
// Build the cached source for legibility, independently of the GPU's cheaper
|
|
17
|
+
// moving-particle sampler. Reapply after canvas resize resets drawing state.
|
|
18
|
+
if ('textRendering' in this.context) this.context.textRendering = 'optimizeLegibility';
|
|
19
|
+
if ('fontKerning' in this.context) this.context.fontKerning = 'normal';
|
|
20
|
+
if ('letterSpacing' in this.context) this.context.letterSpacing = `${this.letterSpacing || 0}px`;
|
|
21
|
+
if ('wordSpacing' in this.context) this.context.wordSpacing = `${this.wordSpacing || 0}px`;
|
|
22
|
+
this.context.textAlign = 'left'; this.context.textBaseline = 'alphabetic';
|
|
23
|
+
// Layout already resolves bidi and mirrors punctuation. Do not mirror it again
|
|
24
|
+
// when Canvas draws an isolated, direction-neutral glyph such as a bracket.
|
|
25
|
+
this.context.direction = direction; this.context.fillStyle = '#fff';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
raster(text, direction = 'ltr') {
|
|
29
|
+
const key = direction + ':' + text;
|
|
30
|
+
if (this.cache.has(key)) return this.cache.get(key);
|
|
31
|
+
this.configure(direction);
|
|
32
|
+
const metric = this.context.measureText(text);
|
|
33
|
+
const padding = 2;
|
|
34
|
+
const drawOffsetX = Math.ceil(Math.max(0, metric.actualBoundingBoxLeft || 0)) + padding;
|
|
35
|
+
const baseline = Math.ceil(Math.max(this.ascent, metric.actualBoundingBoxAscent || 0)) + padding;
|
|
36
|
+
const width = Math.max(2, Math.ceil(Math.max(metric.width, metric.actualBoundingBoxRight || 0) + drawOffsetX + padding));
|
|
37
|
+
const height = Math.max(2, Math.ceil(baseline + Math.max(this.descent, metric.actualBoundingBoxDescent || 0) + padding));
|
|
38
|
+
this.canvas.width = width; this.canvas.height = height; this.configure(direction);
|
|
39
|
+
this.context.fillText(text, drawOffsetX, baseline);
|
|
40
|
+
const rgba = this.context.getImageData(0, 0, width, height).data;
|
|
41
|
+
const result = { width, height, baseline, drawOffsetX, advance: metric.width, rgba };
|
|
42
|
+
this.cache.set(key, result);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function collectDroppedFiles(dataTransfer) {
|
|
48
|
+
const entries = Array.from(dataTransfer.items || [], item => item.webkitGetAsEntry?.()).filter(Boolean);
|
|
49
|
+
const fallback = Array.from(dataTransfer.files || []);
|
|
50
|
+
async function walk(entry) {
|
|
51
|
+
if (entry.isFile) return [await new Promise((resolve, reject) => entry.file(resolve, reject))];
|
|
52
|
+
if (!entry.isDirectory) return [];
|
|
53
|
+
const reader = entry.createReader(), result = [];
|
|
54
|
+
for (;;) {
|
|
55
|
+
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
|
56
|
+
if (!batch.length) break;
|
|
57
|
+
for (const child of batch) result.push(...await walk(child));
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
if (!entries.length) return fallback;
|
|
62
|
+
const result = [];
|
|
63
|
+
for (const entry of entries) result.push(...await walk(entry));
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {RuntimeFontEngine} from './runtime-font-engine.js';
|
|
2
|
+
import {documentDirection} from './text-direction.js';
|
|
3
|
+
|
|
4
|
+
// Read the real semantic text node. No mirror editor, layout guesses, or HTML parsing.
|
|
5
|
+
export function measureHybridTextFlow(element,labelNode,engine){
|
|
6
|
+
const document=element.ownerDocument,style=document.defaultView.getComputedStyle(element),px=value=>parseFloat(value)||0;
|
|
7
|
+
const fontSize=px(style.fontSize),factor=fontSize/200,context=engine.rasterizer.context,box=element.getBoundingClientRect();
|
|
8
|
+
context.font=`${engine.weight} ${fontSize}px "${engine.face.family}"`;const metric=context.measureText('Hgآی');engine.rasterizer.configure();
|
|
9
|
+
const ascent=metric.fontBoundingBoxAscent,descent=metric.fontBoundingBoxDescent;
|
|
10
|
+
const supported=factor>0&&Number.isFinite(ascent)&&style.fontFamily.includes(engine.face.family)&&Number(style.fontWeight)===engine.weight&&style.fontStyle==='normal'&&style.textTransform==='none'
|
|
11
|
+
&&['normal','0px'].includes(style.letterSpacing)&&['normal','0px'].includes(style.wordSpacing)&&!labelNode.textContent.includes('\t')&&style.textAlign!=='justify';
|
|
12
|
+
if(!supported)return {supported:false};
|
|
13
|
+
const node=labelNode.firstChild,range=document.createRange(),rows=[],text=labelNode.textContent;
|
|
14
|
+
const segments=new Intl.Segmenter(undefined,{granularity:'grapheme'});let offset=0;
|
|
15
|
+
for(const paragraph of text.split('\n')){
|
|
16
|
+
const direction=element.dir==='auto'?documentDirection(paragraph,style.direction):style.direction;let row=null;
|
|
17
|
+
for(const part of segments.segment(paragraph)){
|
|
18
|
+
const start=offset+part.index,end=start+part.segment.length;range.setStart(node,start);range.setEnd(node,end);const rects=[...range.getClientRects()];const rect=rects.find(r=>r.height>0);if(!rect)continue;
|
|
19
|
+
const top=rect.top-box.top-element.clientTop;
|
|
20
|
+
if(!row||Math.abs(row.top-top)>.75){if(row)rows.push(row);row={start,end,top,left:Infinity,right:-Infinity,direction,baseline:top+(rect.height+ascent-descent)/2};}else row.end=end;
|
|
21
|
+
for(const r of rects){row.left=Math.min(row.left,r.left-box.left-element.clientLeft);row.right=Math.max(row.right,r.right-box.left-element.clientLeft);}
|
|
22
|
+
}
|
|
23
|
+
if(row)rows.push(row);offset+=paragraph.length+1;
|
|
24
|
+
}
|
|
25
|
+
for(const row of rows)row.text=text.slice(row.start,row.end);
|
|
26
|
+
const key=JSON.stringify([text,element.clientWidth,element.clientHeight,fontSize,rows.map(r=>[r.start,r.end,Math.round(r.left*64)/64,Math.round(r.baseline*64)/64,r.direction])]);
|
|
27
|
+
return {supported:true,rows,factor,key};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Reuse rasterization, mesh generation, LRU accounting and shared glyph scene.
|
|
31
|
+
// Only line placement is supplied by the DOM's actual wrapping and bidi layout.
|
|
32
|
+
export class HybridTextFlowEngine extends RuntimeFontEngine{
|
|
33
|
+
async prepareRows(layout,{cancelled=()=>false}={}){
|
|
34
|
+
const nextCache=new Map(),entries=[];let slice=performance.now();
|
|
35
|
+
try{for(const row of layout.rows){
|
|
36
|
+
if(cancelled())return null;const item={ch:row.text,i:0,form:0,key:`flow:${row.direction}:${row.text}`,rasterText:row.text,direction:row.direction};this.ensureGlyph(item);
|
|
37
|
+
const glyph=this.records.get(item.key+':0'),metrics={text:row.text,items:[{...item,glyph,x:row.left/layout.factor}],utf16Offsets:[0],height:this.fontMetrics.height};
|
|
38
|
+
nextCache.set(item.key,metrics);entries.push({metrics,offset:row.start,length:row.text.length,baseline:this.fontMetrics.ascent-row.baseline/layout.factor});
|
|
39
|
+
if(performance.now()-slice>4){await new Promise(r=>setTimeout(r,0));slice=performance.now();}
|
|
40
|
+
}
|
|
41
|
+
if(cancelled())return null;this.lineCache=nextCache;this.flowLayout={entries};return this.flowLayout;
|
|
42
|
+
}finally{this.trimCache();}
|
|
43
|
+
}
|
|
44
|
+
layout(){return this.flowLayout||{entries:[]};}
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// One expensive DOM-image snapshot/mesh job per frame and document. Decoding
|
|
2
|
+
// happens before this queue, so a slow network image cannot block ready peers.
|
|
3
|
+
const queues=new WeakMap();
|
|
4
|
+
export function queueImagePreparation(window,run){
|
|
5
|
+
let queue=queues.get(window);
|
|
6
|
+
if(!queue){queue={jobs:[],frame:null,running:false};queues.set(window,queue);}
|
|
7
|
+
let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});
|
|
8
|
+
const job={run,resolve,reject,cancelled:false};queue.jobs.push(job);
|
|
9
|
+
function request(){if(!queue.running&&queue.frame===null&&queue.jobs.length)queue.frame=window.requestAnimationFrame(pump);}
|
|
10
|
+
async function pump(){queue.frame=null;const next=queue.jobs.shift();if(!next)return;queue.running=true;
|
|
11
|
+
try{next.resolve(next.cancelled?false:await next.run());}catch(error){next.reject(error);}
|
|
12
|
+
finally{queue.running=false;request();}
|
|
13
|
+
}
|
|
14
|
+
request();
|
|
15
|
+
return {promise,cancel(){if(job.cancelled)return;job.cancelled=true;const index=queue.jobs.indexOf(job);if(index>=0){queue.jobs.splice(index,1);resolve(false);if(!queue.jobs.length&&queue.frame!==null){window.cancelAnimationFrame(queue.frame);queue.frame=null;}}}};
|
|
16
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const TYPES=new Set(['image/png','image/jpeg','image/webp','image/svg+xml']);
|
|
2
|
+
// Internal, branded snapshots avoid encoding already decoded DOM pixels. An
|
|
3
|
+
// arbitrary object passed to the public loader cannot bypass source validation.
|
|
4
|
+
const snapshots=new WeakMap();
|
|
5
|
+
export function imageRasterSource(raster){const source=Object.freeze({});snapshots.set(source,raster);return source;}
|
|
6
|
+
|
|
7
|
+
function validateSVG(text,document){
|
|
8
|
+
const xml=new document.defaultView.DOMParser().parseFromString(text,'image/svg+xml');
|
|
9
|
+
if(xml.querySelector('parsererror')||xml.documentElement.localName!=='svg')throw Error('Invalid SVG');
|
|
10
|
+
// This first image adapter accepts static, self-contained SVG only.
|
|
11
|
+
for(const element of xml.querySelectorAll('*')){
|
|
12
|
+
if(['script','foreignObject','animate','animateMotion','animateTransform','set'].includes(element.localName))throw Error('SVG must be static and self-contained');
|
|
13
|
+
for(const attribute of element.attributes){
|
|
14
|
+
const value=attribute.value.trim();
|
|
15
|
+
if(attribute.localName==='href'&&value&&!value.startsWith('#')&&!/^data:image\/(png|jpeg|webp);base64,/i.test(value))throw Error('SVG external resources are not supported');
|
|
16
|
+
if(/^on/i.test(attribute.localName))throw Error('SVG event handlers are not supported');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if(/@import|url\(\s*['"]?\s*(?!#)[^\s'"\)]/i.test(text))throw Error('SVG external styles and resources are not supported');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Decode once into sRGB RGBA. Browser-supported static files only; no font engine.
|
|
23
|
+
export async function loadImageRaster(source,{document=globalThis.document,signal,maxSide=2048,maxPixels=2097152}={}){
|
|
24
|
+
const start=performance.now();signal?.throwIfAborted();
|
|
25
|
+
const snapshot=source&&typeof source==='object'?snapshots.get(source):null;
|
|
26
|
+
if(snapshot)return snapshot;
|
|
27
|
+
let blob;
|
|
28
|
+
if(source instanceof Blob)blob=source;
|
|
29
|
+
else{
|
|
30
|
+
const url=new URL(source,document.baseURI);
|
|
31
|
+
if(!['http:','https:','blob:','data:'].includes(url.protocol))throw Error('Unsupported image URL');
|
|
32
|
+
const response=await fetch(url,{signal});if(!response.ok)throw Error('Image request failed: '+response.status);
|
|
33
|
+
blob=await response.blob();
|
|
34
|
+
}
|
|
35
|
+
const type=blob.type.split(';')[0].toLowerCase();
|
|
36
|
+
if(!TYPES.has(type))throw Error('Choose a PNG, JPEG, WebP or static SVG image');
|
|
37
|
+
if(blob.size>20*1024*1024)throw Error('Image file must be smaller than 20 MB');
|
|
38
|
+
if(type==='image/svg+xml')validateSVG(await blob.text(),document);
|
|
39
|
+
signal?.throwIfAborted();
|
|
40
|
+
const window=document.defaultView,image=new window.Image(),url=URL.createObjectURL(blob);
|
|
41
|
+
try{
|
|
42
|
+
await new Promise((resolve,reject)=>{
|
|
43
|
+
const cleanup=()=>{image.onload=image.onerror=null;signal?.removeEventListener('abort',abort);};
|
|
44
|
+
const abort=()=>{cleanup();image.src='';reject(signal.reason||new DOMException('Aborted','AbortError'));};
|
|
45
|
+
image.onload=()=>{cleanup();resolve();};image.onerror=()=>{cleanup();reject(Error('The image could not be decoded'));};
|
|
46
|
+
signal?.addEventListener('abort',abort,{once:true});image.src=url;
|
|
47
|
+
});
|
|
48
|
+
signal?.throwIfAborted();
|
|
49
|
+
const originalWidth=image.naturalWidth,originalHeight=image.naturalHeight;
|
|
50
|
+
if(!originalWidth||!originalHeight||originalWidth*originalHeight>67108864)throw Error('Invalid or oversized image dimensions');
|
|
51
|
+
const scale=Math.min(1,maxSide/Math.max(originalWidth,originalHeight),Math.sqrt(maxPixels/(originalWidth*originalHeight)));
|
|
52
|
+
const width=Math.max(1,Math.floor(originalWidth*scale)),height=Math.max(1,Math.floor(originalHeight*scale));
|
|
53
|
+
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
|
|
54
|
+
try{
|
|
55
|
+
const context=canvas.getContext('2d',{willReadFrequently:true,colorSpace:'srgb'});
|
|
56
|
+
context.drawImage(image,0,0,width,height);
|
|
57
|
+
const rgba=context.getImageData(0,0,width,height).data;
|
|
58
|
+
return {width,height,rgba,mask:false,type,originalWidth,originalHeight,decodeMs:performance.now()-start};
|
|
59
|
+
}finally{canvas.width=canvas.height=0;}
|
|
60
|
+
}finally{URL.revokeObjectURL(url);image.src='';}
|
|
61
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {imageMotionPixels} from './motion-envelope.js';
|
|
2
|
+
import {loadImageRaster} from './image-source.js';
|
|
3
|
+
import {rasterToTextureMesh} from './raster-texture-mesh.js';
|
|
4
|
+
import {attachRasterTexture,applyRasterSamplingShader} from './raster-texture-material.js';
|
|
5
|
+
import {textDivisionsForSize,createProjectedTextSize} from './text-mesh-density.js';
|
|
6
|
+
import {TriangleEffect} from './triangle-effect.js';
|
|
7
|
+
import {normalizeTextEffectOptions} from './text-effect-options.js';
|
|
8
|
+
import {textEditMotions} from './text-edit-motions.js';
|
|
9
|
+
|
|
10
|
+
export const IMAGE_EFFECT_MODES=Object.freeze(Object.keys(textEditMotions));
|
|
11
|
+
// Images and text use the same effect recipe defaults.
|
|
12
|
+
export function normalizeImageEffectOptions(settings={},mode='dust-wind'){
|
|
13
|
+
return normalizeTextEffectOptions({formation:-1,...settings},mode);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Images use twice the text-derived rows: half-size cells, with a bounded grid.
|
|
17
|
+
export function imageDivisions(width,height,displayHeight,current=null,maxTriangles=80000){
|
|
18
|
+
let rows=2*textDivisionsForSize(displayHeight,current===null?null:current/2);
|
|
19
|
+
while(rows>1&&2*rows*Math.max(1,Math.round(width*rows/height))>maxTriangles)rows--;
|
|
20
|
+
if(2*rows*Math.max(1,Math.round(width*rows/height))>maxTriangles)throw Error('Image aspect ratio exceeds the triangle budget');
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const vertexShader=`
|
|
25
|
+
attribute vec2 rasterUV;
|
|
26
|
+
attribute vec2 glyphOffset;
|
|
27
|
+
varying vec2 vRasterUV;
|
|
28
|
+
void main(){
|
|
29
|
+
vRasterUV=rasterUV;
|
|
30
|
+
vec3 p=position+vec3(glyphOffset,0.0);
|
|
31
|
+
/* THD_TRIANGLE_MOTION */
|
|
32
|
+
gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);
|
|
33
|
+
}`;
|
|
34
|
+
const fragmentShader=`
|
|
35
|
+
uniform sampler2D rasterMap;
|
|
36
|
+
varying vec2 vRasterUV;
|
|
37
|
+
void main(){
|
|
38
|
+
vec4 rasterSample=texture2D(rasterMap,vRasterUV);
|
|
39
|
+
float coverage=rasterSample.a;
|
|
40
|
+
gl_FragColor=vec4(rasterSample.rgb, coverage);
|
|
41
|
+
#include <colorspace_fragment>
|
|
42
|
+
}`;
|
|
43
|
+
|
|
44
|
+
// One image object in an existing runtime. No renderer, input or font ownership.
|
|
45
|
+
export function createImageSurface(runtime,{source,width=6,height=3.8,effect='dust-wind',settings={},direction='ltr'}={}){
|
|
46
|
+
if(runtime.disposed)throw Error('Spatial runtime disposed');
|
|
47
|
+
if(!Number.isFinite(width)||!Number.isFinite(height)||width<=0||height<=0)throw RangeError('Positive image bounds required');
|
|
48
|
+
const validMode=mode=>{if(!IMAGE_EFFECT_MODES.includes(mode))throw TypeError('Unsupported image effect');};
|
|
49
|
+
const validDirection=value=>{if(!['ltr','rtl'].includes(value))throw TypeError('Invalid direction');};
|
|
50
|
+
validMode(effect);validDirection(direction);
|
|
51
|
+
const {THREE}=runtime,document=runtime.renderer.domElement.ownerDocument,object=new THREE.Group();
|
|
52
|
+
const motion=new TriangleEffect(THREE,{mode:effect,settings:normalizeImageEffectOptions(settings,effect)}),measure=createProjectedTextSize(THREE);
|
|
53
|
+
let requestedSettings=settings,entryMode=effect,entrySettings=motion.settings;
|
|
54
|
+
let disposed=false,asset=null,controller=null,revision=0,loading=false,error=null,state='empty',intent=null;
|
|
55
|
+
let displaySize=null;
|
|
56
|
+
let lastSeed=null,builds=0,loadMs=0,buildMs=0;
|
|
57
|
+
const view={sharedGroups:new Map(),version:0};
|
|
58
|
+
function releaseAsset(){
|
|
59
|
+
motion.cancel();motion.uniforms.dustMaskOnly.value=0;motion.materials.clear();view.sharedGroups.clear();
|
|
60
|
+
if(asset){object.remove(asset.mesh);asset.mesh.geometry.dispose();asset.mesh.material.dispose();asset.releaseTexture();asset=null;}
|
|
61
|
+
}
|
|
62
|
+
function geometry(raster,rows,w,h,filterRadius,aspect){
|
|
63
|
+
const mesh=rasterToTextureMesh(raster.rgba,raster.width,raster.height,rows,{filterRadius,aspect}),positions=new Float32Array(mesh.triangleCount*9);
|
|
64
|
+
for(let i=0;i<mesh.coordinates.length;i+=2){const at=i/2*3;positions[at]=(mesh.coordinates[i]/raster.width-.5)*w;positions[at+1]=(.5-mesh.coordinates[i+1]/raster.height)*h;}
|
|
65
|
+
const result=new THREE.BufferGeometry();result.setAttribute('position',new THREE.BufferAttribute(positions,3));
|
|
66
|
+
result.setAttribute('rasterUV',new THREE.BufferAttribute(mesh.uvs,2));result.computeBoundingBox();
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
function meshSettings(raster,h,current=null){
|
|
70
|
+
const displayHeight=displaySize?.height??measure(object,runtime.camera,runtime.renderer.domElement,h);
|
|
71
|
+
const aspect=displaySize?displaySize.width/displaySize.height:raster.width/raster.height;
|
|
72
|
+
// Mipmap/bilinear filtering sees beyond the original opaque texel. Retain
|
|
73
|
+
// that support too, especially when a wide image is displayed very small.
|
|
74
|
+
const ratio=raster.height/Math.max(1,displayHeight*(runtime.renderer.getPixelRatio?.()||1));
|
|
75
|
+
const filterRadius=displayHeight>0?Math.max(.5,2**Math.ceil(Math.log2(Math.max(1,ratio)))):.5;
|
|
76
|
+
return {rows:imageDivisions(aspect,1,displayHeight,current),filterRadius,aspect};
|
|
77
|
+
}
|
|
78
|
+
function resizeMesh(){
|
|
79
|
+
if(!asset||motion.active)return;
|
|
80
|
+
const next=meshSettings(asset.raster,asset.h,asset.rows);if(next.rows===asset.rows&&next.filterRadius===asset.filterRadius&&next.aspect===asset.aspect)return;
|
|
81
|
+
const start=performance.now(),replacement=geometry(asset.raster,next.rows,asset.w,asset.h,next.filterRadius,next.aspect);
|
|
82
|
+
asset.mesh.geometry.dispose();asset.mesh.geometry=replacement;asset.rows=next.rows;asset.aspect=next.aspect;asset.filterRadius=next.filterRadius;view.version++;builds++;buildMs=performance.now()-start;
|
|
83
|
+
motion.decorate(view);
|
|
84
|
+
}
|
|
85
|
+
async function setSource(value,{preserveMotion=false,bounds=null}={}){
|
|
86
|
+
if(disposed)throw Error('Image surface disposed');
|
|
87
|
+
const token=++revision;controller?.abort();controller=new AbortController();loading=true;error=null;
|
|
88
|
+
try{
|
|
89
|
+
const raster=await loadImageRaster(value,{document,signal:controller.signal,maxSide:Math.min(2048,runtime.renderer.capabilities?.maxTextureSize||2048)});
|
|
90
|
+
if(disposed||token!==revision)return false;
|
|
91
|
+
if(bounds){width=bounds.width;height=bounds.height;}
|
|
92
|
+
const start=performance.now(),factor=Math.min(width/raster.width,height/raster.height),w=raster.width*factor,h=raster.height*factor;
|
|
93
|
+
const {rows,filterRadius,aspect}=meshSettings(raster,h),g=geometry(raster,rows,w,h,filterRadius,aspect);
|
|
94
|
+
const material=new THREE.ShaderMaterial({vertexShader,fragmentShader,side:THREE.DoubleSide,transparent:true,depthWrite:false,toneMapped:false,extensions:{derivatives:true}});
|
|
95
|
+
applyRasterSamplingShader(material);
|
|
96
|
+
material.defaultAttributeValues.glyphOffset=[0,0];
|
|
97
|
+
const releaseTexture=attachRasterTexture(THREE,material,raster),mesh=new THREE.Mesh(g,material);mesh.frustumCulled=false;
|
|
98
|
+
// Replacement of DOM pixels/size must not restart an existing effect or
|
|
99
|
+
// reveal an image that has already completed its exit.
|
|
100
|
+
const previousState=state,job=motion.jobs[0],seed=lastSeed;
|
|
101
|
+
const previousIntent=preserveMotion?(intent??(motion.active&&job?{departing:motion.departing,fromAge:job.fromAge,started:job.started,seed}:null)):null;
|
|
102
|
+
releaseAsset();asset={raster,w,h,rows,filterRadius,aspect,mesh,releaseTexture};object.add(mesh);view.sharedGroups.set('image',{mesh});view.version++;
|
|
103
|
+
view.motionFrame={x:-w/2,y:-h/2,width:w,height:h};
|
|
104
|
+
state=preserveMotion?previousState:'visible';intent=previousIntent;lastSeed=preserveMotion?seed:null;mesh.visible=state!=='hidden';builds++;loadMs=raster.decodeMs;buildMs=performance.now()-start;runtime.requestRender();return true;
|
|
105
|
+
}catch(e){if(disposed||token!==revision||e.name==='AbortError')return false;error=e.message;throw e;}
|
|
106
|
+
finally{if(token===revision){loading=false;runtime.requestRender();}}
|
|
107
|
+
}
|
|
108
|
+
function play(departing){
|
|
109
|
+
if(disposed||!asset)return false;
|
|
110
|
+
if(departing&&(state==='hidden'||state==='leaving'||intent?.departing))return false;
|
|
111
|
+
const fromAge=state==='entering'&&motion.jobs[0]?Math.max(0,Math.min(1,((motion.lastTime??motion.jobs[0].started)-motion.jobs[0].started)/motion.duration)):1;
|
|
112
|
+
intent={departing,fromAge};runtime.requestRender();return true;
|
|
113
|
+
}
|
|
114
|
+
const control={object,
|
|
115
|
+
frame(now,reducedMotion){
|
|
116
|
+
if(disposed||!asset)return;
|
|
117
|
+
resizeMesh();
|
|
118
|
+
if(intent){
|
|
119
|
+
const {departing,fromAge,started,seed}=intent;intent=null;asset.mesh.visible=true;state=departing?'leaving':'entering';
|
|
120
|
+
const exitMode=entrySettings.exitEffect==='same'?entryMode:entrySettings.exitEffect;
|
|
121
|
+
const activeMode=departing?exitMode:entryMode;
|
|
122
|
+
motion.setMode(activeMode);motion.configure(normalizeImageEffectOptions(requestedSettings,activeMode));
|
|
123
|
+
if(seed!==undefined)lastSeed=seed;else if(!departing||lastSeed===null)lastSeed=motion.randomSeed();
|
|
124
|
+
// DOM image coordinates are source pixels, unlike the spatial runtime's
|
|
125
|
+
// world units. Keep travel at a stable 32 CSS px for DOM presentations.
|
|
126
|
+
const shownHeight=displaySize?.height??measure(object,runtime.camera,runtime.renderer.domElement,asset.h);
|
|
127
|
+
const shownWidth=displaySize?.width??shownHeight*asset.w/asset.h;
|
|
128
|
+
const motionUnit=shownHeight>0?imageMotionPixels(shownWidth,shownHeight)*asset.h/shownHeight:.65;
|
|
129
|
+
motion.playRegion(view,{x:-asset.w/2,y:-asset.h/2,width:asset.w,height:asset.h,direction},motionUnit,started??now,{departing,seed:lastSeed,fromAge:departing&&exitMode!==entryMode?1:fromAge});
|
|
130
|
+
}
|
|
131
|
+
if(motion.active){
|
|
132
|
+
motion.step(now,reducedMotion);
|
|
133
|
+
// This surface reuses one material for both directions. Refresh its
|
|
134
|
+
// dynamic clock/mask uniforms on the first draw after a transition too.
|
|
135
|
+
asset.mesh.material.uniformsNeedUpdate=true;
|
|
136
|
+
// Upload this frame's region data before reusing the same sampler.
|
|
137
|
+
runtime.renderer.initTexture?.(motion.uniforms.dustData.value);
|
|
138
|
+
if(motion.active)runtime.requestRender();
|
|
139
|
+
else{state=motion.departing?'hidden':'visible';asset.mesh.visible=!motion.departing;}
|
|
140
|
+
}
|
|
141
|
+
resizeMesh();
|
|
142
|
+
},
|
|
143
|
+
invalidate(){runtime.requestRender();},
|
|
144
|
+
destroy(){if(disposed)return;disposed=true;revision++;controller?.abort();unregister();releaseAsset();motion.dispose();state='disposed';},
|
|
145
|
+
};
|
|
146
|
+
const unregister=runtime.register(control);
|
|
147
|
+
return {object,ready:source===undefined?Promise.resolve(false):setSource(source),setSource,
|
|
148
|
+
enter:()=>play(false),exit:()=>play(true),
|
|
149
|
+
setEffect(mode,options={}){validMode(mode);const next=normalizeImageEffectOptions(options,mode);if(disposed)return;requestedSettings=options;entryMode=mode;entrySettings=next;motion.setMode(mode);motion.configure(next);motion.cancel();motion.uniforms.dustMaskOnly.value=0;intent=null;if(asset){asset.mesh.visible=true;state='visible';}runtime.requestRender();},
|
|
150
|
+
setDisplaySize(width,height){if(!(Number.isFinite(width)&&Number.isFinite(height)&&width>0&&height>0))throw RangeError('Positive display dimensions required');displaySize={width,height};},
|
|
151
|
+
setDirection(value){validDirection(value);direction=value;},
|
|
152
|
+
show(){if(disposed||!asset)return;intent=null;motion.cancel();motion.uniforms.dustMaskOnly.value=0;asset.mesh.visible=true;state='visible';runtime.requestRender();},
|
|
153
|
+
frame:control.frame,destroy:control.destroy,
|
|
154
|
+
stats(){const raster=asset?.raster,g=asset?.mesh.geometry;return {disposed,loading,error,state,active:motion.active,mode:entryMode,activeMode:motion.mode,exitMode:entrySettings.exitEffect==='same'?entryMode:entrySettings.exitEffect,duration:motion.duration,settings:{...motion.settings,recipe:{...motion.settings.recipe}},direction,motionFrame:asset?{...view.motionFrame}:null,divisions:asset?.rows,gridAspect:asset?.aspect,gridColumns:asset?Math.max(1,Math.round(asset.rows*asset.aspect)):0,triangles:g?g.getAttribute('position').count/3:0,builds,loadMs,buildMs,rasterBytes:raster?.rgba.byteLength||0,estimatedTextureBytes:raster?Math.ceil(raster.rgba.byteLength*4/3):0,geometryBytes:g?Object.values(g.attributes).reduce((n,a)=>n+a.array.byteLength,0):0,source:raster?{type:raster.type,width:raster.width,height:raster.height,originalWidth:raster.originalWidth,originalHeight:raster.originalHeight}:null};},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// UTF-16 edit range shared by both adapters. The selection disambiguates repeats.
|
|
2
|
+
export function insertedRange(before,after,inputType='insertText'){
|
|
3
|
+
if(!before||!after||!/^insert/.test(inputType)||before.text===after.text)return null;
|
|
4
|
+
const start=before.start??0,end=before.end??start;
|
|
5
|
+
const length=after.text.length-(before.text.length-(end-start));
|
|
6
|
+
if(length>0&&after.text.slice(0,start)===before.text.slice(0,start)&&after.text.slice(start+length)===before.text.slice(end))return {start,end:start+length};
|
|
7
|
+
let left=0,right=0;
|
|
8
|
+
while(left<before.text.length&&left<after.text.length&&before.text[left]===after.text[left])left++;
|
|
9
|
+
while(right<before.text.length-left&&right<after.text.length-left&&before.text[before.text.length-1-right]===after.text[after.text.length-1-right])right++;
|
|
10
|
+
return after.text.length-right>left?{start:left,end:after.text.length-right}:null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Keep the surviving pieces of an older insertion when a later edit shifts it.
|
|
14
|
+
export function remapInsertionRange(range,start,oldEnd,delta){
|
|
15
|
+
const pieces=[];
|
|
16
|
+
if(range.start<start)pieces.push({start:range.start,end:Math.min(range.end,start)});
|
|
17
|
+
if(range.end>oldEnd)pieces.push({start:Math.max(range.start,oldEnd)+delta,end:range.end+delta});
|
|
18
|
+
return pieces.filter(piece=>piece.end>piece.start);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function removedRange(before,after,inputType=''){
|
|
22
|
+
if(!before||!after||!/^delete/.test(inputType))return null;
|
|
23
|
+
const length=before.text.length-after.text.length;if(length<=0)return null;
|
|
24
|
+
const caret=before.start??0;
|
|
25
|
+
const start=before.end>caret?caret:/Backward$/.test(inputType)?Math.max(0,caret-length):caret;
|
|
26
|
+
if(before.text.slice(0,start)+before.text.slice(start+length)===after.text)return {start,end:start+length};
|
|
27
|
+
let left=0;while(left<after.text.length&&before.text[left]===after.text[left])left++;
|
|
28
|
+
return {start:left,end:left+length};
|
|
29
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { JOINING_FORMS } from './font-mesh-engine.js';
|
|
2
|
+
import { integrateTenSamples } from './triangle-coverage.js';
|
|
3
|
+
|
|
4
|
+
export const DIVISIONS = [24, 36, 48, 60, 72];
|
|
5
|
+
export const PERSIAN_ALPHABET = 'آابپتثجچحخدذرزژسشصضطظعغفقکگلمنوهی';
|
|
6
|
+
export const EXTRA_CHARACTERS = ['أ', 'إ', 'ؤ', 'ئ', 'هٔ', 'اً'];
|
|
7
|
+
export const SAMPLE_TEXT = 'آ ا ب پ ت ث ج چ ح خ د ذ ر ز ژ\nس ش ص ض ط ظ ع غ ف ق ک گ ل م ن و ه ی\nپدر، پنجره، سپید، توپ؛ چشمه، پژوهش و زندگی\nخانۀ ما، خانهٔ ما؛ لطفاً، واقعاً، سؤال، مسئول\n۰۱۲۳۴۵۶۷۸۹ ٠١٢٣٤٥٦٧٨٩ 0123456789\nABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz';
|
|
8
|
+
|
|
9
|
+
export function repertoire(extras = true) {
|
|
10
|
+
const chars = [...new Set(Array.from(PERSIAN_ALPHABET + 'ء' + '۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩' +
|
|
11
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +
|
|
12
|
+
' `~!@#$%^&*()-_=+[]{}\\|;:\'",.<>/?،؛؟«»٪٫٬'))];
|
|
13
|
+
if (extras) chars.push(...EXTRA_CHARACTERS);
|
|
14
|
+
return chars.flatMap(character => {
|
|
15
|
+
const base = Array.from(character)[0];
|
|
16
|
+
const marks = Array.from(character).slice(1).join('');
|
|
17
|
+
const variants = JOINING_FORMS[base] || [base];
|
|
18
|
+
return variants.map((form, formIndex) => ({ character, formIndex,
|
|
19
|
+
rasterText: (formIndex === 0 ? base : form) + marks }));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Exact antialiased coverage by default; runtime text can opt into the tested
|
|
24
|
+
// ten-point approximation. Both paths share the same grid and output format.
|
|
25
|
+
export function rasterToTriangles(rgba, width, height, divisions, packed = false, coverageSamples = 0) {
|
|
26
|
+
if (!DIVISIONS.includes(divisions)) throw new Error('تقسیمات نامعتبر است.');
|
|
27
|
+
if (coverageSamples !== 0 && coverageSamples !== 10) throw new RangeError('Coverage samples must be 0 (all pixels) or 10');
|
|
28
|
+
const rows = divisions, columns = Math.max(1, Math.round(width / (height / rows)));
|
|
29
|
+
const cellWidth = width / columns, cellHeight = height / rows;
|
|
30
|
+
const sums = new Float64Array(rows * columns * 2);
|
|
31
|
+
const counts = new Uint32Array(sums.length);
|
|
32
|
+
// Keep exact integration for export and for cells already smaller than the
|
|
33
|
+
// sample budget. Runtime callers opt into ten-point coverage explicitly.
|
|
34
|
+
if (coverageSamples === 10 && cellWidth * cellHeight > 20) integrateTenSamples(rgba,width,height,rows,columns,cellWidth,cellHeight,sums,counts);
|
|
35
|
+
else if (width > 1024) integrateWideRaster(rgba,width,height,rows,columns,cellWidth,cellHeight,sums,counts);
|
|
36
|
+
else for (let y = 0; y < height; y++) {
|
|
37
|
+
const gy = (y + 0.5) / cellHeight, row = Math.min(rows - 1, Math.floor(gy)), fy = gy - row;
|
|
38
|
+
for (let x = 0; x < width; x++) {
|
|
39
|
+
const gx = (x + 0.5) / cellWidth, column = Math.min(columns - 1, Math.floor(gx)), fx = gx - column;
|
|
40
|
+
const side = (row + column) % 2 === 0 ? (fx + fy <= 1 ? 0 : 1) : (fy <= fx ? 0 : 1);
|
|
41
|
+
const slot = (row * columns + column) * 2 + side;
|
|
42
|
+
sums[slot] += rgba[(y * width + x) * 4 + 3] / 255;
|
|
43
|
+
counts[slot]++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let triangleCount = 0;
|
|
47
|
+
if (packed) for (let at = 0; at < sums.length; at++) if (sums[at] >= 0.08) triangleCount++;
|
|
48
|
+
// Float64 preserves font-space arithmetic until the final GPU Float32 write.
|
|
49
|
+
const coordinates = packed ? new Float64Array(triangleCount * 6) : null;
|
|
50
|
+
const coverage = packed ? new Uint8Array(triangleCount) : null;
|
|
51
|
+
const triangles = packed ? null : [];
|
|
52
|
+
let triangleIndex = 0;
|
|
53
|
+
for (let row = 0; row < rows; row++) for (let column = 0; column < columns; column++) {
|
|
54
|
+
const left = column * cellWidth, right = (column + 1) * cellWidth;
|
|
55
|
+
const top = row * cellHeight, bottom = (row + 1) * cellHeight;
|
|
56
|
+
const even = (row + column) % 2 === 0;
|
|
57
|
+
const pair = packed ? null : even
|
|
58
|
+
? [[[right, top], [left, bottom], [left, top]], [[right, top], [right, bottom], [left, bottom]]]
|
|
59
|
+
: [[[left, top], [right, bottom], [right, top]], [[left, top], [left, bottom], [right, bottom]]];
|
|
60
|
+
for (let side = 0; side < 2; side++) {
|
|
61
|
+
const at = (row * columns + column) * 2 + side;
|
|
62
|
+
if (sums[at] < 0.08) continue;
|
|
63
|
+
const level = Math.max(1, Math.min(6, Math.round(sums[at] / counts[at] * 6)));
|
|
64
|
+
if (packed) {
|
|
65
|
+
const at = triangleIndex * 6;
|
|
66
|
+
coordinates[at] = even ? right : left;
|
|
67
|
+
coordinates[at + 1] = top;
|
|
68
|
+
coordinates[at + 2] = even ? (side ? right : left) : (side ? left : right);
|
|
69
|
+
coordinates[at + 3] = bottom;
|
|
70
|
+
coordinates[at + 4] = even ? left : right;
|
|
71
|
+
coordinates[at + 5] = side ? bottom : top;
|
|
72
|
+
coverage[triangleIndex++] = level;
|
|
73
|
+
} else triangles.push({ points: pair[side], average: level / 6 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return packed ? { coordinates, coverage, triangleCount } : triangles;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Long paragraphs have many pixels per grid cell. Resolve each axis once and
|
|
80
|
+
// accumulate locally instead of dividing and writing two arrays per pixel.
|
|
81
|
+
// Within each triangle pixels retain the reference's y/x order and /255 sum;
|
|
82
|
+
// grid boundaries, threshold decisions and quantized coverage remain identical.
|
|
83
|
+
function integrateWideRaster(rgba,width,height,rows,columns,cellWidth,cellHeight,sums,counts){
|
|
84
|
+
function axis(size,step,bins){
|
|
85
|
+
const starts=new Uint32Array(bins+1),fractions=new Float64Array(size);let previous=0;
|
|
86
|
+
for(let pixel=0;pixel<size;pixel++){
|
|
87
|
+
const grid=(pixel+.5)/step,bin=Math.min(bins-1,Math.floor(grid));fractions[pixel]=grid-bin;
|
|
88
|
+
while(previous<bin)starts[++previous]=pixel;
|
|
89
|
+
}
|
|
90
|
+
starts.fill(size,previous+1);return {starts,fractions};
|
|
91
|
+
}
|
|
92
|
+
const x=axis(width,cellWidth,columns),y=axis(height,cellHeight,rows);
|
|
93
|
+
for(let row=0;row<rows;row++){
|
|
94
|
+
const top=y.starts[row],bottom=y.starts[row+1];if(top===bottom)continue;
|
|
95
|
+
for(let column=0;column<columns;column++){
|
|
96
|
+
const left=x.starts[column],right=x.starts[column+1];if(left===right)continue;
|
|
97
|
+
const even=(row+column)%2===0;let sum0=0,sum1=0,count0=0,count1=0;
|
|
98
|
+
for(let py=top;py<bottom;py++){
|
|
99
|
+
const fy=y.fractions[py];let alpha=(py*width+left)*4+3;
|
|
100
|
+
for(let px=left;px<right;px++,alpha+=4){
|
|
101
|
+
if(even?x.fractions[px]+fy<=1:fy<=x.fractions[px]){sum0+=rgba[alpha]/255;count0++;}
|
|
102
|
+
else {sum1+=rgba[alpha]/255;count1++;}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const slot=(row*columns+column)*2;
|
|
106
|
+
sums[slot]=sum0;sums[slot+1]=sum1;counts[slot]=count0;counts[slot+1]=count1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function encodeTMG(records) {
|
|
112
|
+
const size = 12 + records.reduce((sum, r) => sum + 20 + Array.from(r.character).length * 4 + r.triangles.length * 13, 0);
|
|
113
|
+
const bytes = new Uint8Array(size), view = new DataView(bytes.buffer);
|
|
114
|
+
bytes.set([84, 77, 71, 53, 1, 0, 0, 0]);
|
|
115
|
+
view.setUint32(8, records.length, true);
|
|
116
|
+
let offset = 12;
|
|
117
|
+
for (const r of records) {
|
|
118
|
+
const codePoints = Array.from(r.character, ch => ch.codePointAt(0));
|
|
119
|
+
view.setUint8(offset++, codePoints.length);
|
|
120
|
+
for (const cp of codePoints) { view.setUint32(offset, cp, true); offset += 4; }
|
|
121
|
+
view.setUint8(offset++, r.formIndex);
|
|
122
|
+
for (const value of [r.width, r.height]) { view.setUint16(offset, value, true); offset += 2; }
|
|
123
|
+
for (const value of [r.advance, r.baseline]) { view.setFloat32(offset, value, true); offset += 4; }
|
|
124
|
+
view.setUint16(offset, r.drawOffsetX, true); offset += 2;
|
|
125
|
+
view.setUint32(offset, r.triangles.length, true); offset += 4;
|
|
126
|
+
for (const t of r.triangles) {
|
|
127
|
+
for (const [x, y] of t.points) {
|
|
128
|
+
view.setUint16(offset, Math.round(x / r.width * 65535), true);
|
|
129
|
+
view.setUint16(offset + 2, Math.round(y / r.height * 65535), true); offset += 4;
|
|
130
|
+
}
|
|
131
|
+
view.setUint8(offset++, Math.round(t.average * 6));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (offset !== size) throw new Error('اندازهٔ خروجی با رکوردها سازگار نیست.');
|
|
135
|
+
return bytes;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function compressTMG(records) {
|
|
139
|
+
return new Blob([await new Response(new Blob([encodeTMG(records)]).stream().pipeThrough(new CompressionStream('gzip'))).arrayBuffer()], { type: 'application/gzip' });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function assertCoverage(records, extras = true) {
|
|
143
|
+
const map = new Map(records.map(r => [`${r.character}:${r.formIndex}`, r]));
|
|
144
|
+
const missing = repertoire(extras).filter(r => {
|
|
145
|
+
const found = map.get(`${r.character}:${r.formIndex}`);
|
|
146
|
+
return !found || (r.character.trim() && !found.triangles.length);
|
|
147
|
+
});
|
|
148
|
+
return missing.map(r => `${r.character}:${r.formIndex}`);
|
|
149
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// CSS display pixels, independent of source resolution and triangle density.
|
|
2
|
+
// 32px at a 200px short side; sublinear growth with bounded extremes.
|
|
3
|
+
export function imageMotionPixels(width,height){
|
|
4
|
+
const side=Math.max(1,Math.min(width,height));
|
|
5
|
+
return Math.min(64,Math.max(8,32*Math.sqrt(side/200)));
|
|
6
|
+
}
|
|
7
|
+
// Identity inside the normal envelope, C1-continuous outside it; no hard clamp.
|
|
8
|
+
export function regulatedMotionLength(length,unit){
|
|
9
|
+
const radius=4*Math.max(unit,0.0001);
|
|
10
|
+
if(length<=radius)return length;
|
|
11
|
+
const excess=length-radius;
|
|
12
|
+
return radius+radius*excess/(radius+excess);
|
|
13
|
+
}
|
|
14
|
+
export const motionEnvelopeShader=`
|
|
15
|
+
vec3 thdRegulateMotion(vec3 delta,float unit){
|
|
16
|
+
float radius=4.0*max(unit,0.0001);
|
|
17
|
+
float distance=length(delta);
|
|
18
|
+
if(distance<=radius)return delta;
|
|
19
|
+
float excess=distance-radius;
|
|
20
|
+
return delta*((radius+radius*excess/(radius+excess))/distance);
|
|
21
|
+
}
|
|
22
|
+
`;
|
package/src/motion.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const MOTION_SAMPLES = 32;
|
|
2
|
+
|
|
3
|
+
// A fixed-size radial lookup replaces one CPU rotation calculation per vertex.
|
|
4
|
+
// The vertex shader and pointer projection interpolate the same samples.
|
|
5
|
+
export class RadialMotion {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.angles = new Float32Array(MOTION_SAMPLES * 2);
|
|
8
|
+
this.targetX = 0;
|
|
9
|
+
this.targetY = 0;
|
|
10
|
+
this.radius = 1;
|
|
11
|
+
this.active = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
rotate(x, y) {
|
|
15
|
+
this.targetX += x;
|
|
16
|
+
this.targetY += y;
|
|
17
|
+
this.active = true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
step(dt) {
|
|
21
|
+
if (!this.active) return false;
|
|
22
|
+
let error = 0;
|
|
23
|
+
for (let i = 0; i < MOTION_SAMPLES; i++) {
|
|
24
|
+
const factor = 1 - Math.exp(-(11.9 - i / (MOTION_SAMPLES - 1) * 8.8) * dt);
|
|
25
|
+
for (let axis = 0; axis < 2; axis++) {
|
|
26
|
+
const at = i * 2 + axis;
|
|
27
|
+
const target = axis ? this.targetY : this.targetX;
|
|
28
|
+
this.angles[at] += (target - this.angles[at]) * factor;
|
|
29
|
+
error = Math.max(error, Math.abs(target - this.angles[at]));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
this.active = error >= 0.0005;
|
|
33
|
+
if (!this.active) {
|
|
34
|
+
for (let i = 0; i < MOTION_SAMPLES; i++) {
|
|
35
|
+
this.angles[i * 2] = this.targetX;
|
|
36
|
+
this.angles[i * 2 + 1] = this.targetY;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
transform(x, y, out, z = 0) {
|
|
43
|
+
const sample = Math.min(1, Math.hypot(x, y) / Math.max(this.radius, 0.001)) * (MOTION_SAMPLES - 1);
|
|
44
|
+
const low = Math.min(MOTION_SAMPLES - 2, Math.floor(sample));
|
|
45
|
+
const t = sample - low;
|
|
46
|
+
const ax = this.angles[low * 2] * (1 - t) + this.angles[(low + 1) * 2] * t;
|
|
47
|
+
const ay = this.angles[low * 2 + 1] * (1 - t) + this.angles[(low + 1) * 2 + 1] * t;
|
|
48
|
+
const ry = y * Math.cos(ax) - z * Math.sin(ax);
|
|
49
|
+
const rz = y * Math.sin(ax) + z * Math.cos(ax);
|
|
50
|
+
return out.set(x * Math.cos(ay) + rz * Math.sin(ay), ry, -x * Math.sin(ay) + rz * Math.cos(ay));
|
|
51
|
+
}
|
|
52
|
+
}
|