@expofp/renderer 3.37.1 → 3.39.0

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.
Files changed (71) hide show
  1. package/README.md +34 -0
  2. package/dist/controls/camera-api.d.ts +28 -0
  3. package/dist/controls/camera-api.js +33 -10
  4. package/dist/controls/hooks.js +2 -0
  5. package/dist/core/InstanceUniforms.d.ts +15 -0
  6. package/dist/core/InstanceUniforms.js +32 -0
  7. package/dist/core/batch/BatchedMesh.js +6 -0
  8. package/dist/core/instanced/instanced-material.js +5 -1
  9. package/dist/core/lines/BatchedLines.d.ts +9 -7
  10. package/dist/core/lines/BatchedLines.js +9 -7
  11. package/dist/core/lines/{FrustumCulling.d.ts → FrustumCullingLines.d.ts} +7 -6
  12. package/dist/core/lines/{FrustumCulling.js → FrustumCullingLines.js} +2 -2
  13. package/dist/core/lines/{Raycasting.d.ts → RaycastingLines.d.ts} +7 -6
  14. package/dist/core/lines/{Raycasting.js → RaycastingLines.js} +2 -2
  15. package/dist/core/lines/lines-material.js +10 -1
  16. package/dist/core/sprites/BatchedSprites.d.ts +115 -0
  17. package/dist/core/sprites/BatchedSprites.js +205 -0
  18. package/dist/core/sprites/RaycastingSprites.d.ts +41 -0
  19. package/dist/core/sprites/RaycastingSprites.js +71 -0
  20. package/dist/core/sprites/sprite-material.d.ts +59 -0
  21. package/dist/core/sprites/sprite-material.js +192 -0
  22. package/dist/core/text/BatchedText.d.ts +6 -0
  23. package/dist/core/text/BatchedText.js +6 -0
  24. package/dist/core/text/MemberTexture.js +55 -1
  25. package/dist/core/text/Text.d.ts +16 -1
  26. package/dist/core/text/Text.js +15 -0
  27. package/dist/core/text/batched-text-material.js +4 -2
  28. package/dist/objects/containers/hooks.d.ts +7 -2
  29. package/dist/objects/containers/hooks.js +20 -5
  30. package/dist/objects/containers/mesh-writes.js +3 -3
  31. package/dist/objects/images/ImageInstance.js +111 -43
  32. package/dist/objects/images/Images.d.ts +19 -7
  33. package/dist/objects/images/Images.js +266 -83
  34. package/dist/objects/images/atlas-packer.d.ts +92 -91
  35. package/dist/objects/images/atlas-packer.js +230 -258
  36. package/dist/objects/images/image-container.d.ts +14 -3
  37. package/dist/objects/images/image-container.js +19 -5
  38. package/dist/objects/images/pack-sources.d.ts +190 -0
  39. package/dist/objects/images/pack-sources.js +272 -0
  40. package/dist/objects/images/texture-budget.js +2 -2
  41. package/dist/objects/images/worker/atlas-worker.d.ts +56 -0
  42. package/dist/objects/images/worker/atlas-worker.js +123 -0
  43. package/dist/objects/images/worker/client.d.ts +62 -0
  44. package/dist/objects/images/worker/client.js +249 -0
  45. package/dist/objects/images/worker/index.d.ts +10 -0
  46. package/dist/objects/images/worker/index.js +1 -0
  47. package/dist/objects/layer/Layer.d.ts +7 -6
  48. package/dist/objects/layer/Layer.js +40 -30
  49. package/dist/objects/layer/layer-context.d.ts +41 -0
  50. package/dist/objects/layer/layer-context.js +45 -0
  51. package/dist/objects/materials/ColorMaterial.js +16 -3
  52. package/dist/objects/materials/LineMaterial.js +4 -2
  53. package/dist/objects/materials/TextureMaterial.js +14 -2
  54. package/dist/objects/materials/effects/color-dim.d.ts +1 -10
  55. package/dist/objects/materials/effects/color-dim.js +21 -18
  56. package/dist/objects/materials/effects/effects-context.d.ts +6 -5
  57. package/dist/objects/materials/shared.d.ts +18 -0
  58. package/dist/objects/materials/shared.js +21 -0
  59. package/dist/objects/models/Models.js +1 -8
  60. package/dist/objects/shapes/Shapes.js +2 -9
  61. package/dist/objects/text/TextInstance.js +1 -1
  62. package/dist/objects/text/text-layout.d.ts +13 -4
  63. package/dist/objects/text/text-layout.js +30 -4
  64. package/dist/space/Space.d.ts +15 -0
  65. package/dist/space/Space.js +16 -10
  66. package/dist/types/defs.d.ts +53 -2
  67. package/dist/utils/logging.d.ts +6 -3
  68. package/dist/utils/logging.js +15 -7
  69. package/dist/utils/math.d.ts +11 -0
  70. package/dist/utils/math.js +14 -0
  71. package/package.json +2 -2
@@ -0,0 +1,45 @@
1
+ import { createContext, use } from 'react';
2
+ /**
3
+ * The dim amount a layer draws at, which every material beneath it reads.
4
+ * @returns The nearest provided amount, or `0` with no layer above.
5
+ */
6
+ export function useLayerDim() {
7
+ return use(LayerDimContext);
8
+ }
9
+ /**
10
+ * Whether the current layer draws in the overlay tier.
11
+ * @returns `true` if this layer or an ancestor opted into the overlay tier, `false` otherwise.
12
+ */
13
+ export function useLayerOverlay() {
14
+ return use(LayerOverlayContext);
15
+ }
16
+ /**
17
+ * The dim amount the current layer hands down to its descendants.
18
+ *
19
+ * Context rather than a prop, because the value crosses the whole subtree and concerns none of the
20
+ * components between. A container hands its material nothing about layers, and a material that
21
+ * never asked about them still dims correctly. Threading it as a prop would put a `dim` on every
22
+ * material, every container, and every one of their inner components.
23
+ *
24
+ * Read through {@link useLayerDim} instead of directly. A `LayerNode` provides it, always, never
25
+ * only when it declares a `dim` of its own. See `Layer.tsx` for why.
26
+ *
27
+ * Defaults to `0`, undimmed, which is what "no layer above me" means for a component rendered
28
+ * outside a `Layer` tree. A fourth inherited field belongs in a context and a hook of its own here,
29
+ * not folded into this one.
30
+ */
31
+ export const LayerDimContext = createContext(0);
32
+ /**
33
+ * Whether the current layer hands its overlay tier down to its descendants.
34
+ *
35
+ * Context rather than a prop, for the reason {@link LayerDimContext} is. It is also not a shading
36
+ * term, which is why `overlayParams`, the material parameters this tier unlocks, stays in
37
+ * `objects/materials/shared.ts` instead of moving here.
38
+ *
39
+ * Read through {@link useLayerOverlay} instead of directly. A `LayerNode` provides it, always,
40
+ * never only when it declares an `overlay` of its own. See `Layer.tsx` for why.
41
+ *
42
+ * Defaults to `false`, the plan tier, which is what "no layer above me" means for a component
43
+ * rendered outside a `Layer` tree.
44
+ */
45
+ export const LayerOverlayContext = createContext(false);
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useEffect, useState } from 'react';
3
+ import { createLogger } from '../../utils/logging';
4
+ import { useLayerOverlay } from '../layer/layer-context';
3
5
  import { applyAmbientOcclusion } from './effects/ao';
4
6
  import { useDimPatch } from './effects/color-dim';
5
7
  import { applyContactShadow } from './effects/contact-shadow';
6
8
  import { useSceneEffects } from './effects/effects-context';
7
- import { sharedParams } from './shared';
9
+ import { overlayParams, sharedParams } from './shared';
10
+ const logger = createLogger('color-material');
8
11
  /**
9
12
  * The material a shape batch draws with. It renders as a child of the mesh, so R3F attaches, updates
10
13
  * and disposes it. It is also troika's base material for text, which never asks for `solid`. See
@@ -76,6 +79,16 @@ export function ColorMaterial(props) {
76
79
  // skips the component in silence, so only the running app shows the failure.
77
80
  const color = props.color ?? 'white';
78
81
  const solid = props.solid === true;
82
+ const overlay = useLayerOverlay();
83
+ // Overlay is a contract for planar content. A volume that stops depth testing loses its own self
84
+ // occlusion, because its far faces then paint over its near ones in pack order. Making it work
85
+ // needs a depth clear between the tiers, so a second render pass, which external mode does not
86
+ // own. So a volume ignores the flag completely and says once that it did.
87
+ useEffect(() => {
88
+ if (solid && overlay) {
89
+ logger.warn('overlay has no effect on a solid batch, so it is ignored.');
90
+ }
91
+ }, [solid, overlay]);
79
92
  // Handed to both branches, because a volume dims the way flat content does. The patch runs on
80
93
  // whatever `gl_FragColor` the material arrived at, which for a lit material is the shaded color.
81
94
  const dimPatch = useDimPatch();
@@ -107,5 +120,5 @@ export function ColorMaterial(props) {
107
120
  const translucent = props.translucent ?? false;
108
121
  return (_jsx("meshLambertMaterial", { ...sharedParams, color: color, depthWrite: true, polygonOffset: false, transparent: translucent, forceSinglePass: !translucent, onBeforeCompile: onBeforeCompile }));
109
122
  }
110
- return _jsx("meshBasicMaterial", { ...sharedParams, color: color, onBeforeCompile: onBeforeCompile });
123
+ return (_jsx("meshBasicMaterial", { ...sharedParams, ...(overlay ? overlayParams : null), color: color, onBeforeCompile: onBeforeCompile }));
111
124
  }
@@ -2,8 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { extend } from '@react-three/fiber';
3
3
  import { NotEqualStencilFunc, ReplaceStencilOp, } from 'three';
4
4
  import { LineMaterial as LineMaterialBase } from 'three/addons/lines/LineMaterial.js';
5
+ import { useLayerOverlay } from '../layer/layer-context';
5
6
  import { useDimPatch } from './effects/color-dim';
6
- import { sharedParams } from './shared';
7
+ import { overlayParams, sharedParams } from './shared';
7
8
  /**
8
9
  * The stencil value a line layer stamps on every pixel it covers.
9
10
  *
@@ -55,6 +56,7 @@ export function LineMaterial(props) {
55
56
  const color = props.color ?? 'white';
56
57
  const width = props.width ?? 1;
57
58
  const onBeforeCompile = useDimPatch();
59
+ const overlay = useLayerOverlay();
58
60
  /**
59
61
  * Hands the batch about to draw a clean stencil to stamp.
60
62
  *
@@ -68,5 +70,5 @@ export function LineMaterial(props) {
68
70
  function onBeforeRender(renderer) {
69
71
  renderer.clearStencil();
70
72
  }
71
- return (_jsx(LineMaterialElement, { ...sharedParams, color: color, linewidth: width, stencilWrite: true, stencilFunc: NotEqualStencilFunc, stencilZPass: ReplaceStencilOp, stencilRef: LINE_STENCIL_REF, onBeforeRender: onBeforeRender, onBeforeCompile: onBeforeCompile }));
73
+ return (_jsx(LineMaterialElement, { ...sharedParams, ...(overlay ? overlayParams : null), color: color, linewidth: width, stencilWrite: true, stencilFunc: NotEqualStencilFunc, stencilZPass: ReplaceStencilOp, stencilRef: LINE_STENCIL_REF, onBeforeRender: onBeforeRender, onBeforeCompile: onBeforeCompile }));
72
74
  }
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { OneFactor } from 'three';
3
+ import { useLayerOverlay } from '../layer/layer-context';
2
4
  import { useDimPatch } from './effects/color-dim';
3
- import { sharedParams } from './shared';
5
+ import { overlayParams, sharedParams } from './shared';
4
6
  /**
5
7
  * The material used to render a texture, optionally through a per-instance uv-offset uniform so an
6
8
  * instanced/batched mesh can pack many regions into one atlas.
@@ -15,6 +17,7 @@ import { sharedParams } from './shared';
15
17
  export function TextureMaterial(props) {
16
18
  const { map, uvOffset } = props;
17
19
  const applyDim = useDimPatch();
20
+ const overlay = useLayerOverlay();
18
21
  function onBeforeCompile(parameters) {
19
22
  applyDim(parameters);
20
23
  if (!uvOffset)
@@ -25,5 +28,14 @@ export function TextureMaterial(props) {
25
28
  vMapUv = uv * ${uvOffset}.zw + ${uvOffset}.xy;
26
29
  `.trim());
27
30
  }
28
- return _jsx("meshBasicMaterial", { ...sharedParams, map: map, onBeforeCompile: onBeforeCompile });
31
+ // An atlas texture is premultiplied (see `makeAtlasTexture`'s `premultiplyAlpha` comment), so
32
+ // this material needs the premultiplied blend, not `sharedParams`'s straight-alpha one.
33
+ // `blendSrc: OneFactor` is the only change: the source term becomes `1 * (c * a)`, which already
34
+ // equals the straight blend's own `c * a` source term, so the composited color comes out the
35
+ // same either way. `blendDst` stays `OneMinusSrcAlphaFactor` from `sharedParams`, since that half
36
+ // does not depend on how the source is stored.
37
+ //
38
+ // The override lives here, not in `sharedParams`, because `ColorMaterial` and `LineMaterial`
39
+ // share that object and their colors are never premultiplied.
40
+ return (_jsx("meshBasicMaterial", { ...sharedParams, ...(overlay ? overlayParams : null), blendSrc: OneFactor, map: map, onBeforeCompile: onBeforeCompile }));
29
41
  }
@@ -8,7 +8,7 @@ import { type RenderableDef } from '../../../types/defs';
8
8
  */
9
9
  export type DimPatch = (parameters: WebGLProgramParametersWithUniforms) => void;
10
10
  /**
11
- * The dim patch every material installs, reading the layer amount from {@link LayerDimContext}.
11
+ * The dim patch every material installs, reading the layer amount from {@link useLayerDim}.
12
12
  *
13
13
  * Dimming is deliberately **not** a material prop. It cuts across every material and every def type,
14
14
  * so threading it as a prop would put a `dim` on each material, each container, and each of their
@@ -86,15 +86,6 @@ export declare function instanceDim(dim: number | undefined): number;
86
86
  * @returns The amount, within `[0, 1]`.
87
87
  */
88
88
  export declare function clampDim(dim: number): number;
89
- /**
90
- * The dim amount a layer draws at, which every material beneath it reads.
91
- *
92
- * Context rather than a prop because the value crosses the whole subtree and concerns none of the
93
- * components between: a container hands its material nothing, and a material that never asked about
94
- * layers still dims correctly. `Layer` provides it, and only when it declares a `dim` of its own —
95
- * a layer without one is transparent to this, which is exactly what inheritance means.
96
- */
97
- export declare const LayerDimContext: import("react").Context<number>;
98
89
  /**
99
90
  * The per-instance uniform a def's own dim is written to. Declared by every container that draws
100
91
  * dimmable defs and read by {@link DIM_APPLY}; the material owns the name, the mesh only has to
@@ -1,8 +1,9 @@
1
1
  import { useThree } from '@react-three/fiber';
2
- import { createContext, use, useEffect, useRef, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { declareBeforeMain, sceneColorAnchor } from '../../../core/shader-source';
4
+ import { useLayerDim } from '../../layer/layer-context';
4
5
  /**
5
- * The dim patch every material installs, reading the layer amount from {@link LayerDimContext}.
6
+ * The dim patch every material installs, reading the layer amount from {@link useLayerDim}.
6
7
  *
7
8
  * Dimming is deliberately **not** a material prop. It cuts across every material and every def type,
8
9
  * so threading it as a prop would put a `dim` on each material, each container, and each of their
@@ -15,7 +16,7 @@ import { declareBeforeMain, sceneColorAnchor } from '../../../core/shader-source
15
16
  * @returns The patch to call from the material's `onBeforeCompile`.
16
17
  */
17
18
  export function useDimPatch() {
18
- const dim = use(LayerDimContext);
19
+ const dim = useLayerDim();
19
20
  const invalidate = useThree((state) => state.invalidate);
20
21
  // A ref, not state: this object is written after render, every frame of an eased dim, and a ref is
21
22
  // the container React sanctions that for. Its identity is what three holds onto, so the value can
@@ -125,15 +126,6 @@ export function instanceDim(dim) {
125
126
  export function clampDim(dim) {
126
127
  return Math.min(Math.max(dim, 0), 1);
127
128
  }
128
- /**
129
- * The dim amount a layer draws at, which every material beneath it reads.
130
- *
131
- * Context rather than a prop because the value crosses the whole subtree and concerns none of the
132
- * components between: a container hands its material nothing, and a material that never asked about
133
- * layers still dims correctly. `Layer` provides it, and only when it declares a `dim` of its own —
134
- * a layer without one is transparent to this, which is exactly what inheritance means.
135
- */
136
- export const LayerDimContext = createContext(0);
137
129
  /**
138
130
  * The per-instance uniform a def's own dim is written to. Declared by every container that draws
139
131
  * dimmable defs and read by {@link DIM_APPLY}; the material owns the name, the mesh only has to
@@ -166,14 +158,25 @@ const DIM_FUNCTION = 'vec4 dimColor(';
166
158
  /**
167
159
  * Dimming's file-scope declarations.
168
160
  *
169
- * Dim is a desaturation toward gray, not a darkening — which is why it cannot be baked into a
161
+ * Dim is a desaturation toward gray, not a darkening. That is why it cannot be baked into a
170
162
  * per-instance tint: a texel sampled per fragment has no tint to bake into.
171
163
  *
172
- * `dimColor` takes straight (non-premultiplied) `gl_FragColor` and returns it the same way. The
173
- * imperative renderer divided rgb by alpha here and multiplied it back at the end; the round-trip
174
- * cancels — `mix(c, dot(w, c) / k, t)` is linear and homogeneous in `c`, so `f(c / a) * a == f(c)`
175
- * for every alpha — and it was the one thing that could produce a NaN, at `a == 0`, which SDF text
176
- * edges generate in quantity. Dropping it changes no pixel.
164
+ * `dimColor` works on `gl_FragColor` whichever way it is stored, and hands it back stored the same
165
+ * way. It only ever scales `col.rgb`: `mix(c, dot(w, c) / k, t)` is linear and homogeneous in `c`,
166
+ * so scaling the input by any constant scales the output by that same constant. That homogeneity
167
+ * is also why a premultiplied, `SRGBColorSpace`-tagged atlas texture dims to the same composited
168
+ * pixels as the straight-alpha texture this renderer used before it. Measuring old against new
169
+ * leaves a small residual: at most about 8 of 255 on one channel, across every alpha and dim
170
+ * amount tested.
171
+ *
172
+ * `stories/objects/layer/Dim.stories.tsx` does not cover any of this. Its image layer draws
173
+ * `markerCanvas(tint)`, whose whole surface is opaque, so no pixel in that story ever carries a
174
+ * partial alpha for `dimColor` to distort.
175
+ *
176
+ * The imperative renderer divided rgb by alpha here and multiplied it back at the end. The round
177
+ * trip cancels for the same reason: `f(c / a) * a == f(c)` for every alpha. Dividing by alpha was
178
+ * also the one step that could produce a NaN, at `a == 0`, which SDF text edges generate in
179
+ * quantity. Dropping it changes no pixel.
177
180
  */
178
181
  const DIM_DEFS = /* glsl */ `
179
182
  uniform float ${LAYER_DIM};
@@ -25,11 +25,12 @@ import { type ContactShadow } from './contact-shadow';
25
25
  * `contact-shadow.ts` uses for its three uniforms, published the way `SpaceContext` publishes a
26
26
  * `Space` that is refitted in place.
27
27
  *
28
- * **`LayerDimContext` stays where it is and is not merged in.** It carries a single number, it is
29
- * re-provided by every `Layer`, and it nests. This bundle is one object per renderer mount whose
30
- * identity must never change, which is what lets a material capture it in `onBeforeCompile` and a
31
- * frame write `.value` with no recompile. Merging the two would either re-provide this object at
32
- * every layer boundary or flatten dim's nesting.
28
+ * **`LayerDimContext` and `LayerOverlayContext` stay where they are and are not merged in.** Each
29
+ * carries one primitive of a layer's inherited state, is re-provided by every layer, and nests.
30
+ * This bundle is one object per renderer mount whose identity must never change, which is what
31
+ * lets a material capture it in `onBeforeCompile` and a frame write `.value` with no recompile.
32
+ * Merging either one in would either re-provide this object at every layer boundary or flatten
33
+ * that nesting.
33
34
  */
34
35
  export type SceneEffects = AmbientOcclusion & ContactShadow;
35
36
  /**
@@ -3,4 +3,22 @@ import { type MaterialParameters } from 'three';
3
3
  * Material parameter defaults, shared between all types
4
4
  */
5
5
  export declare const sharedParams: MaterialParameters;
6
+ /**
7
+ * What an overlay layer's planar materials add on top of {@link sharedParams}.
8
+ *
9
+ * Two obstacles, and render order clears neither.
10
+ *
11
+ * Depth. A solid shape sets `depthWrite: true`, so booth volumes fill the depth buffer. A planar def
12
+ * drawn later is still depth tested against them and loses. Turning the test off leaves order inside
13
+ * the tier as pure painter's order, because nothing in the tier writes depth.
14
+ *
15
+ * Pass. A translucent solid carries `transparent: true`, so it lives in three's transparent list.
16
+ * three draws that list after every opaque object, whatever render order says. An overlay that
17
+ * stayed in the opaque list would lose to a glass roof. So it opts back in. Inside the transparent
18
+ * list `reversePainterSortStable` compares `groupOrder` first, and the overlay tier's value of at
19
+ * least 1 beats every plan-tier value.
20
+ *
21
+ * `forceSinglePass` is not touched, so a double-sided overlay quad still blends once per pixel.
22
+ */
23
+ export declare const overlayParams: MaterialParameters;
6
24
  //# sourceMappingURL=shared.d.ts.map
@@ -49,3 +49,24 @@ export const sharedParams = {
49
49
  polygonOffsetUnits: -FLAT_BIAS_UNITS,
50
50
  forceSinglePass: true,
51
51
  };
52
+ /**
53
+ * What an overlay layer's planar materials add on top of {@link sharedParams}.
54
+ *
55
+ * Two obstacles, and render order clears neither.
56
+ *
57
+ * Depth. A solid shape sets `depthWrite: true`, so booth volumes fill the depth buffer. A planar def
58
+ * drawn later is still depth tested against them and loses. Turning the test off leaves order inside
59
+ * the tier as pure painter's order, because nothing in the tier writes depth.
60
+ *
61
+ * Pass. A translucent solid carries `transparent: true`, so it lives in three's transparent list.
62
+ * three draws that list after every opaque object, whatever render order says. An overlay that
63
+ * stayed in the opaque list would lose to a glass roof. So it opts back in. Inside the transparent
64
+ * list `reversePainterSortStable` compares `groupOrder` first, and the overlay tier's value of at
65
+ * least 1 beats every plan-tier value.
66
+ *
67
+ * `forceSinglePass` is not touched, so a double-sided overlay quad still blends once per pixel.
68
+ */
69
+ export const overlayParams = {
70
+ depthTest: false,
71
+ transparent: true,
72
+ };
@@ -139,15 +139,8 @@ function ModelPartMesh(props) {
139
139
  // instead — but the hit resolution is not: `InstancedMeshImpl.raycast` walks the live slots,
140
140
  // skipping deleted and hidden ones, and raycasts the real merged geometry, so a hit names the
141
141
  // model's actual triangles and the slot that drew them.
142
- const container = useContainer(() => new ModelContainer(asset.extents, defs.length, kind), defs);
142
+ const container = useContainer(() => new ModelContainer(asset.extents, defs.length, kind), defs, onKindMismatch);
143
143
  const occluder = useOccluder();
144
- // Assigned after mount rather than passed to the constructor: the container is built once and
145
- // outlives every render, so it cannot close over a current callback. Nothing can fire before this
146
- // runs — the split that placed these defs was computed from the same opacities they mount with.
147
- useEffect(() => {
148
- container.setKindMismatchHandler(onKindMismatch);
149
- return () => container.setKindMismatchHandler(undefined);
150
- }, [container, onKindMismatch]);
151
144
  // The geometry belongs to the module-level cache and may be drawing in another layer, so it is
152
145
  // mounted as a `primitive` with `dispose={null}` — R3F must not free it on unmount. Sharing it is
153
146
  // safe because `InstancedMeshImpl` never writes to the geometry it is handed: its draw list lives in
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useState } from 'react';
3
3
  import { BatchedMesh } from '../../core/batch/BatchedMesh';
4
4
  import { defKey, useContainer } from '../containers';
5
5
  import { useOccluder } from '../lighting/occluder';
@@ -106,15 +106,8 @@ function ShapeBatch(props) {
106
106
  //
107
107
  // Stickiness costs this nothing: a batch mounts on the render that first fills it, so the defs
108
108
  // sizing the container are a real membership rather than the empty one it may later stand at.
109
- const container = useContainer(() => new ShapeContainer(defs, kind), defs);
109
+ const container = useContainer(() => new ShapeContainer(defs, kind), defs, onKindMismatch);
110
110
  const occluder = useOccluder();
111
- // Assigned after mount rather than passed to the constructor: the container is built once and
112
- // outlives every render, so it cannot close over a current callback. Nothing can fire before this
113
- // runs — the split that placed these defs was computed from the same colors they mount with.
114
- useEffect(() => {
115
- container.setKindMismatchHandler(onKindMismatch);
116
- return () => container.setKindMismatchHandler(undefined);
117
- }, [container, onKindMismatch]);
118
111
  // Declares the incoming membership before React mounts the delta, so a change that outgrows the
119
112
  // mesh resizes each buffer once, to exactly this size, instead of once per claiming instance.
120
113
  // During render on purpose: instances claim in their mount effects, which run before any effect
@@ -77,7 +77,7 @@ function writeLine(mesh, lineSlot, text, line, placed, def) {
77
77
  writeColorAndOpacity(text, line.stroke.color, 'outline');
78
78
  text.outlineWidth = line.fontSize == 0 ? 0 : line.stroke.width / line.fontSize;
79
79
  }
80
- applyPlacement(text, placed, def.bounds);
80
+ applyPlacement(text, placed, def.bounds, def.screenSpace === true);
81
81
  }
82
82
  function writeColorAndOpacity(text, color, field) {
83
83
  const colorFieldName = field == 'fill' ? 'color' : 'outlineColor';
@@ -61,16 +61,25 @@ export interface LaidOutLine {
61
61
  */
62
62
  export declare function layoutText(def: TextDef): LaidOutLine[];
63
63
  /**
64
- * Writes a laid-out line onto its member: the bounds' rotation and position applied to the local
64
+ * Writes a laid-out line onto its member: the bounds rotation and position applied to the local
65
65
  * offset, the line's scale, and its clip rect.
66
66
  *
67
- * The post-transform half of {@link layoutText}. `updateMatrix` is what reports the move to the
68
- * batch — members are not in the scene graph, so nothing else would.
67
+ * The post-transform half of `layoutText`. `updateMatrix` is what reports the move to the batch,
68
+ * because members are not in the scene graph and nothing else would.
69
+ *
70
+ * A screen-space def also gets its sprite anchor. That is the def's bounds center, shared by every
71
+ * line: each line's member sits at its own plan position, and pivoting each about itself would
72
+ * compress the stack under tilt. The anchor carries no elevation, because every line already sits at
73
+ * the def's own elevation on `position`, and `MemberTexture` reads it from there instead.
74
+ * `MemberTexture` then composes the member per draw, so `matrixAutoUpdate` goes off and the
75
+ * position, rotation and scale written here become the inputs that compose reads rather than the
76
+ * matrix itself.
69
77
  * @param text - The member to place.
70
78
  * @param line - The line's local placement.
71
79
  * @param bounds - The def's bounds, supplying the rotation, center and elevation.
80
+ * @param screenSpace - Whether the def faces the camera.
72
81
  */
73
- export declare function applyPlacement(text: TextImpl, line: LaidOutLine, bounds: Rect): void;
82
+ export declare function applyPlacement(text: TextImpl, line: LaidOutLine, bounds: Rect, screenSpace: boolean): void;
74
83
  /**
75
84
  * The height one line's box occupies.
76
85
  *
@@ -54,6 +54,11 @@ export function layoutText(def) {
54
54
  const scaleX = line.fontSize;
55
55
  // Negative: the y flip. Also why the clip rect below comes out correctly ordered — dividing by a
56
56
  // negative scale swaps its min and max back.
57
+ //
58
+ // `TextImpl.orientation` looks like the right tool for this flip, but it only reaches the shader
59
+ // as a uniform. `BatchedTextImpl.raycast` and `isDrawn` pick and cull through `text.matrix` and
60
+ // its geometry's bounds on the CPU, which never read it, so using `orientation` here draws
61
+ // correctly and picks the wrong region.
57
62
  const scaleY = -scaleX;
58
63
  placed.push({
59
64
  anchorX: alignment.horizontal,
@@ -80,16 +85,25 @@ export function layoutText(def) {
80
85
  return placed;
81
86
  }
82
87
  /**
83
- * Writes a laid-out line onto its member: the bounds' rotation and position applied to the local
88
+ * Writes a laid-out line onto its member: the bounds rotation and position applied to the local
84
89
  * offset, the line's scale, and its clip rect.
85
90
  *
86
- * The post-transform half of {@link layoutText}. `updateMatrix` is what reports the move to the
87
- * batch — members are not in the scene graph, so nothing else would.
91
+ * The post-transform half of `layoutText`. `updateMatrix` is what reports the move to the batch,
92
+ * because members are not in the scene graph and nothing else would.
93
+ *
94
+ * A screen-space def also gets its sprite anchor. That is the def's bounds center, shared by every
95
+ * line: each line's member sits at its own plan position, and pivoting each about itself would
96
+ * compress the stack under tilt. The anchor carries no elevation, because every line already sits at
97
+ * the def's own elevation on `position`, and `MemberTexture` reads it from there instead.
98
+ * `MemberTexture` then composes the member per draw, so `matrixAutoUpdate` goes off and the
99
+ * position, rotation and scale written here become the inputs that compose reads rather than the
100
+ * matrix itself.
88
101
  * @param text - The member to place.
89
102
  * @param line - The line's local placement.
90
103
  * @param bounds - The def's bounds, supplying the rotation, center and elevation.
104
+ * @param screenSpace - Whether the def faces the camera.
91
105
  */
92
- export function applyPlacement(text, line, bounds) {
106
+ export function applyPlacement(text, line, bounds, screenSpace) {
93
107
  const { rotation } = bounds;
94
108
  const position = _position
95
109
  .set(line.offsetX, line.offsetY)
@@ -98,6 +112,18 @@ export function applyPlacement(text, line, bounds) {
98
112
  text.position.set(position.x, position.y, bounds.elevation);
99
113
  text.rotation.set(0, 0, rotation);
100
114
  text.scale.set(line.scaleX, line.scaleY, 1);
115
+ if (screenSpace) {
116
+ // Mutated in place rather than replaced, so a per-update write allocates nothing.
117
+ const anchor = text.spriteAnchor ?? new Vector2();
118
+ anchor.set(bounds.center.x, bounds.center.y);
119
+ text.spriteAnchor = anchor;
120
+ // The compose owns the matrix from here. Nothing may recompose it from the three fields above.
121
+ text.matrixAutoUpdate = false;
122
+ }
123
+ else {
124
+ text.spriteAnchor = null;
125
+ text.matrixAutoUpdate = true;
126
+ }
101
127
  text.updateMatrix();
102
128
  // Held as one array for the member's life: `clipRect` is not a syncable property, so this is a
103
129
  // plain per-frame read and mutating it in place avoids a per-update allocation.
@@ -307,4 +307,19 @@ export declare function castRay(raycaster: Raycaster, ndc: NDCPointLike, camera:
307
307
  * would go.
308
308
  */
309
309
  export declare const HANDEDNESS_FLIP: Matrix4;
310
+ /**
311
+ * The plan's y-down convention, mirrored for a consumer that reads y as up.
312
+ *
313
+ * One value, two unrelated roles. In `ImageInstance.tsx` it is the plan-to-view y mirror composed
314
+ * into a screen-space quad's own 2D linear map, so the quad's offset reads correctly once the
315
+ * shader plants it in the view basis. In `MemberTexture.ts` it is the camera's own rest pose
316
+ * relative to the plan. Its job there is to stop that pose being applied twice, once baked into a
317
+ * member's own matrix and once more by the ordinary view transform at draw time. That role does
318
+ * not depend on the sign of a member's own `scale.y`. The requirement is that the billboard matrix
319
+ * equal the flat matrix at the rest pose, and the scale cancels from both sides of that equality.
320
+ *
321
+ * The two roles share a value by coincidence of geometry, not by a shared derivation. Check both
322
+ * call sites before changing this constant for either reason.
323
+ */
324
+ export declare const Y_DOWN_FLIP: Matrix4;
310
325
  //# sourceMappingURL=Space.d.ts.map
@@ -2,7 +2,7 @@ import { Point } from '@expofp/geometry';
2
2
  import { createContext, use } from 'react';
3
3
  import { Matrix4, Plane, Ray, Raycaster, Vector2, Vector3, Vector4, } from 'three';
4
4
  import { createLogger } from '../utils/logging';
5
- import { clamp } from '../utils/math';
5
+ import { axisLength, clamp } from '../utils/math';
6
6
  import { projectsFromFrustum } from './camera-kind';
7
7
  const logger = createLogger('space');
8
8
  /**
@@ -302,15 +302,6 @@ export function castRay(raycaster, ndc, camera) {
302
302
  .sub(raycaster.ray.origin)
303
303
  .normalize();
304
304
  }
305
- /**
306
- * The length one unit along an axis is drawn at.
307
- * @param matrix - The matrix to measure.
308
- * @param axis - Which axis: `0` for x, `1` for y.
309
- * @returns The transformed axis's length.
310
- */
311
- function axisLength(matrix, axis) {
312
- return new Vector3().setFromMatrixColumn(matrix, axis).length();
313
- }
314
305
  /**
315
306
  * The last step of both paths into world space: mirror z.
316
307
  *
@@ -330,6 +321,21 @@ function axisLength(matrix, axis) {
330
321
  * would go.
331
322
  */
332
323
  export const HANDEDNESS_FLIP = new Matrix4().makeScale(1, 1, -1);
324
+ /**
325
+ * The plan's y-down convention, mirrored for a consumer that reads y as up.
326
+ *
327
+ * One value, two unrelated roles. In `ImageInstance.tsx` it is the plan-to-view y mirror composed
328
+ * into a screen-space quad's own 2D linear map, so the quad's offset reads correctly once the
329
+ * shader plants it in the view basis. In `MemberTexture.ts` it is the camera's own rest pose
330
+ * relative to the plan. Its job there is to stop that pose being applied twice, once baked into a
331
+ * member's own matrix and once more by the ordinary view transform at draw time. That role does
332
+ * not depend on the sign of a member's own `scale.y`. The requirement is that the billboard matrix
333
+ * equal the flat matrix at the rest pose, and the scale cancels from both sides of that equality.
334
+ *
335
+ * The two roles share a value by coincidence of geometry, not by a shared derivation. Check both
336
+ * call sites before changing this constant for either reason.
337
+ */
338
+ export const Y_DOWN_FLIP = new Matrix4().makeScale(1, -1, 1);
333
339
  /** The plan's own plane in plan space, which every pointer position is reported on. */
334
340
  const PLAN_PLANE = new Plane(new Vector3(0, 0, 1), 0);
335
341
  /**
@@ -2,8 +2,22 @@ import { type Box, type LineLike, type Rect, type Shape } from '@expofp/geometry
2
2
  import { type LayoutHandlers, type PointerHandlers } from './events';
3
3
  /** A color as a css string or a packed numeric value. */
4
4
  export type ColorInput = string | number;
5
- /** Any image-like source that can be uploaded as a texture. */
6
- export type ImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas;
5
+ /**
6
+ * Any image-like source that the renderer can upload as a texture.
7
+ *
8
+ * A `Blob` is how anything fetched over the network arrives. It is a handle to an immutable byte
9
+ * store that the browser reference-counts. `postMessage` clones the handle, not the bytes. That is
10
+ * what lets atlas packing move to a worker. The renderer decodes the `Blob` itself, off the main
11
+ * thread wherever the environment allows it.
12
+ *
13
+ * `HTMLImageElement` is deliberately absent. Decoding one costs the main thread more than the
14
+ * packing it feeds: at a thousand logos on a throttled CPU, it stalled the page for two seconds.
15
+ * Leaving the member in the union would keep that slow path reachable. Pass a `Blob` instead.
16
+ *
17
+ * There is no `string` member either. The caller keeps all networking, so the renderer never
18
+ * learns about URLs, retries, or CORS.
19
+ */
20
+ export type ImageSource = HTMLCanvasElement | ImageBitmap | OffscreenCanvas | Blob;
7
21
  /** Scene definition */
8
22
  export interface SceneDef {
9
23
  /** Root layer of the scene tree */
@@ -61,6 +75,22 @@ export interface LayerDef<T extends RenderableDef = RenderableDef> extends Share
61
75
  readonly name: string;
62
76
  /** Collection of layer's children. Mixing different types of children is not supported. */
63
77
  children: RenderableDefCollection<T>;
78
+ /**
79
+ * Draws this layer and its subtree above 3D content. Depth testing goes off, and the layer is
80
+ * ordered by the overlay tier rather than by the plan tier.
81
+ *
82
+ * A layer property, and a forced one. Depth mode is per material and render order is per group, so
83
+ * no implementation can vary either per instance. A def-level override could not be honored even
84
+ * if a caller wrote one.
85
+ *
86
+ * **This is for planar content.** A volume that stops depth testing loses its own self occlusion,
87
+ * because its far faces then paint over its near ones in pack order. So a solid shape batch and a
88
+ * model batch ignore this flag. A solid batch also warns once that it did.
89
+ *
90
+ * **A descendant cannot opt back out**: three propagates a group's render order to its whole
91
+ * subtree, so a descendant left in the plan tier would invert the nesting.
92
+ */
93
+ overlay?: boolean;
64
94
  }
65
95
  /** Shape definition. */
66
96
  export interface ShapeDef extends SharedDef {
@@ -91,6 +121,21 @@ export interface ImageDef extends SharedDef {
91
121
  * [0, 0] is the top-left corner, [0.5, 0.5] is the center (default), [1, 1] is the bottom-right corner.
92
122
  */
93
123
  origin?: [number, number];
124
+ /**
125
+ * Faces the camera, pivoting about `bounds.center` at `bounds.elevation`, at the size `bounds.size`
126
+ * gives in **plan units**. That is the same meaning `bounds.size` has for a flat def. A caller that
127
+ * wants a constant on-screen size keeps doing what markers do today, which is an `onScale` rule
128
+ * that rebuilds the bounds from the pixel size.
129
+ *
130
+ * `bounds.rotation` keeps its meaning, which is a spin about the content plane's normal. For a
131
+ * screen-space def that plane is the screen, so there is no separate spin field. An `onRoll` rule
132
+ * still runs, so keep this off where the rotation carries a plan bearing. Such a def would point
133
+ * at one screen direction however the plan is turned.
134
+ *
135
+ * Changing this at runtime is supported. The def moves between the layer's flat mesh and its
136
+ * camera-facing mesh, which never touches the atlas packing.
137
+ */
138
+ screenSpace?: boolean;
94
139
  }
95
140
  /** Text definition */
96
141
  export interface TextDef extends SharedDef {
@@ -109,6 +154,12 @@ export interface TextDef extends SharedDef {
109
154
  padding: [number, number];
110
155
  /** {@link TextAlignment} object describing text's alignment */
111
156
  alignment: TextAlignment;
157
+ /**
158
+ * As `ImageDef.screenSpace`. The block faces the camera and keeps its plan anchor and its
159
+ * plan-unit font sizes. The whole block pivots about `bounds.center`, on one anchor shared by
160
+ * every line, which is what keeps a stack from splaying under tilt.
161
+ */
162
+ screenSpace?: boolean;
112
163
  }
113
164
  /** Single text line definition */
114
165
  export interface TextLineDef {
@@ -12,9 +12,12 @@ export interface Logger {
12
12
  error: Debugger;
13
13
  }
14
14
  /**
15
- * Creates a logger with the given namespace.
16
- * @param namespace - The namespace to use for the logger.
17
- * @returns A logger with the given namespace.
15
+ * Creates a logger for one namespace, nested under the root namespace.
16
+ *
17
+ * Each level prints only if you enable its namespace, for example with
18
+ * `debug.enable('efp:renderer:*')`.
19
+ * @param namespace - The namespace to nest under the root namespace.
20
+ * @returns A logger with four levels, all under that namespace.
18
21
  */
19
22
  export declare function createLogger(namespace: string): Logger;
20
23
  /**