@tongue-core/engine 0.1.0-rc.1 → 0.1.0-rc.10
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/README.md +17 -0
- package/dist/assets/codecs/types.d.ts +2 -0
- package/dist/assets/codecs/wav.d.ts +3 -0
- package/dist/assets/codecs/wav.js +26 -6
- package/dist/assets/store.d.ts +18 -0
- package/dist/assets/store.js +82 -6
- package/dist/audio/mix.d.ts +7 -1
- package/dist/audio/mix.js +14 -3
- package/dist/evaluate/evaluate.js +123 -10
- package/dist/evaluate/tree.d.ts +24 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/layout/layout.js +130 -82
- package/dist/paint/geometry.d.ts +12 -4
- package/dist/paint/geometry.js +25 -11
- package/dist/paint/list.d.ts +28 -1
- package/dist/paint/list.js +26 -2
- package/dist/paint/morph.js +11 -7
- package/dist/paint/paint.d.ts +18 -7
- package/dist/paint/paint.js +181 -16
- package/dist/paint/stroke.d.ts +26 -0
- package/dist/paint/stroke.js +233 -0
- package/dist/raster/raster.d.ts +3 -0
- package/dist/raster/raster.js +5 -0
- package/dist/raster/wasm-bytes.js +2 -2
- package/dist/render/compose.d.ts +1 -1
- package/dist/render/compose.js +33 -11
- package/dist/render/renderer.d.ts +7 -6
- package/dist/render/renderer.js +28 -7
- package/dist/render/size.d.ts +6 -0
- package/dist/render/size.js +7 -0
- package/dist/render/sounds.d.ts +4 -1
- package/dist/render/sounds.js +6 -2
- package/dist/style/computed.d.ts +14 -1
- package/dist/style/parse.js +2 -4
- package/dist/style/resolve.js +53 -4
- package/dist/text/fonts.d.ts +4 -1
- package/dist/text/fonts.js +9 -1
- package/dist/text/ttf.js +5 -1
- package/package.json +6 -4
- package/raster.wasm +0 -0
package/dist/paint/paint.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { composePose, full, rest } from "@tongue-core/film/model";
|
|
2
2
|
import { cos, lerp, radians, sin } from "@tongue-core/film/model";
|
|
3
|
-
import { about, apply, bounds, fitRect, identity, inflate, lerpRect, mul, perspective, scaleOf, translate, union } from "./geometry.js";
|
|
3
|
+
import { about, apply, bounds, fitRect, identity, inflate, lerpRect, mul, ontoQuad, perspective, scaleOf, translate, union } from "./geometry.js";
|
|
4
|
+
import { strokeOutline } from "./stroke.js";
|
|
4
5
|
import { screenGlyphs } from "../evaluate/scene-frame.js";
|
|
5
6
|
export const premul = (c, alpha = 1) => {
|
|
6
7
|
const a = c.a * alpha;
|
|
@@ -29,9 +30,16 @@ export function extentOf(lb) {
|
|
|
29
30
|
return hit;
|
|
30
31
|
let r = rectOf(lb);
|
|
31
32
|
for (const s of lb.box.style.shadows)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
if (!s.inset)
|
|
34
|
+
r = union(r, inflate({ x: lb.x + s.x - s.spread, y: lb.y + s.y - s.spread, w: lb.w + 2 * s.spread, h: lb.h + 2 * s.spread }, s.blur * 1.5));
|
|
35
|
+
if (lb.text) {
|
|
36
|
+
const text = { x: lb.text.x, y: lb.text.y, w: lb.text.paragraph.width, h: lb.text.paragraph.height };
|
|
37
|
+
r = union(r, text);
|
|
38
|
+
if (lb.box.content.kind === "text")
|
|
39
|
+
for (const run of lb.box.content.runs)
|
|
40
|
+
for (const s of run.style.shadows)
|
|
41
|
+
r = union(r, inflate({ ...text, x: text.x + s.x, y: text.y + s.y }, s.blur * 1.5));
|
|
42
|
+
}
|
|
35
43
|
if (!lb.box.style.overflowHidden)
|
|
36
44
|
for (const c of lb.children)
|
|
37
45
|
r = union(r, extentOf(c));
|
|
@@ -72,6 +80,8 @@ export class Painter {
|
|
|
72
80
|
lifted = null;
|
|
73
81
|
/** Screen → surface for the scene being painted (render scale, plus any whole-scene move). */
|
|
74
82
|
screenBase;
|
|
83
|
+
/** The camera the children now being painted share (their parent's `perspective`), in surface pixels. */
|
|
84
|
+
view = null;
|
|
75
85
|
constructor(list, res, device) {
|
|
76
86
|
this.list = list;
|
|
77
87
|
this.res = res;
|
|
@@ -164,17 +174,31 @@ export class Painter {
|
|
|
164
174
|
const opacity = s.opacity * pose.opacity;
|
|
165
175
|
if (opacity <= 0.002)
|
|
166
176
|
return;
|
|
177
|
+
// Perspective reaches only direct children: this element uses its parent's, and its own
|
|
178
|
+
// descendants start without one.
|
|
179
|
+
const view = this.view;
|
|
180
|
+
this.view = null;
|
|
167
181
|
const blur = s.blur + pose.blur;
|
|
168
182
|
const brightness = s.brightness * pose.brightness;
|
|
169
183
|
const cx = lb.x + lb.w / 2, cy = lb.y + lb.h / 2;
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
184
|
+
const ox = lb.x + lb.w * t.origin[0], oy = lb.y + lb.h * t.origin[1];
|
|
185
|
+
const local = mul(about(ox, oy, scale, rotate), translate(t.x + pose.x, t.y + pose.y));
|
|
186
|
+
const corners = lb.box.corners;
|
|
187
|
+
// A mapped element is drawn at the corner of its layer, wherever layout put it, then warped.
|
|
188
|
+
const moved = mul(base, local);
|
|
189
|
+
const source = corners ? bounds(moved, rectOf(lb)) : null;
|
|
190
|
+
const m = source ? mul(translate(-source.x, -source.y), moved) : moved;
|
|
191
|
+
const onto = corners && source ? ontoQuad({ ...source, x: 0, y: 0 }, corners.map(([x, y]) => apply(base, x, y))) : null;
|
|
192
|
+
if (corners && !onto) {
|
|
193
|
+
this.view = view;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const flat = turn === 0 && tilt === 0 && !corners;
|
|
197
|
+
const layered = opacity < 0.998 || blur > 0.01 || Math.abs(brightness - 1) > 0.001 || Math.abs(s.saturate - 1) > 0.001 || Math.abs(s.contrast - 1) > 0.001 || s.hueRotate !== 0 || s.blend !== "normal" || !flat;
|
|
174
198
|
const k = scaleOf(m);
|
|
175
199
|
if (layered) {
|
|
176
200
|
const area = inflate(bounds(m, extentOf(lb)), blur * 3 * k + 2);
|
|
177
|
-
this.layer(area, { opacity, brightness, saturate: s.saturate, blur: blur * k });
|
|
201
|
+
this.layer(area, { opacity, brightness, saturate: s.saturate, blur: blur * k, contrast: s.contrast, hue: s.hueRotate === 0 ? undefined : { cos: cos(radians(s.hueRotate)), sin: sin(radians(s.hueRotate)) }, blend: s.blend });
|
|
178
202
|
}
|
|
179
203
|
const iris = pose.iris < 0.999;
|
|
180
204
|
if (iris) {
|
|
@@ -187,12 +211,16 @@ export class Painter {
|
|
|
187
211
|
this.content(sf, lb, m, cam, hooks);
|
|
188
212
|
if (iris)
|
|
189
213
|
this.list.popClip();
|
|
214
|
+
this.view = view;
|
|
190
215
|
if (layered) {
|
|
191
|
-
if (
|
|
216
|
+
if (onto)
|
|
217
|
+
this.list.popLayer(onto);
|
|
218
|
+
else if (flat)
|
|
192
219
|
this.list.popLayer();
|
|
193
220
|
else {
|
|
194
221
|
const box = bounds(m, rectOf(lb));
|
|
195
|
-
|
|
222
|
+
const custom = view !== null || t.origin[0] !== 0.5 || t.origin[1] !== 0.5;
|
|
223
|
+
this.list.popLayer(custom ? perspective(box, turn, tilt, view?.depth ?? 1800 * k, apply(m, ox, oy), view?.vanish) : perspective(box, turn, tilt, 1800 * k));
|
|
196
224
|
}
|
|
197
225
|
this.current = null;
|
|
198
226
|
}
|
|
@@ -203,7 +231,7 @@ export class Painter {
|
|
|
203
231
|
decorateRect(rect, m, look, alpha = 1) {
|
|
204
232
|
this.setTransform(m);
|
|
205
233
|
for (const sh of look.shadows) {
|
|
206
|
-
if (sh.color.a <= 0)
|
|
234
|
+
if (sh.color.a <= 0 || sh.inset)
|
|
207
235
|
continue;
|
|
208
236
|
const r = { x: rect.x + sh.x - sh.spread, y: rect.y + sh.y - sh.spread, w: rect.w + 2 * sh.spread, h: rect.h + 2 * sh.spread };
|
|
209
237
|
this.list.shadowRRect(r, Math.max(0, (look.radii.tl + look.radii.tr + look.radii.br + look.radii.bl) / 4 + sh.spread), Math.max(0.01, sh.blur / 2), premul(sh.color, alpha));
|
|
@@ -213,6 +241,10 @@ export class Painter {
|
|
|
213
241
|
if (paint)
|
|
214
242
|
this.list.fillRRect(rect, look.radii, paint);
|
|
215
243
|
}
|
|
244
|
+
for (const sh of look.shadows)
|
|
245
|
+
if (sh.inset && sh.color.a > 0)
|
|
246
|
+
this.insetShadow(rect, m, look.radii, sh, alpha);
|
|
247
|
+
this.setTransform(m);
|
|
216
248
|
const [top, right, bottom, left] = look.border;
|
|
217
249
|
const uniform = top.width === right.width && top.width === bottom.width && top.width === left.width &&
|
|
218
250
|
[right, bottom, left].every((x) => x.color.r === top.color.r && x.color.g === top.color.g && x.color.b === top.color.b && x.color.a === top.color.a);
|
|
@@ -230,6 +262,31 @@ export class Painter {
|
|
|
230
262
|
this.list.fillRRect(r, { tl: 0, tr: 0, br: 0, bl: 0 }, { kind: "solid", color: premul(side.color, alpha) });
|
|
231
263
|
}
|
|
232
264
|
}
|
|
265
|
+
/** A shadow cast inside a box: everything outside the box's shape, shifted and blurred, seen
|
|
266
|
+
* through the box. */
|
|
267
|
+
insetShadow(rect, m, radii, sh, alpha) {
|
|
268
|
+
const k = scaleOf(m);
|
|
269
|
+
const sigma = sh.blur / 2;
|
|
270
|
+
const reach = sigma * 3 + Math.abs(sh.x) + Math.abs(sh.y) + Math.abs(sh.spread) + 2;
|
|
271
|
+
const outer = inflate(rect, reach);
|
|
272
|
+
const hole = { x: rect.x + sh.x + sh.spread, y: rect.y + sh.y + sh.spread, w: Math.max(0, rect.w - 2 * sh.spread), h: Math.max(0, rect.h - 2 * sh.spread) };
|
|
273
|
+
const verbs = [
|
|
274
|
+
{ kind: "move", x: outer.x, y: outer.y },
|
|
275
|
+
{ kind: "line", x: outer.x + outer.w, y: outer.y },
|
|
276
|
+
{ kind: "line", x: outer.x + outer.w, y: outer.y + outer.h },
|
|
277
|
+
{ kind: "line", x: outer.x, y: outer.y + outer.h },
|
|
278
|
+
{ kind: "close" },
|
|
279
|
+
...rrectVerbs(hole, radii, -sh.spread),
|
|
280
|
+
];
|
|
281
|
+
this.setTransform(m);
|
|
282
|
+
this.list.pushClip(rect, radii.tl);
|
|
283
|
+
this.layer(inflate(bounds(m, rect), reach * k), { opacity: 1, brightness: 1, saturate: 1, blur: sigma * k });
|
|
284
|
+
this.setTransform(m);
|
|
285
|
+
this.list.fillPath(verbs, "evenodd", { kind: "solid", color: premul(sh.color, alpha) });
|
|
286
|
+
this.popLayer();
|
|
287
|
+
this.setTransform(m);
|
|
288
|
+
this.list.popClip();
|
|
289
|
+
}
|
|
233
290
|
paintOf(layer, r, alpha) {
|
|
234
291
|
if (layer.kind === "color")
|
|
235
292
|
return layer.color.a <= 0 ? null : { kind: "solid", color: premul(layer.color, alpha) };
|
|
@@ -302,6 +359,15 @@ export class Painter {
|
|
|
302
359
|
unclip() {
|
|
303
360
|
this.list.popClip();
|
|
304
361
|
}
|
|
362
|
+
/** Fills a rect of stage pixels with a paint (transitions). */
|
|
363
|
+
fillStage(rect, paint) {
|
|
364
|
+
this.setTransform(this.device);
|
|
365
|
+
this.list.fillRRect(rect, { tl: 0, tr: 0, br: 0, bl: 0 }, paint);
|
|
366
|
+
}
|
|
367
|
+
popMask(invert) {
|
|
368
|
+
this.list.popMask(invert);
|
|
369
|
+
this.current = null;
|
|
370
|
+
}
|
|
305
371
|
popLayer(projective) {
|
|
306
372
|
if (projective)
|
|
307
373
|
this.list.popLayer(projective);
|
|
@@ -332,10 +398,53 @@ export class Painter {
|
|
|
332
398
|
this.setTransform(m);
|
|
333
399
|
this.list.pushClip(inner, Math.max(0, radii.tl - s.border[0].width));
|
|
334
400
|
}
|
|
335
|
-
|
|
401
|
+
if (b.style.perspective !== null) {
|
|
402
|
+
const [fx, fy] = b.style.perspectiveOrigin;
|
|
403
|
+
this.view = { depth: b.style.perspective * scaleOf(m), vanish: apply(m, lb.x + lb.w * fx, lb.y + lb.h * fy) };
|
|
404
|
+
}
|
|
405
|
+
if (b.group?.kind === "mask")
|
|
406
|
+
this.maskGroup(sf, lb, m, cam, hooks, b.group.invert);
|
|
407
|
+
else if (b.group?.kind === "samples")
|
|
408
|
+
this.sampleGroup(sf, lb, m, cam, hooks);
|
|
409
|
+
else
|
|
410
|
+
this.children(sf, lb, m, cam, hooks);
|
|
411
|
+
this.view = null;
|
|
336
412
|
if (clip)
|
|
337
413
|
this.list.popClip();
|
|
338
414
|
}
|
|
415
|
+
/** Motion blur: each sample in its own layer, added into one with its share of the weight. The
|
|
416
|
+
* layers add rather than cover, so a partly transparent trail averages correctly. */
|
|
417
|
+
sampleGroup(sf, lb, m, cam, hooks) {
|
|
418
|
+
const area = union(this.surfaceRect(), inflate(bounds(m, extentOf(lb)), 2));
|
|
419
|
+
const weight = 1 / lb.children.length;
|
|
420
|
+
this.layer(area, { opacity: 1, brightness: 1, saturate: 1, blur: 0 });
|
|
421
|
+
for (const sample of lb.children) {
|
|
422
|
+
this.layer(area, { opacity: weight, brightness: 1, saturate: 1, blur: 0, blend: "plus" });
|
|
423
|
+
this.box(sf, sample, m, cam, hooks);
|
|
424
|
+
this.popLayer();
|
|
425
|
+
}
|
|
426
|
+
this.popLayer();
|
|
427
|
+
}
|
|
428
|
+
/** The content in a layer, then the shape in another, which is multiplied into the first. Both
|
|
429
|
+
* layers span at least the whole frame: anything inside may be transformed beyond its box. */
|
|
430
|
+
maskGroup(sf, lb, m, cam, hooks, invert) {
|
|
431
|
+
const [content, shape] = lb.children;
|
|
432
|
+
if (!content || !shape)
|
|
433
|
+
return;
|
|
434
|
+
const area = union(this.surfaceRect(), inflate(bounds(m, extentOf(lb)), 2));
|
|
435
|
+
const plain = { opacity: 1, brightness: 1, saturate: 1, blur: 0 };
|
|
436
|
+
// Keyed elements inside can't travel out from under the mask.
|
|
437
|
+
const lifted = this.lifted;
|
|
438
|
+
this.lifted = null;
|
|
439
|
+
this.layer(area, plain);
|
|
440
|
+
this.box(sf, content, m, cam, hooks);
|
|
441
|
+
this.layer(area, plain);
|
|
442
|
+
this.box(sf, shape, m, cam, hooks);
|
|
443
|
+
this.list.popMask(invert);
|
|
444
|
+
this.current = null;
|
|
445
|
+
this.popLayer();
|
|
446
|
+
this.lifted = lifted;
|
|
447
|
+
}
|
|
339
448
|
image(lb, m, picture, alpha = 1) {
|
|
340
449
|
const s = lb.box.style;
|
|
341
450
|
const box = { x: lb.x + s.padding[3] + s.border[3].width, y: lb.y + s.padding[0] + s.border[0].width, w: lb.w - s.padding[1] - s.padding[3] - s.border[1].width - s.border[3].width, h: lb.h - s.padding[0] - s.padding[2] - s.border[0].width - s.border[2].width };
|
|
@@ -354,9 +463,17 @@ export class Painter {
|
|
|
354
463
|
const box = { x: lb.x + s.padding[3], y: lb.y + s.padding[0], w: lb.w - s.padding[1] - s.padding[3], h: lb.h - s.padding[0] - s.padding[2] };
|
|
355
464
|
const k = Math.min(box.w / vw, box.h / vh);
|
|
356
465
|
const view = mul(translate(box.x + (box.w - vw * k) / 2, box.y + (box.h - vh * k) / 2), mul({ a: k, b: 0, c: 0, d: k, e: 0, f: 0 }, translate(-vx, -vy)));
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
466
|
+
const full = mul(m, view);
|
|
467
|
+
this.setTransform(full);
|
|
468
|
+
for (const p of lb.box.content.paths) {
|
|
469
|
+
if (p.fill)
|
|
470
|
+
this.list.fillPath(p.verbs, p.rule, { kind: "solid", color: premul(p.fill, alpha) });
|
|
471
|
+
if (!p.stroke)
|
|
472
|
+
continue;
|
|
473
|
+
const outline = strokeOutline(p.verbs, p.stroke, scaleOf(full));
|
|
474
|
+
if (outline.length > 0)
|
|
475
|
+
this.list.fillPath(outline, "nonzero", { kind: "solid", color: premul(p.stroke.color, alpha) });
|
|
476
|
+
}
|
|
360
477
|
}
|
|
361
478
|
/** Paragraph text: glyph runs, Say words (each with its own pose) and the caret. */
|
|
362
479
|
text(sf, lb, m, cam, hooks) {
|
|
@@ -394,6 +511,7 @@ export class Painter {
|
|
|
394
511
|
const layered = pose.opacity < 0.998 || pose.blur > 0.01;
|
|
395
512
|
if (layered)
|
|
396
513
|
this.layer(inflate(bounds(wm, wr), pose.blur * 3 * scaleOf(wm) + wr.h * 0.3), { opacity: pose.opacity, brightness: 1, saturate: 1, blur: pose.blur * scaleOf(wm) });
|
|
514
|
+
this.textShadows(c.runs, glyphs, laid.x, laid.y, wm, wr);
|
|
397
515
|
this.glyphs(glyphs, laid.x, laid.y, wm, color, 1);
|
|
398
516
|
if (layered) {
|
|
399
517
|
this.list.popLayer();
|
|
@@ -402,6 +520,7 @@ export class Painter {
|
|
|
402
520
|
}
|
|
403
521
|
}
|
|
404
522
|
else {
|
|
523
|
+
this.textShadows(c.runs, visible, laid.x, laid.y, m, { x: laid.x, y: laid.y, w: laid.paragraph.width, h: laid.paragraph.height });
|
|
405
524
|
this.glyphs(visible, laid.x, laid.y, m, color, 1);
|
|
406
525
|
}
|
|
407
526
|
// Keyed text moving to a new place inside the scene draws at its travelling position.
|
|
@@ -438,6 +557,31 @@ export class Painter {
|
|
|
438
557
|
return col;
|
|
439
558
|
};
|
|
440
559
|
}
|
|
560
|
+
/** Text shadows, each run's own, the last listed lowest (as in CSS). */
|
|
561
|
+
textShadows(runs, list, ox, oy, m, box) {
|
|
562
|
+
runs.forEach((run, index) => {
|
|
563
|
+
if (run.style.shadows.length === 0)
|
|
564
|
+
return;
|
|
565
|
+
const glyphs = list.filter((g) => g.run === index);
|
|
566
|
+
if (glyphs.length === 0)
|
|
567
|
+
return;
|
|
568
|
+
const size = run.style.fontSize;
|
|
569
|
+
for (const sh of [...run.style.shadows].reverse()) {
|
|
570
|
+
if (sh.color.a <= 0)
|
|
571
|
+
continue;
|
|
572
|
+
const moved = mul(m, translate(sh.x, sh.y));
|
|
573
|
+
const k = scaleOf(moved);
|
|
574
|
+
const sigma = sh.blur / 2;
|
|
575
|
+
const layered = sigma > 0.05;
|
|
576
|
+
// Glyphs reach past their line boxes, so the layer allows for a font size all round.
|
|
577
|
+
if (layered)
|
|
578
|
+
this.layer(inflate(bounds(moved, box), size * k + sigma * 3 * k + 2), { opacity: 1, brightness: 1, saturate: 1, blur: sigma * k });
|
|
579
|
+
this.glyphs(glyphs, ox, oy, moved, () => sh.color, 1);
|
|
580
|
+
if (layered)
|
|
581
|
+
this.popLayer();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
441
585
|
/** Draws glyphs (positions relative to a paragraph origin), batching runs that share a font,
|
|
442
586
|
* size and color. */
|
|
443
587
|
glyphs(list, ox, oy, m, color, alpha) {
|
|
@@ -486,6 +630,27 @@ export class Painter {
|
|
|
486
630
|
return apply(m, x, y);
|
|
487
631
|
}
|
|
488
632
|
}
|
|
633
|
+
/** A rounded rect as path verbs. Corner radii follow the shape as it grows or shrinks by `grow`. */
|
|
634
|
+
function rrectVerbs(r, radii, grow) {
|
|
635
|
+
const fit = Math.min(r.w, r.h) / 2;
|
|
636
|
+
const at = (v) => Math.min(fit, Math.max(0, v + grow));
|
|
637
|
+
const [tl, tr, br, bl] = [at(radii.tl), at(radii.tr), at(radii.br), at(radii.bl)];
|
|
638
|
+
// A quarter circle as one cubic.
|
|
639
|
+
const K = 1 - 0.5522847498;
|
|
640
|
+
const [x0, y0, x1, y1] = [r.x, r.y, r.x + r.w, r.y + r.h];
|
|
641
|
+
return [
|
|
642
|
+
{ kind: "move", x: x0 + tl, y: y0 },
|
|
643
|
+
{ kind: "line", x: x1 - tr, y: y0 },
|
|
644
|
+
{ kind: "cubic", c1x: x1 - tr * K, c1y: y0, c2x: x1, c2y: y0 + tr * K, x: x1, y: y0 + tr },
|
|
645
|
+
{ kind: "line", x: x1, y: y1 - br },
|
|
646
|
+
{ kind: "cubic", c1x: x1, c1y: y1 - br * K, c2x: x1 - br * K, c2y: y1, x: x1 - br, y: y1 },
|
|
647
|
+
{ kind: "line", x: x0 + bl, y: y1 },
|
|
648
|
+
{ kind: "cubic", c1x: x0 + bl * K, c1y: y1, c2x: x0, c2y: y1 - bl * K, x: x0, y: y1 - bl },
|
|
649
|
+
{ kind: "line", x: x0, y: y0 + tl },
|
|
650
|
+
{ kind: "cubic", c1x: x0, c1y: y0 + tl * K, c2x: x0 + tl * K, c2y: y0, x: x0 + tl, y: y0 },
|
|
651
|
+
{ kind: "close" },
|
|
652
|
+
];
|
|
653
|
+
}
|
|
489
654
|
/** An arrow cursor in a unit box, inset by `inset` for the dark inner shape. */
|
|
490
655
|
function arrow(inset) {
|
|
491
656
|
const pts = inset === 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PathVerb } from "../text/font-types.js";
|
|
2
|
+
export type StrokeCap = "butt" | "round" | "square";
|
|
3
|
+
export type StrokeJoin = "miter" | "round" | "bevel";
|
|
4
|
+
export interface StrokeOptions {
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly cap: StrokeCap;
|
|
7
|
+
readonly join: StrokeJoin;
|
|
8
|
+
/** The stretch to draw, as fractions of the outline's whole length. */
|
|
9
|
+
readonly trim: readonly [number, number];
|
|
10
|
+
}
|
|
11
|
+
interface Point {
|
|
12
|
+
readonly x: number;
|
|
13
|
+
readonly y: number;
|
|
14
|
+
/** Inside a flattened curve: the angle here is tiny, not a corner the author drew. */
|
|
15
|
+
readonly smooth: boolean;
|
|
16
|
+
}
|
|
17
|
+
interface Line {
|
|
18
|
+
readonly points: ReadonlyArray<Point>;
|
|
19
|
+
readonly closed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** The path as polylines, one per subpath, with repeated points removed. */
|
|
22
|
+
export declare function flatten(verbs: ReadonlyArray<PathVerb>, tolerance: number): ReadonlyArray<Line>;
|
|
23
|
+
/** The outline of a stroked path as fillable verbs (nonzero rule). `scale` is how many surface
|
|
24
|
+
* pixels one path unit covers: it sets how finely curves and round ends are drawn. */
|
|
25
|
+
export declare function strokeOutline(verbs: ReadonlyArray<PathVerb>, stroke: StrokeOptions, scale: number): ReadonlyArray<PathVerb>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { cos, PI, sin } from "@tongue-core/film/model";
|
|
2
|
+
/** Corners sharper than this (a ratio of miter length to width) are beveled, as in SVG. */
|
|
3
|
+
const MITER_LIMIT = 4;
|
|
4
|
+
const dist = (ax, ay, bx, by) => Math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay));
|
|
5
|
+
/** Straight pieces per curve: enough that none strays more than `tolerance` from the curve. */
|
|
6
|
+
const pieces = (bend, tolerance) => Math.min(96, Math.max(1, Math.ceil(Math.sqrt(bend / tolerance))));
|
|
7
|
+
/** The path as polylines, one per subpath, with repeated points removed. */
|
|
8
|
+
export function flatten(verbs, tolerance) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
let current = [];
|
|
11
|
+
let x = 0, y = 0;
|
|
12
|
+
const add = (px, py, smooth) => {
|
|
13
|
+
const last = current[current.length - 1];
|
|
14
|
+
if (!last || last.x !== px || last.y !== py)
|
|
15
|
+
current.push({ x: px, y: py, smooth });
|
|
16
|
+
x = px;
|
|
17
|
+
y = py;
|
|
18
|
+
};
|
|
19
|
+
const end = (closed) => {
|
|
20
|
+
if (current.length > 0)
|
|
21
|
+
lines.push({ points: current, closed: closed && current.length > 2 });
|
|
22
|
+
current = [];
|
|
23
|
+
};
|
|
24
|
+
for (const v of verbs) {
|
|
25
|
+
switch (v.kind) {
|
|
26
|
+
case "move":
|
|
27
|
+
end(false);
|
|
28
|
+
add(v.x, v.y, false);
|
|
29
|
+
break;
|
|
30
|
+
case "line":
|
|
31
|
+
add(v.x, v.y, false);
|
|
32
|
+
break;
|
|
33
|
+
case "quad": {
|
|
34
|
+
const x0 = x, y0 = y;
|
|
35
|
+
const n = pieces(0.25 * dist(x0 - v.cx, y0 - v.cy, v.cx - v.x, v.cy - v.y), tolerance);
|
|
36
|
+
for (let i = 1; i <= n; i++) {
|
|
37
|
+
const t = i / n, u = 1 - t;
|
|
38
|
+
add(u * u * x0 + 2 * u * t * v.cx + t * t * v.x, u * u * y0 + 2 * u * t * v.cy + t * t * v.y, i < n);
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case "cubic": {
|
|
43
|
+
const x0 = x, y0 = y;
|
|
44
|
+
const bend = 0.75 * Math.max(dist(x0 - v.c1x, y0 - v.c1y, v.c1x - v.c2x, v.c1y - v.c2y), dist(v.c1x - v.c2x, v.c1y - v.c2y, v.c2x - v.x, v.c2y - v.y));
|
|
45
|
+
const n = pieces(bend, tolerance);
|
|
46
|
+
for (let i = 1; i <= n; i++) {
|
|
47
|
+
const t = i / n, u = 1 - t;
|
|
48
|
+
const a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
|
|
49
|
+
add(a * x0 + b * v.c1x + c * v.c2x + d * v.x, a * y0 + b * v.c1y + c * v.c2y + d * v.y, i < n);
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "close": {
|
|
54
|
+
const first = current[0];
|
|
55
|
+
if (first) {
|
|
56
|
+
// Back at the start, the closing line's end is that same point, not a second one.
|
|
57
|
+
const last = current[current.length - 1];
|
|
58
|
+
if (last && last.x === first.x && last.y === first.y && current.length > 1)
|
|
59
|
+
current.pop();
|
|
60
|
+
end(true);
|
|
61
|
+
x = first.x;
|
|
62
|
+
y = first.y;
|
|
63
|
+
add(x, y, false);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// A lone point left by a close, or by a move that nothing followed.
|
|
70
|
+
if (current.length > 1)
|
|
71
|
+
end(false);
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
const lengthOf = (line) => {
|
|
75
|
+
let total = 0;
|
|
76
|
+
const count = line.closed ? line.points.length : line.points.length - 1;
|
|
77
|
+
for (let i = 0; i < count; i++) {
|
|
78
|
+
const a = line.points[i], b = line.points[(i + 1) % line.points.length];
|
|
79
|
+
if (a && b)
|
|
80
|
+
total += dist(a.x, a.y, b.x, b.y);
|
|
81
|
+
}
|
|
82
|
+
return total;
|
|
83
|
+
};
|
|
84
|
+
/** The stretches of `lines` between two distances along their combined length. */
|
|
85
|
+
function trimmed(lines, from, to) {
|
|
86
|
+
const out = [];
|
|
87
|
+
let start = 0;
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const length = lengthOf(line);
|
|
90
|
+
const a = Math.max(from - start, 0), b = Math.min(to - start, length);
|
|
91
|
+
start += length;
|
|
92
|
+
if (b <= a && !(length === 0 && a === 0 && b === 0))
|
|
93
|
+
continue;
|
|
94
|
+
if (a <= 0 && b >= length) {
|
|
95
|
+
out.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const points = [];
|
|
99
|
+
let walked = 0;
|
|
100
|
+
const count = line.closed ? line.points.length : line.points.length - 1;
|
|
101
|
+
for (let i = 0; i < count; i++) {
|
|
102
|
+
const p = line.points[i], q = line.points[(i + 1) % line.points.length];
|
|
103
|
+
if (!p || !q)
|
|
104
|
+
continue;
|
|
105
|
+
const seg = dist(p.x, p.y, q.x, q.y);
|
|
106
|
+
const lo = (a - walked) / seg, hi = (b - walked) / seg;
|
|
107
|
+
if (hi >= 0 && lo <= 1) {
|
|
108
|
+
const t0 = Math.max(lo, 0), t1 = Math.min(hi, 1);
|
|
109
|
+
if (points.length === 0)
|
|
110
|
+
points.push({ x: p.x + (q.x - p.x) * t0, y: p.y + (q.y - p.y) * t0, smooth: false });
|
|
111
|
+
if (t1 > t0)
|
|
112
|
+
points.push({ x: p.x + (q.x - p.x) * t1, y: p.y + (q.y - p.y) * t1, smooth: t1 < 1 ? false : q.smooth });
|
|
113
|
+
}
|
|
114
|
+
walked += seg;
|
|
115
|
+
}
|
|
116
|
+
if (points.length > 1)
|
|
117
|
+
out.push({ points, closed: false });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** A polygon, wound so its signed area is positive, so overlaps add up and never cancel out. */
|
|
122
|
+
function polygon(out, xs, ys) {
|
|
123
|
+
const n = xs.length;
|
|
124
|
+
let area = 0;
|
|
125
|
+
for (let i = 0; i < n; i++) {
|
|
126
|
+
const j = (i + 1) % n;
|
|
127
|
+
area += (xs[i] ?? 0) * (ys[j] ?? 0) - (xs[j] ?? 0) * (ys[i] ?? 0);
|
|
128
|
+
}
|
|
129
|
+
if (area === 0)
|
|
130
|
+
return;
|
|
131
|
+
for (let k = 0; k < n; k++) {
|
|
132
|
+
const i = area > 0 ? k : n - 1 - k;
|
|
133
|
+
out.push({ kind: k === 0 ? "move" : "line", x: xs[i] ?? 0, y: ys[i] ?? 0 });
|
|
134
|
+
}
|
|
135
|
+
out.push({ kind: "close" });
|
|
136
|
+
}
|
|
137
|
+
function disk(out, x, y, r, sides) {
|
|
138
|
+
const xs = [], ys = [];
|
|
139
|
+
for (let i = 0; i < sides; i++) {
|
|
140
|
+
const a = (i / sides) * 2 * PI;
|
|
141
|
+
xs.push(x + r * cos(a));
|
|
142
|
+
ys.push(y + r * sin(a));
|
|
143
|
+
}
|
|
144
|
+
polygon(out, xs, ys);
|
|
145
|
+
}
|
|
146
|
+
function corner(out, p, ax, ay, bx, by, hw, join, sides) {
|
|
147
|
+
// (ax, ay) and (bx, by): unit directions arriving at and leaving p.
|
|
148
|
+
const turn = ax * by - ay * bx, along = ax * bx + ay * by;
|
|
149
|
+
if (turn === 0 && along >= 0)
|
|
150
|
+
return;
|
|
151
|
+
if (join === "round" && !p.smooth) {
|
|
152
|
+
disk(out, p.x, p.y, hw, sides);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// The outer side of the turn: rotate the direction a quarter away from where it is turning.
|
|
156
|
+
const side = turn > 0 ? -1 : 1;
|
|
157
|
+
const nax = -ay * side, nay = ax * side, nbx = -by * side, nby = bx * side;
|
|
158
|
+
const a1 = p.x + nax * hw, a2 = p.y + nay * hw, b1 = p.x + nbx * hw, b2 = p.y + nby * hw;
|
|
159
|
+
if (join === "miter" && !p.smooth) {
|
|
160
|
+
// Miter ratio is 1 / sin(half the angle between the pieces).
|
|
161
|
+
const half = Math.sqrt(Math.max(0, (1 + along) / 2));
|
|
162
|
+
if (half > 0 && 1 / half <= MITER_LIMIT) {
|
|
163
|
+
const mx = nax + nbx, my = nay + nby;
|
|
164
|
+
const ml = Math.sqrt(mx * mx + my * my);
|
|
165
|
+
if (ml > 0) {
|
|
166
|
+
const reach = hw / half;
|
|
167
|
+
polygon(out, [p.x, a1, p.x + (mx / ml) * reach, b1], [p.y, a2, p.y + (my / ml) * reach, b2]);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
polygon(out, [p.x, a1, b1], [p.y, a2, b2]);
|
|
173
|
+
}
|
|
174
|
+
/** The outline of a stroked path as fillable verbs (nonzero rule). `scale` is how many surface
|
|
175
|
+
* pixels one path unit covers: it sets how finely curves and round ends are drawn. */
|
|
176
|
+
export function strokeOutline(verbs, stroke, scale) {
|
|
177
|
+
if (!(stroke.width > 0) || !(scale > 0))
|
|
178
|
+
return [];
|
|
179
|
+
const hw = stroke.width / 2;
|
|
180
|
+
const tolerance = 0.2 / scale;
|
|
181
|
+
const sides = Math.min(64, Math.max(8, Math.ceil(PI * Math.sqrt(hw * scale) * 2)));
|
|
182
|
+
const lines = flatten(verbs, tolerance);
|
|
183
|
+
const total = lines.reduce((sum, l) => sum + lengthOf(l), 0);
|
|
184
|
+
const from = Math.min(Math.max(stroke.trim[0], 0), 1) * total, to = Math.min(Math.max(stroke.trim[1], 0), 1) * total;
|
|
185
|
+
if (to <= from && total > 0)
|
|
186
|
+
return [];
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const line of from <= 0 && to >= total ? lines : trimmed(lines, from, to)) {
|
|
189
|
+
const n = line.points.length;
|
|
190
|
+
const first = line.points[0];
|
|
191
|
+
if (!first)
|
|
192
|
+
continue;
|
|
193
|
+
if (n === 1) {
|
|
194
|
+
if (stroke.cap === "round")
|
|
195
|
+
disk(out, first.x, first.y, hw, sides);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const count = line.closed ? n : n - 1;
|
|
199
|
+
let px = 0, py = 0;
|
|
200
|
+
let startX = 0, startY = 0;
|
|
201
|
+
for (let i = 0; i < count; i++) {
|
|
202
|
+
const a = line.points[i], b = line.points[(i + 1) % n];
|
|
203
|
+
if (!a || !b)
|
|
204
|
+
continue;
|
|
205
|
+
const l = dist(a.x, a.y, b.x, b.y);
|
|
206
|
+
if (l === 0)
|
|
207
|
+
continue;
|
|
208
|
+
const dx = (b.x - a.x) / l, dy = (b.y - a.y) / l;
|
|
209
|
+
const nx = -dy * hw, ny = dx * hw;
|
|
210
|
+
if (i === 0) {
|
|
211
|
+
startX = dx;
|
|
212
|
+
startY = dy;
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
corner(out, a, px, py, dx, dy, hw, stroke.join, sides);
|
|
216
|
+
const openStart = !line.closed && i === 0 && stroke.cap === "square" ? hw : 0;
|
|
217
|
+
const openEnd = !line.closed && i === count - 1 && stroke.cap === "square" ? hw : 0;
|
|
218
|
+
const ax = a.x - dx * openStart, ay = a.y - dy * openStart, bx = b.x + dx * openEnd, by = b.y + dy * openEnd;
|
|
219
|
+
polygon(out, [ax + nx, bx + nx, bx - nx, ax - nx], [ay + ny, by + ny, by - ny, ay - ny]);
|
|
220
|
+
px = dx;
|
|
221
|
+
py = dy;
|
|
222
|
+
}
|
|
223
|
+
if (line.closed)
|
|
224
|
+
corner(out, first, px, py, startX, startY, hw, stroke.join, sides);
|
|
225
|
+
else if (stroke.cap === "round") {
|
|
226
|
+
const last = line.points[n - 1];
|
|
227
|
+
disk(out, first.x, first.y, hw, sides);
|
|
228
|
+
if (last)
|
|
229
|
+
disk(out, last.x, last.y, hw, sides);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
package/dist/raster/raster.d.ts
CHANGED
|
@@ -26,6 +26,9 @@ export declare class Raster implements RasterBackend {
|
|
|
26
26
|
/** Uploads an image as a QOI file. */
|
|
27
27
|
loadImage(id: number, qoi: Uint8Array): void;
|
|
28
28
|
hasImage(id: number): boolean;
|
|
29
|
+
/** The raster's WebAssembly memory. It grows to its high-water mark and stays there; what is freed
|
|
30
|
+
* inside it is used again. */
|
|
31
|
+
get memoryBytes(): number;
|
|
29
32
|
/** Executes a display list into the frame. */
|
|
30
33
|
render(list: Uint8Array): void;
|
|
31
34
|
private copy;
|
package/dist/raster/raster.js
CHANGED
|
@@ -118,6 +118,11 @@ export class Raster {
|
|
|
118
118
|
hasImage(id) {
|
|
119
119
|
return this.x.has_image(id) === 1;
|
|
120
120
|
}
|
|
121
|
+
/** The raster's WebAssembly memory. It grows to its high-water mark and stays there; what is freed
|
|
122
|
+
* inside it is used again. */
|
|
123
|
+
get memoryBytes() {
|
|
124
|
+
return this.x.memory.buffer.byteLength;
|
|
125
|
+
}
|
|
121
126
|
/** Executes a display list into the frame. */
|
|
122
127
|
render(list) {
|
|
123
128
|
const code = this.withBytes(list, (ptr, len) => this.x.render(ptr, len));
|