@tongue-core/engine 0.1.0-rc.2 → 0.1.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,7 @@ export const rotation = (degrees) => {
19
19
  return { a: cs, b: sn, c: -sn, d: cs, e: 0, f: 0 };
20
20
  };
21
21
  export const apply = (m, x, y) => ({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
22
- /** Scale, rotate about a point (CSS transform-origin at the box center). */
22
+ /** Scale, rotate about a point (CSS transform-origin). */
23
23
  export function about(cx, cy, scale, rotate) {
24
24
  let m = translate(cx, cy);
25
25
  if (rotate !== 0)
@@ -73,21 +73,35 @@ function mulH(m, n) {
73
73
  const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0, i = 1] = out;
74
74
  return [a, b, c, d, e, f, g, h, i];
75
75
  }
76
- /** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`,
77
- * about the rect's center, in the same coordinate space. */
78
- export function perspective(r, turn, tilt, depth) {
79
- const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
76
+ /** The homography taking a rect's corners (top left, clockwise) to a quad's. */
77
+ function rectMap(r, quad) {
78
+ // Compose: rect → unit square, then unit square → quad.
79
+ const toUnit = [1 / r.w, 0, -r.x / r.w, 0, 1 / r.h, -r.y / r.h, 0, 0, 1];
80
+ return mulH(squareToQuad(quad), toUnit);
81
+ }
82
+ /** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`, in
83
+ * the same coordinate space. It pivots on `pivot` and looks at `vanish`, both by default the
84
+ * rect's center. */
85
+ export function perspective(r, turn, tilt, depth, pivot, vanish) {
86
+ const center = { x: r.x + r.w / 2, y: r.y + r.h / 2 };
87
+ const { x: px, y: py } = pivot ?? center;
88
+ const { x: vx, y: vy } = vanish ?? center;
80
89
  const ty = radians(turn), tx = radians(tilt);
81
90
  const project = (x, y) => {
82
- const lx = x - cx, ly = y - cy;
91
+ const lx = x - px, ly = y - py;
83
92
  // rotateY then rotateX, like `rotateY(turn) rotateX(tilt)`.
84
93
  const x1 = lx * cos(ty), z1 = -lx * sin(ty);
85
94
  const y2 = ly * cos(tx) - z1 * sin(tx), z2 = ly * sin(tx) + z1 * cos(tx);
86
95
  const w = 1 - z2 / depth;
87
- return { x: cx + x1 / w, y: cy + y2 / w };
96
+ return { x: vx + (px + x1 - vx) / w, y: vy + (py + y2 - vy) / w };
88
97
  };
89
- const quad = [project(r.x, r.y), project(r.x + r.w, r.y), project(r.x + r.w, r.y + r.h), project(r.x, r.y + r.h)];
90
- // Compose: rect → unit square, then unit square → quad.
91
- const toUnit = [1 / r.w, 0, -r.x / r.w, 0, 1 / r.h, -r.y / r.h, 0, 0, 1];
92
- return mulH(squareToQuad(quad), toUnit);
98
+ return rectMap(r, [project(r.x, r.y), project(r.x + r.w, r.y), project(r.x + r.w, r.y + r.h), project(r.x, r.y + r.h)]);
99
+ }
100
+ /** The homography putting a rect on a four-sided shape; null when the shape has collapsed to a
101
+ * line or a point, which nothing can be drawn on. */
102
+ export function ontoQuad(r, quad) {
103
+ if (!(r.w > 0) || !(r.h > 0))
104
+ return null;
105
+ const m = rectMap(r, quad);
106
+ return m.every((v) => Number.isFinite(v)) ? m : null;
93
107
  }
@@ -1,6 +1,7 @@
1
+ import type { BlendMode } from "@tongue-core/film/model";
1
2
  import type { PathVerb } from "../text/font-types.js";
2
3
  export declare const MAGIC = 1279544402;
3
- export declare const VERSION = 1;
4
+ export declare const VERSION = 2;
4
5
  export declare const OP_END = 0;
5
6
  export declare const OP_TRANSFORM = 1;
6
7
  export declare const OP_FILL_RRECT = 2;
@@ -13,6 +14,7 @@ export declare const OP_PUSH_CLIP = 8;
13
14
  export declare const OP_POP_CLIP = 9;
14
15
  export declare const OP_PUSH_LAYER = 10;
15
16
  export declare const OP_POP_LAYER = 11;
17
+ export declare const OP_POP_MASK = 12;
16
18
  /** Premultiplied color, each channel 0..1. */
17
19
  export interface Rgba {
18
20
  readonly r: number;
@@ -41,6 +43,17 @@ export type Paint = {
41
43
  readonly rx: number;
42
44
  readonly ry: number;
43
45
  readonly stops: ReadonlyArray<Stop>;
46
+ }
47
+ /** A color through noise. Solid: where the noise is below `threshold`, feathered by `soft`. Band:
48
+ * only near the threshold, within `soft`. */
49
+ | {
50
+ readonly kind: "noise";
51
+ readonly ridged: boolean;
52
+ readonly band: boolean;
53
+ readonly size: number;
54
+ readonly threshold: number;
55
+ readonly soft: number;
56
+ readonly color: Rgba;
44
57
  };
45
58
  /** `x' = a·x + c·y + e`, `y' = b·x + d·y + f`. */
46
59
  export interface Affine {
@@ -65,11 +78,22 @@ export interface Radii {
65
78
  }
66
79
  /** Row-major 3×3 projective map. */
67
80
  export type Homography = readonly [number, number, number, number, number, number, number, number, number];
81
+ /** How a layer lands on its parent. "plus" adds, for averaging samples; it isn't an author's mode. */
82
+ export type LayerBlend = BlendMode | "plus";
68
83
  export interface LayerEffects {
69
84
  readonly opacity: number;
70
85
  readonly brightness: number;
71
86
  readonly saturate: number;
72
87
  readonly blur: number;
88
+ /** Default 1. */
89
+ readonly contrast?: number;
90
+ /** A hue rotation, as the cosine and sine of its angle (the raster has no trigonometry of its own). Default none. */
91
+ readonly hue?: {
92
+ readonly cos: number;
93
+ readonly sin: number;
94
+ };
95
+ /** Default "normal". */
96
+ readonly blend?: LayerBlend;
73
97
  }
74
98
  export interface PlacedGlyph {
75
99
  readonly glyph: number;
@@ -100,6 +124,9 @@ export declare class DisplayList {
100
124
  /** Composites the innermost layer: in place, or through `projective` (from where its pixels
101
125
  * were drawn to where they land). */
102
126
  popLayer(projective?: Homography): void;
127
+ /** Pops the innermost layer as a mask: instead of drawing onto its parent, it multiplies the
128
+ * parent's pixels by its alpha (or, inverted, by one minus its alpha). The parent stays open. */
129
+ popMask(invert: boolean): void;
103
130
  /** Appends END and returns the bytes. The writer is spent afterwards. */
104
131
  finish(): Uint8Array;
105
132
  }
@@ -1,5 +1,5 @@
1
1
  export const MAGIC = 0x4c444c52;
2
- export const VERSION = 1;
2
+ export const VERSION = 2;
3
3
  export const OP_END = 0;
4
4
  export const OP_TRANSFORM = 1;
5
5
  export const OP_FILL_RRECT = 2;
@@ -12,6 +12,7 @@ export const OP_PUSH_CLIP = 8;
12
12
  export const OP_POP_CLIP = 9;
13
13
  export const OP_PUSH_LAYER = 10;
14
14
  export const OP_POP_LAYER = 11;
15
+ export const OP_POP_MASK = 12;
15
16
  const VERB_MOVE = 0;
16
17
  const VERB_LINE = 1;
17
18
  const VERB_QUAD = 2;
@@ -20,6 +21,8 @@ const VERB_CLOSE = 4;
20
21
  const PAINT_SOLID = 0;
21
22
  const PAINT_LINEAR = 1;
22
23
  const PAINT_RADIAL = 2;
24
+ const PAINT_NOISE = 3;
25
+ const BLENDS = { normal: 0, multiply: 1, screen: 2, overlay: 3, "soft-light": 4, "color-dodge": 5, plus: 6 };
23
26
  /** A growable little-endian word buffer. Rejects non-finite numbers early, where the mistake is
24
27
  * easy to trace, instead of letting the raster refuse the whole list. */
25
28
  class Words {
@@ -79,6 +82,14 @@ function writePaint(w, p) {
79
82
  w.f32(v);
80
83
  writeStops(w, p.stops);
81
84
  return;
85
+ case "noise":
86
+ w.u32(PAINT_NOISE);
87
+ w.u32(p.ridged ? 1 : 0);
88
+ w.u32(p.band ? 1 : 0);
89
+ for (const v of [p.size, p.threshold, p.soft])
90
+ w.f32(v);
91
+ writeColor(w, p.color);
92
+ return;
82
93
  }
83
94
  }
84
95
  function writeStops(w, stops) {
@@ -210,8 +221,9 @@ export class DisplayList {
210
221
  }
211
222
  pushLayer(bounds, effects) {
212
223
  const w = this.op(OP_PUSH_LAYER);
213
- for (const v of [bounds.x, bounds.y, bounds.w, bounds.h, effects.opacity, effects.brightness, effects.saturate, effects.blur])
224
+ for (const v of [bounds.x, bounds.y, bounds.w, bounds.h, effects.opacity, effects.brightness, effects.saturate, effects.blur, effects.contrast ?? 1, effects.hue?.cos ?? 1, effects.hue?.sin ?? 0])
214
225
  w.f32(v);
226
+ w.u32(BLENDS[effects.blend ?? "normal"]);
215
227
  this.layers += 1;
216
228
  this.clips.push(0);
217
229
  }
@@ -233,6 +245,18 @@ export class DisplayList {
233
245
  this.layers -= 1;
234
246
  this.clips.pop();
235
247
  }
248
+ /** Pops the innermost layer as a mask: instead of drawing onto its parent, it multiplies the
249
+ * parent's pixels by its alpha (or, inverted, by one minus its alpha). The parent stays open. */
250
+ popMask(invert) {
251
+ if (this.layers < 2)
252
+ throw new Error("display list: popMask needs a mask layer and a layer to mask");
253
+ if ((this.clips.at(-1) ?? 0) > 0)
254
+ throw new Error("display list: popMask with clips still pushed inside the layer");
255
+ const w = this.op(OP_POP_MASK);
256
+ w.u32(invert ? 1 : 0);
257
+ this.layers -= 1;
258
+ this.clips.pop();
259
+ }
236
260
  /** Appends END and returns the bytes. The writer is spent afterwards. */
237
261
  finish() {
238
262
  if (this.layers !== 0)
@@ -91,13 +91,17 @@ const scaleSide = (s, z) => ({ width: s.width * z, color: s.color });
91
91
  const mixSide = (a, b, t) => ({ width: lerp(a.width, b.width, t), color: mixColor(a.color, b.color, t) });
92
92
  /** Corners, borders and shadows blend; a solid background blends, anything else cross-fades. */
93
93
  function mixLook(a, b, t, tone) {
94
- const count = Math.max(a.shadows.length, b.shadows.length);
95
- const clear = { x: 0, y: 0, blur: 0, spread: 0, color: { r: 0, g: 0, b: 0, a: 0 } };
96
- const shadows = Array.from({ length: count }, (_, i) => {
97
- const sa = a.shadows[i] ?? { ...(b.shadows[i] ?? clear), color: { ...(b.shadows[i] ?? clear).color, a: 0 } };
98
- const sb = b.shadows[i] ?? { ...sa, color: { ...sa.color, a: 0 } };
99
- return { x: lerp(sa.x, sb.x, t), y: lerp(sa.y, sb.y, t), blur: lerp(sa.blur, sb.blur, t), spread: lerp(sa.spread, sb.spread, t), color: mixColor(sa.color, sb.color, t) };
100
- });
94
+ // Outer shadows pair with outer ones and inset with inset: one can't become the other.
95
+ const mixed = (inset) => {
96
+ const from = a.shadows.filter((s) => s.inset === inset), to = b.shadows.filter((s) => s.inset === inset);
97
+ const clear = { x: 0, y: 0, blur: 0, spread: 0, color: { r: 0, g: 0, b: 0, a: 0 }, inset };
98
+ return Array.from({ length: Math.max(from.length, to.length) }, (_, i) => {
99
+ const sa = from[i] ?? { ...(to[i] ?? clear), color: { ...(to[i] ?? clear).color, a: 0 } };
100
+ const sb = to[i] ?? { ...sa, color: { ...sa.color, a: 0 } };
101
+ return { x: lerp(sa.x, sb.x, t), y: lerp(sa.y, sb.y, t), blur: lerp(sa.blur, sb.blur, t), spread: lerp(sa.spread, sb.spread, t), color: mixColor(sa.color, sb.color, t), inset };
102
+ });
103
+ };
104
+ const shadows = [...mixed(false), ...mixed(true)];
101
105
  const radii = { tl: lerp(a.radii.tl, b.radii.tl, t), tr: lerp(a.radii.tr, b.radii.tr, t), br: lerp(a.radii.br, b.radii.br, t), bl: lerp(a.radii.bl, b.radii.bl, t) };
102
106
  const border = [mixSide(a.border[0], b.border[0], t), mixSide(a.border[1], b.border[1], t), mixSide(a.border[2], b.border[2], t), mixSide(a.border[3], b.border[3], t)];
103
107
  const fa = a.background.length === 1 ? a.background[0] : undefined;
@@ -2,7 +2,7 @@ import { type FullPose } from "@tongue-core/film/model";
2
2
  import type { LBox } from "../layout/layout.js";
3
3
  import type { BackgroundLayer, Color, Computed, Shadow } from "../style/computed.js";
4
4
  import type { PlacedGlyph as LaidGlyph } from "../text/paragraph.js";
5
- import type { Affine, DisplayList, Homography, Radii, Rect, Rgba } from "./list.js";
5
+ import type { Affine, DisplayList, Homography, LayerEffects, Paint, Radii, Rect, Rgba } from "./list.js";
6
6
  import { type SceneFrame, type ScreenGlyph } from "../evaluate/scene-frame.js";
7
7
  import type { Picture } from "../evaluate/tree.js";
8
8
  export interface Resources {
@@ -42,6 +42,8 @@ export declare class Painter {
42
42
  private lifted;
43
43
  /** Screen → surface for the scene being painted (render scale, plus any whole-scene move). */
44
44
  private screenBase;
45
+ /** The camera the children now being painted share (their parent's `perspective`), in surface pixels. */
46
+ private view;
45
47
  constructor(list: DisplayList, res: Resources, device: Affine);
46
48
  private setTransform;
47
49
  /** The stage → surface transform for a scene (camera, then render scale). */
@@ -49,12 +51,7 @@ export declare class Painter {
49
51
  /** Paints a whole scene; `outer` moves the whole picture in screen space (transitions). */
50
52
  scene(sf: SceneFrame, hooks?: PaintHooks, outer?: Affine): void;
51
53
  surfaceRect(): Rect;
52
- layer(bounds: Rect, effects: {
53
- readonly opacity: number;
54
- readonly brightness: number;
55
- readonly saturate: number;
56
- readonly blur: number;
57
- }): void;
54
+ layer(bounds: Rect, effects: LayerEffects): void;
58
55
  /** Paints children in z order; `childPose` adds a pose per child index (transition presets). */
59
56
  children(sf: SceneFrame, lb: LBox, m: Affine, cam: Affine, hooks: PaintHooks, childPose?: (index: number) => FullPose): void;
60
57
  /** Paints one box and its subtree. `m` maps stage to surface for this box (it includes any
@@ -66,6 +63,9 @@ export declare class Painter {
66
63
  paintWith(sf: SceneFrame, lb: LBox, base: Affine, cam: Affine, hooks: PaintHooks, pose: FullPose, look: Look, part: "full" | "content"): void;
67
64
  decorate(lb: LBox, m: Affine, look: Look, alpha?: number): void;
68
65
  decorateRect(rect: Rect, m: Affine, look: Look, alpha?: number): void;
66
+ /** A shadow cast inside a box: everything outside the box's shape, shifted and blurred, seen
67
+ * through the box. */
68
+ private insetShadow;
69
69
  private paintOf;
70
70
  /** Identical text moving to a new place: each glyph flies from where it was to where it is
71
71
  * going, resizing on the way (so text re-wraps smoothly). Null when the text differs. */
@@ -80,14 +80,25 @@ export declare class Painter {
80
80
  /** Clips what follows to a rectangle of the surface, in surface pixels (wipe transitions). */
81
81
  clipSurface(rect: Rect): void;
82
82
  unclip(): void;
83
+ /** Fills a rect of stage pixels with a paint (transitions). */
84
+ fillStage(rect: Rect, paint: Paint): void;
85
+ popMask(invert: boolean): void;
83
86
  popLayer(projective?: Homography): void;
84
87
  content(sf: SceneFrame, lb: LBox, m: Affine, cam: Affine, hooks: PaintHooks): void;
88
+ /** Motion blur: each sample in its own layer, added into one with its share of the weight. The
89
+ * layers add rather than cover, so a partly transparent trail averages correctly. */
90
+ private sampleGroup;
91
+ /** The content in a layer, then the shape in another, which is multiplied into the first. Both
92
+ * layers span at least the whole frame: anything inside may be transformed beyond its box. */
93
+ private maskGroup;
85
94
  image(lb: LBox, m: Affine, picture: Picture, alpha?: number): void;
86
95
  imageRect(picture: Picture, box: Rect, m: Affine, fit: Computed["objectFit"], pos: readonly [number, number], radius: number, alpha: number): void;
87
96
  svg(lb: LBox, m: Affine, alpha?: number): void;
88
97
  /** Paragraph text: glyph runs, Say words (each with its own pose) and the caret. */
89
98
  private text;
90
99
  textColor(sf: SceneFrame, lb: LBox): ((g: LaidGlyph) => Color);
100
+ /** Text shadows, each run's own, the last listed lowest (as in CSS). */
101
+ private textShadows;
91
102
  /** Draws glyphs (positions relative to a paragraph origin), batching runs that share a font,
92
103
  * size and color. */
93
104
  glyphs(list: ReadonlyArray<LaidGlyph>, ox: number, oy: number, m: Affine, color: (g: LaidGlyph) => Color, alpha: number): void;
@@ -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
- 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));
33
- if (lb.text)
34
- r = union(r, { x: lb.text.x, y: lb.text.y, w: lb.text.paragraph.width, h: lb.text.paragraph.height });
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 local = mul(about(cx, cy, scale, rotate), translate(t.x + pose.x, t.y + pose.y));
171
- const m = mul(base, local);
172
- const flat = turn === 0 && tilt === 0;
173
- const layered = opacity < 0.998 || blur > 0.01 || Math.abs(brightness - 1) > 0.001 || Math.abs(s.saturate - 1) > 0.001 || !flat;
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 (flat)
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
- this.list.popLayer(perspective(box, turn, tilt, 1800 * k));
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
- this.children(sf, lb, m, cam, hooks);
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
- this.setTransform(mul(m, view));
358
- for (const p of lb.box.content.paths)
359
- this.list.fillPath(p.verbs, p.rule, { kind: "solid", color: premul(p.fill, alpha) });
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 {};