@appinternalleads/ui 0.4.1 → 0.4.2
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/README.md +92 -0
- package/dist/cjs/motion/typesurface/TypeSurfaceCanvas.js +25 -2
- package/dist/cjs/motion/typesurface/TypeSurfaceCanvas.js.map +1 -1
- package/dist/cjs/motion/typesurface/config.js +21 -1
- package/dist/cjs/motion/typesurface/config.js.map +1 -1
- package/dist/cjs/motion/typesurface/construct.js +875 -0
- package/dist/cjs/motion/typesurface/construct.js.map +1 -0
- package/dist/cjs/motion/typesurface/engine.js +28 -4
- package/dist/cjs/motion/typesurface/engine.js.map +1 -1
- package/dist/cjs/motion/typesurface/index.js +9 -1
- package/dist/cjs/motion/typesurface/index.js.map +1 -1
- package/dist/cjs/motion/typesurface/presets.js +69 -2
- package/dist/cjs/motion/typesurface/presets.js.map +1 -1
- package/dist/cjs/motion/typesurface/renderer.js +81 -15
- package/dist/cjs/motion/typesurface/renderer.js.map +1 -1
- package/dist/cjs/motion/typesurface/shaders.js +23 -1
- package/dist/cjs/motion/typesurface/shaders.js.map +1 -1
- package/dist/cjs/motion/typesurface/surfaces.js +69 -0
- package/dist/cjs/motion/typesurface/surfaces.js.map +1 -1
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.d.ts +11 -1
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.js +25 -2
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.js.map +1 -1
- package/dist/esm/motion/typesurface/config.d.ts +13 -2
- package/dist/esm/motion/typesurface/config.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/config.js +21 -1
- package/dist/esm/motion/typesurface/config.js.map +1 -1
- package/dist/esm/motion/typesurface/construct.d.ts +284 -0
- package/dist/esm/motion/typesurface/construct.d.ts.map +1 -0
- package/dist/esm/motion/typesurface/construct.js +866 -0
- package/dist/esm/motion/typesurface/construct.js.map +1 -0
- package/dist/esm/motion/typesurface/engine.d.ts +8 -0
- package/dist/esm/motion/typesurface/engine.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/engine.js +28 -4
- package/dist/esm/motion/typesurface/engine.js.map +1 -1
- package/dist/esm/motion/typesurface/index.d.ts +7 -3
- package/dist/esm/motion/typesurface/index.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/index.js +6 -2
- package/dist/esm/motion/typesurface/index.js.map +1 -1
- package/dist/esm/motion/typesurface/presets.d.ts +18 -0
- package/dist/esm/motion/typesurface/presets.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/presets.js +68 -3
- package/dist/esm/motion/typesurface/presets.js.map +1 -1
- package/dist/esm/motion/typesurface/renderer.d.ts +10 -0
- package/dist/esm/motion/typesurface/renderer.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/renderer.js +82 -16
- package/dist/esm/motion/typesurface/renderer.js.map +1 -1
- package/dist/esm/motion/typesurface/shaders.d.ts +1 -1
- package/dist/esm/motion/typesurface/shaders.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/shaders.js +23 -1
- package/dist/esm/motion/typesurface/shaders.js.map +1 -1
- package/dist/esm/motion/typesurface/surfaces.d.ts +28 -1
- package/dist/esm/motion/typesurface/surfaces.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/surfaces.js +67 -0
- package/dist/esm/motion/typesurface/surfaces.js.map +1 -1
- package/docs/TYPE-SURFACE.md +183 -0
- package/package.json +4 -2
- package/src/motion/typesurface/TypeSurfaceCanvas.tsx +30 -2
- package/src/motion/typesurface/config.ts +29 -3
- package/src/motion/typesurface/construct.ts +828 -0
- package/src/motion/typesurface/engine.ts +31 -5
- package/src/motion/typesurface/index.ts +7 -3
- package/src/motion/typesurface/presets.ts +75 -3
- package/src/motion/typesurface/renderer.ts +77 -17
- package/src/motion/typesurface/shaders.ts +23 -1
- package/src/motion/typesurface/surfaces.ts +78 -1
- package/tools/ask/dist/components.mjs +1138 -57
- package/tools/ask/dist/neon.js +69 -0
- package/tools/ask/mcp.mjs +90 -5
- package/tools/ask/neon-render.mjs +143 -0
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { DEFAULT_TYPE_SURFACE, TS_FOLD_KEYS, TS_WRAP_KEYS, type TSFold, type TSWrap, type TypeSurfaceConfig } from './config';
|
|
17
17
|
import { glyphAt, morphGap, type TSTextLayout } from './layout';
|
|
18
18
|
import { bend, bendDistance, crumpleCreases, crumpleField, cylinder, roll, rotate, smoothstep, type Bend, type Crease, type Roll, type Vec3 } from './ops';
|
|
19
|
+
import type { ResolvedMaterial } from './construct';
|
|
19
20
|
import type { SurfaceDef } from './surfaces';
|
|
20
21
|
|
|
21
22
|
const DEG = Math.PI / 180;
|
|
@@ -38,7 +39,9 @@ export type TextMesh = {
|
|
|
38
39
|
indexVersion: number;
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
/** A solid's triangles that share one look: the wine bottle (`material: null`), or one part of a constructed object. */
|
|
43
|
+
export type PropRange = { start: number; count: number; material: ResolvedMaterial | null };
|
|
44
|
+
export type PropMesh = { positions: Float32Array; normals: Float32Array; attrs: Float32Array; indices: Uint32Array; vertexCount: number; ranges: PropRange[] };
|
|
42
45
|
|
|
43
46
|
export type Guides = {
|
|
44
47
|
folds: Float32Array[];
|
|
@@ -225,10 +228,16 @@ export function createGeometryEngine(def: SurfaceDef): GeometryEngine {
|
|
|
225
228
|
};
|
|
226
229
|
let textRect = '';
|
|
227
230
|
|
|
228
|
-
|
|
231
|
+
// The solid the sheet wraps: the wine bottle, or a constructed object's parts.
|
|
232
|
+
const bottle = def.prop === 'bottle' ? bottleMesh(def.curveRadius - 0.0025, def.curveRadius)
|
|
233
|
+
: def.solid ? { ...def.solid, attrs: new Float32Array(def.solid.count * 4) } : null;
|
|
229
234
|
const prop: PropMesh | null = bottle
|
|
230
|
-
? {
|
|
235
|
+
? {
|
|
236
|
+
positions: new Float32Array(bottle.count * 3), normals: new Float32Array(bottle.count * 3), attrs: bottle.attrs, indices: bottle.indices, vertexCount: bottle.count,
|
|
237
|
+
ranges: def.solid?.ranges ?? [{ start: 0, count: bottle.indices.length, material: null }],
|
|
238
|
+
}
|
|
231
239
|
: null;
|
|
240
|
+
const solid = !!(def.prop || def.solid);
|
|
232
241
|
|
|
233
242
|
const loose = def.looseSheet
|
|
234
243
|
? { positions: new Float32Array(12), normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), uvs: new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), indices: new Uint32Array([0, 2, 1, 1, 2, 3]) }
|
|
@@ -262,6 +271,12 @@ export function createGeometryEngine(def: SurfaceDef): GeometryEngine {
|
|
|
262
271
|
if (F.roll) roll(p, F.roll);
|
|
263
272
|
if (F.kappa) {
|
|
264
273
|
cylinder(p, F.kappa);
|
|
274
|
+
if (def.radiusAt) {
|
|
275
|
+
// Follow the part's own radius at this height (a taper, a bulge), about the cylinder's axis.
|
|
276
|
+
const k = 1 + (def.radiusAt(p[1]) / def.curveRadius - 1) * F.d.curve, A = 1 / F.kappa;
|
|
277
|
+
p[0] *= k;
|
|
278
|
+
p[2] = (p[2] + A) * k - A;
|
|
279
|
+
}
|
|
265
280
|
if (F.spin) {
|
|
266
281
|
const R = def.curveRadius;
|
|
267
282
|
const zz = p[2] + R, c = Math.cos(F.spin), s = Math.sin(F.spin);
|
|
@@ -313,6 +328,15 @@ export function createGeometryEngine(def: SurfaceDef): GeometryEngine {
|
|
|
313
328
|
};
|
|
314
329
|
|
|
315
330
|
const scratch: Vec3 = [0, 0, 0];
|
|
331
|
+
/** The solid turns with its label, about the wrap axis (a mug's handle comes round with the words). */
|
|
332
|
+
const spinSolid = (p: Vec3, direction = false) => {
|
|
333
|
+
if (!F.spin) return;
|
|
334
|
+
const R = direction ? 0 : def.curveRadius;
|
|
335
|
+
const zz = p[2] + R, c = Math.cos(F.spin), s = Math.sin(F.spin);
|
|
336
|
+
const xx = p[0] * c + zz * s;
|
|
337
|
+
p[2] = -p[0] * s + zz * c - R;
|
|
338
|
+
p[0] = xx;
|
|
339
|
+
};
|
|
316
340
|
|
|
317
341
|
const evaluate: GeometryEngine['evaluate'] = (cfg, timeMs, layout, opts) => {
|
|
318
342
|
const d = cfg.deformation;
|
|
@@ -347,7 +371,7 @@ export function createGeometryEngine(def: SurfaceDef): GeometryEngine {
|
|
|
347
371
|
crumpleA: c > 0 ? c * (0.5 + 0.5 * smoothstep(0.5, 1, c)) : 0, shrink: c > 0 ? shrink : 1, bowl: 0.35 * smoothstep(0.55, 1, c),
|
|
348
372
|
wrinkleA: d.wrinkle > 0 ? d.wrinkle * def.wrinkleDepth * 60 : 0,
|
|
349
373
|
bends, roll: rl, kappa,
|
|
350
|
-
spin:
|
|
374
|
+
spin: solid ? d.curve * cfg.view.motion * 0.5 * Math.sin((2 * Math.PI * timeMs) / 10000) : 0,
|
|
351
375
|
rotA: d.tiltX * 0.6, rotB: -d.tiltY * 0.6, rotC: cfg.view.roll * DEG, shiftX: 0, shiftY: 0,
|
|
352
376
|
creases: field?.creases ?? [],
|
|
353
377
|
};
|
|
@@ -369,10 +393,12 @@ export function createGeometryEngine(def: SurfaceDef): GeometryEngine {
|
|
|
369
393
|
if (prop && bottle) {
|
|
370
394
|
for (let k = 0; k < bottle.count; k++) {
|
|
371
395
|
scratch[0] = bottle.positions[k * 3]!; scratch[1] = bottle.positions[k * 3 + 1]!; scratch[2] = bottle.positions[k * 3 + 2]!;
|
|
396
|
+
spinSolid(scratch);
|
|
372
397
|
rotate(scratch, F.rotA, F.rotB, F.rotC);
|
|
373
398
|
prop.positions.set(scratch, k * 3);
|
|
374
399
|
for (let a = 0; a < 3; a++) { const v = scratch[a]!; if (v < min[a]!) min[a] = v; if (v > max[a]!) max[a] = v; }
|
|
375
400
|
scratch[0] = bottle.normals[k * 3]!; scratch[1] = bottle.normals[k * 3 + 1]!; scratch[2] = bottle.normals[k * 3 + 2]!;
|
|
401
|
+
spinSolid(scratch, true);
|
|
376
402
|
rotate(scratch, F.rotA, F.rotB, F.rotC);
|
|
377
403
|
prop.normals.set(scratch, k * 3);
|
|
378
404
|
}
|
|
@@ -555,7 +581,7 @@ export function createGeometryEngine(def: SurfaceDef): GeometryEngine {
|
|
|
555
581
|
guides = { folds, foldZones: zones, roll: rollLine, textOutline: outline, baselines, anchors: new Float32Array(anchors) };
|
|
556
582
|
}
|
|
557
583
|
|
|
558
|
-
const radius = def.prop === 'bottle' ? 1.45 : 0.5 * Math.hypot(W, def.height) * 1.04;
|
|
584
|
+
const radius = def.frameRadius ?? (def.prop === 'bottle' ? 1.45 : 0.5 * Math.hypot(W, def.height) * 1.04);
|
|
559
585
|
return {
|
|
560
586
|
def, nu, nv, uvs, indices,
|
|
561
587
|
sheet: { positions: pos, normals: nrm, attrs },
|
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
* crumples, rolls and finally prints into the material. `docs/TYPE-SURFACE.md`.
|
|
4
4
|
*
|
|
5
5
|
* text → layout (TextGeometry) → surface pipeline (SurfaceMapping) → deformation → material → renderer
|
|
6
|
+
*
|
|
7
|
+
* The text meets a built-in surface, or an object composed from primitives (`construct.ts`):
|
|
8
|
+
* `describeConstruction()` is the manifest an agent reads to build one.
|
|
6
9
|
*/
|
|
7
10
|
export * from './config';
|
|
8
11
|
export { stageEnvelope, envelopes, configAt, stageLabels, activeStage } from './timeline';
|
|
9
12
|
export { bend, bendDistance, roll, cylinder, crumpleCreases, crumpleField, rotate, smoothstep, type Bend, type Roll, type Crease, type Vec3 } from './ops';
|
|
10
|
-
export
|
|
13
|
+
export * from './construct';
|
|
14
|
+
export { SURFACES, registerSurface, getSurface, hasSurface, listSurfaces, objectSurface, surfaceFor, type SurfaceDef, type SurfaceState, type SurfaceShading, type TableStyle } from './surfaces';
|
|
11
15
|
export { layoutText, estimateMeasurer, morphGap, glyphShift, glyphAt, type TSMeasurer, type TSGlyph, type TSLine, type TSTextLayout } from './layout';
|
|
12
|
-
export { createGeometryEngine, textVertexOnSheet, type GeometryEngine, type SceneGeometry, type TextMesh, type PropMesh, type Guides } from './engine';
|
|
13
|
-
export { TYPE_SURFACE_PRESETS, findTypeSurfacePreset, applySurfaceState, switchSurface, createTypeSurface, type TypeSurfacePreset, type TypeSurfaceShorthand } from './presets';
|
|
16
|
+
export { createGeometryEngine, textVertexOnSheet, type GeometryEngine, type SceneGeometry, type TextMesh, type PropMesh, type PropRange, type Guides } from './engine';
|
|
17
|
+
export { TYPE_SURFACE_PRESETS, findTypeSurfacePreset, applySurfaceState, switchSurface, withObject, fitObjectText, createTypeSurface, type TypeSurfacePreset, type TypeSurfaceShorthand } from './presets';
|
|
14
18
|
export { TS_FONTS, TS_FONT_CATEGORIES, findTypeSurfaceFont, typeSurfaceFontsUrl, type TSFont, type TSFontCategory } from './fonts';
|
|
15
19
|
export { TypeSurfaceRenderer, drawDebugOverlay, cameraFor, type TSDebug } from './renderer';
|
|
16
20
|
export { TypeSurfaceCanvas, type TypeSurfaceCanvasProps } from './TypeSurfaceCanvas';
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { findTypeSurfaceFont } from './fonts';
|
|
9
9
|
import { DEFAULT_TYPE_SURFACE, REST_DEFORMATION, resolveTypeSurface, type TSStage, type TSSurfaceType, type TypeSurfaceConfig, type TypeSurfaceConfigInput } from './config';
|
|
10
|
-
import {
|
|
10
|
+
import { createObject, OBJECT_FRONT_ARC, type TSObjectMaterialId, type TSObjectSpec } from './construct';
|
|
11
|
+
import { estimateMeasurer, layoutText, type TSMeasurer } from './layout';
|
|
12
|
+
import { getSurface, objectSurface, surfaceFor } from './surfaces';
|
|
11
13
|
|
|
12
14
|
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
13
15
|
function merge<T>(base: T, over: unknown): T {
|
|
@@ -22,7 +24,7 @@ function merge<T>(base: T, over: unknown): T {
|
|
|
22
24
|
* change; the text, and how far it has wrapped and morphed, do not.
|
|
23
25
|
*/
|
|
24
26
|
export function applySurfaceState(config: TypeSurfaceConfig, stateId: string): TypeSurfaceConfig {
|
|
25
|
-
const def =
|
|
27
|
+
const def = surfaceFor(config);
|
|
26
28
|
const state = def.states[stateId];
|
|
27
29
|
if (!state) return config;
|
|
28
30
|
const deformation = merge(REST_DEFORMATION, state.deformation);
|
|
@@ -40,7 +42,7 @@ export function applySurfaceState(config: TypeSurfaceConfig, stateId: string): T
|
|
|
40
42
|
*/
|
|
41
43
|
export function switchSurface(config: TypeSurfaceConfig, type: TSSurfaceType | string): TypeSurfaceConfig {
|
|
42
44
|
const def = getSurface(type);
|
|
43
|
-
const next: TypeSurfaceConfig = { ...config, surface: { ...config.surface, type: type as TSSurfaceType, color: null }, material: { ...config.material, ...def.material } };
|
|
45
|
+
const next: TypeSurfaceConfig = { ...config, object: null, surface: { ...config.surface, type: type as TSSurfaceType, color: null }, material: { ...config.material, ...def.material } };
|
|
44
46
|
return applySurfaceState(next, def.defaultState);
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -55,6 +57,49 @@ const on = (type: TSSurfaceType, input: TypeSurfaceConfigInput): TypeSurfaceConf
|
|
|
55
57
|
return resolveTypeSurface({ ...input, surface: { type, state: def.defaultState, seed: 7, color: null, ...input.surface } }, { ...base, deformation: REST_DEFORMATION });
|
|
56
58
|
};
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Put the text on a constructed object: the object replaces the surface, the text wraps the part it
|
|
62
|
+
* names and prints in its own colour (not multiplied into the part's). The words and type are kept.
|
|
63
|
+
*/
|
|
64
|
+
export function withObject(config: TypeSurfaceConfig, object: TSObjectSpec): TypeSurfaceConfig {
|
|
65
|
+
const def = objectSurface(object);
|
|
66
|
+
return {
|
|
67
|
+
...config, object,
|
|
68
|
+
surface: { ...config.surface, type: 'object', state: def.defaultState, color: null },
|
|
69
|
+
typography: { ...config.typography, blendMode: 'normal' },
|
|
70
|
+
placement: { ...config.placement, x: 0.5, y: 0.5, rotation: 0 },
|
|
71
|
+
deformation: { ...merge(REST_DEFORMATION, def.states[def.defaultState]!.deformation), wrap: 1 },
|
|
72
|
+
material: { ...config.material, ...def.material, blend: 1, gloss: 0.2 },
|
|
73
|
+
view: { ...config.view, roll: 0 },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Size the text to the object: as large as fits the part's front face (`OBJECT_FRONT_ARC` of its
|
|
79
|
+
* circumference) and the text area's height, on one block that never wraps. Without an object the
|
|
80
|
+
* config is returned as it is. Pass the canvas measurer in a browser for exact widths.
|
|
81
|
+
*/
|
|
82
|
+
export function fitObjectText(config: TypeSurfaceConfig, measure: TSMeasurer = estimateMeasurer): TypeSurfaceConfig {
|
|
83
|
+
if (!config.object || !config.text.trim()) return config;
|
|
84
|
+
const def = objectSurface(config.object);
|
|
85
|
+
const arc = (def.width / def.curveRadius) * (180 / Math.PI);
|
|
86
|
+
const wide = 1000 * Math.min(1, OBJECT_FRONT_ARC / arc) * 0.95;
|
|
87
|
+
const high = 1000 * (def.height / def.width) * 0.8;
|
|
88
|
+
const at100 = layoutText(config.text, { ...config.typography, fontSize: 100 }, 0, measure);
|
|
89
|
+
const fontSize = Math.max(12, Math.min(400, Math.floor(100 * Math.min(wide / at100.width, high / at100.height))));
|
|
90
|
+
return { ...config, typography: { ...config.typography, fontSize }, placement: { ...config.placement, maxWidth: 0 } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Near-white or near-black, whichever reads on a colour. */
|
|
94
|
+
const inkOn = (hex: string): string => {
|
|
95
|
+
const h = hex.length === 4 ? hex.slice(1).split('').map((c) => c + c).join('') : hex.slice(1);
|
|
96
|
+
const n = parseInt(h, 16);
|
|
97
|
+
return 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255) > 140 ? '#1B1D20' : '#FFFFFF';
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const onObject = (object: TSObjectSpec, input: TypeSurfaceConfigInput): TypeSurfaceConfig =>
|
|
101
|
+
resolveTypeSurface(input, withObject({ ...DEFAULT_TYPE_SURFACE, deformation: REST_DEFORMATION }, object));
|
|
102
|
+
|
|
58
103
|
const ANTON = "'Anton', Impact, sans-serif";
|
|
59
104
|
const PLAYFAIR = "'Playfair Display', Georgia, serif";
|
|
60
105
|
|
|
@@ -167,6 +212,22 @@ export const TYPE_SURFACE_PRESETS: TypeSurfacePreset[] = [
|
|
|
167
212
|
animation: { duration: 8000, stages: [stage('crumple', 0.15, 0.7, 'crush')] },
|
|
168
213
|
}),
|
|
169
214
|
},
|
|
215
|
+
{
|
|
216
|
+
id: 'object-tumbler', name: 'Tumbler · Built from parts', description: 'No tumbler exists in the library: a hollow tapered cylinder, a ring for the rim and a short cylinder for the foot. The name wraps the taper.',
|
|
217
|
+
config: onObject(createObject('tumbler'), {
|
|
218
|
+
text: 'Shekhar', typography: { fontFamily: "'Archivo Black', 'Arial Black', sans-serif", fontSize: 120, letterSpacing: 0, color: '#F4F1EA' },
|
|
219
|
+
view: { motion: 0.8 },
|
|
220
|
+
animation: { duration: 8000, stages: [stage('curve', 0.05, 0.45, 'wrap round'), stage('blend', 0.45, 0.85, 'print')] },
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'object-mug', name: 'Mug · Built from parts', description: 'A hollow cylinder and a tube along a path for the handle, glazed black; the name is printed in white.',
|
|
225
|
+
config: onObject(createObject('mug'), {
|
|
226
|
+
text: 'Tushar', typography: { fontFamily: PLAYFAIR, fontWeight: 900, fontSize: 150, letterSpacing: 0, color: '#FFFFFF' },
|
|
227
|
+
view: { motion: 0.8 },
|
|
228
|
+
animation: { duration: 8000, stages: [stage('curve', 0.05, 0.45, 'wrap round'), stage('blend', 0.45, 0.85, 'print')] },
|
|
229
|
+
}),
|
|
230
|
+
},
|
|
170
231
|
];
|
|
171
232
|
|
|
172
233
|
export function findTypeSurfacePreset(id: string): TypeSurfacePreset | undefined {
|
|
@@ -183,17 +244,28 @@ export type TypeSurfaceShorthand = {
|
|
|
183
244
|
material?: TypeSurfaceConfig['material']['type'];
|
|
184
245
|
/** One of the preset ids to start from. */
|
|
185
246
|
preset?: string;
|
|
247
|
+
/** Instead of a surface: a recipe name (`TS_RECIPES`), or a whole object spec built from primitives. */
|
|
248
|
+
object?: string | TSObjectSpec;
|
|
249
|
+
/** With a recipe name: the object's colour and material. The text is then sized to the object (`fitObjectText`) and, with no `color`, inked to read on it. */
|
|
250
|
+
objectColor?: string;
|
|
251
|
+
objectMaterial?: TSObjectMaterialId;
|
|
186
252
|
};
|
|
187
253
|
|
|
188
254
|
/** Build an experiment from shorthand: preset → surface → state → text, font, colour, material. */
|
|
189
255
|
export function createTypeSurface(s: TypeSurfaceShorthand = {}): TypeSurfaceConfig {
|
|
190
256
|
let cfg = findTypeSurfacePreset(s.preset ?? '')?.config ?? TYPE_SURFACE_PRESETS[0]!.config;
|
|
191
257
|
if (s.surface && s.surface !== cfg.surface.type) cfg = switchSurface(cfg, s.surface);
|
|
258
|
+
if (s.object) cfg = withObject(cfg, typeof s.object === 'string' ? createObject(s.object, { color: s.objectColor, material: s.objectMaterial }) : s.object);
|
|
192
259
|
if (s.state) cfg = applySurfaceState(cfg, s.state);
|
|
193
260
|
if (s.text !== undefined) cfg = { ...cfg, text: s.text };
|
|
194
261
|
const font = s.font ? findTypeSurfaceFont(s.font) : undefined;
|
|
195
262
|
if (font) cfg = { ...cfg, typography: { ...cfg.typography, fontFamily: font.family, fontWeight: font.weights.includes(cfg.typography.fontWeight) ? cfg.typography.fontWeight : font.weights[font.weights.length - 1]! } };
|
|
196
263
|
if (s.color && /^#[0-9a-f]{6}$/i.test(s.color)) cfg = { ...cfg, typography: { ...cfg.typography, color: s.color.toUpperCase() } };
|
|
197
264
|
if (s.material) cfg = { ...cfg, material: { ...cfg.material, type: s.material } };
|
|
265
|
+
if (cfg.object && s.object) {
|
|
266
|
+
// On an object the words start legible: sized to its front face, in an ink that reads on it.
|
|
267
|
+
if (!s.color) cfg = { ...cfg, typography: { ...cfg.typography, color: inkOn(objectSurface(cfg.object).color) } };
|
|
268
|
+
cfg = fitObjectText(cfg);
|
|
269
|
+
}
|
|
198
270
|
return cfg;
|
|
199
271
|
}
|
|
@@ -10,8 +10,9 @@ import { ART_FONTS, paintGrain, paintSurfaceArt } from './art';
|
|
|
10
10
|
import { TS_BLEND_MODES, type TypeSurfaceConfig } from './config';
|
|
11
11
|
import { createGeometryEngine, type GeometryEngine, type SceneGeometry } from './engine';
|
|
12
12
|
import { layoutText, morphGap, glyphShift, type TSTextLayout } from './layout';
|
|
13
|
+
import { findTypeSurfaceFont, typeSurfaceFontsUrl } from './fonts';
|
|
13
14
|
import { DEPTH_FS, DEPTH_VS, SURFACE_FS, SURFACE_VS, TEXT_FS, TEXT_VS } from './shaders';
|
|
14
|
-
import {
|
|
15
|
+
import { surfaceFor, type SurfaceDef } from './surfaces';
|
|
15
16
|
import { canvasMeasurer, cssFont, rasterText } from './text';
|
|
16
17
|
import type { Vec3 } from './ops';
|
|
17
18
|
|
|
@@ -104,11 +105,14 @@ export class TypeSurfaceRenderer {
|
|
|
104
105
|
private shadowFbo!: WebGLFramebuffer;
|
|
105
106
|
private shadowTex!: WebGLTexture;
|
|
106
107
|
private shadowSize = 2048;
|
|
107
|
-
private indexVersions = new Map<string, number>();
|
|
108
|
+
private indexVersions = new Map<string, number | string>();
|
|
108
109
|
private viewProj = m4();
|
|
109
110
|
private fontsVersion = 0;
|
|
110
111
|
private lastGeometry: SceneGeometry | null = null;
|
|
111
112
|
private propUv: Float32Array | null = null;
|
|
113
|
+
/** Bumped when an engine is (re)built, so static buffers (uvs, triangles) are uploaded again. */
|
|
114
|
+
private geometryVersion = 1;
|
|
115
|
+
private uvVersions = new Map<string, number>();
|
|
112
116
|
private onFonts = () => { this.fontsVersion++; };
|
|
113
117
|
|
|
114
118
|
constructor(private canvas: HTMLCanvasElement) {
|
|
@@ -210,7 +214,7 @@ export class TypeSurfaceRenderer {
|
|
|
210
214
|
gl.enableVertexAttribArray(loc);
|
|
211
215
|
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
|
|
212
216
|
}
|
|
213
|
-
private elements(key: string, data: Uint32Array, count: number, version: number) {
|
|
217
|
+
private elements(key: string, data: Uint32Array, count: number, version: number | string) {
|
|
214
218
|
const gl = this.gl;
|
|
215
219
|
if (this.indexVersions.get(key) !== version || !this.bufs.has(key)) {
|
|
216
220
|
this.buffer(key, data, gl.ELEMENT_ARRAY_BUFFER, count);
|
|
@@ -220,17 +224,25 @@ export class TypeSurfaceRenderer {
|
|
|
220
224
|
|
|
221
225
|
private engine(def: SurfaceDef): GeometryEngine {
|
|
222
226
|
let e = this.engines.get(def.id);
|
|
223
|
-
|
|
227
|
+
// The engine is built for one definition: a re-registered surface, or an edited object, gets a new one.
|
|
228
|
+
if (!e || e.def !== def) {
|
|
229
|
+
if (def.solid) for (const [k, v] of this.engines) if (v.def.solid) this.engines.delete(k);
|
|
230
|
+
e = createGeometryEngine(def);
|
|
231
|
+
this.engines.set(def.id, e);
|
|
232
|
+
this.geometryVersion++;
|
|
233
|
+
}
|
|
224
234
|
return e;
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
private artFor(def: SurfaceDef, seed: number, color: string) {
|
|
228
|
-
|
|
238
|
+
// Every constructed object shares one slot, so editing one doesn't pile up textures.
|
|
239
|
+
const slot = def.solid ? 'object' : def.id;
|
|
240
|
+
const key = `${slot}:${def.solid ? `${def.width.toFixed(4)}:${def.height.toFixed(4)}:` : ''}${seed}:${color}:${this.fontsVersion}`;
|
|
229
241
|
let a = this.art.get(key);
|
|
230
242
|
if (!a) {
|
|
231
243
|
const aspect = def.height / def.width;
|
|
232
244
|
const { front, back, inner } = paintSurfaceArt(def.art, seed, aspect, color);
|
|
233
|
-
for (const [k, v] of this.art) if (k.startsWith(`${
|
|
245
|
+
for (const [k, v] of this.art) if (k.startsWith(`${slot}:`)) { this.gl.deleteTexture(v.front); this.gl.deleteTexture(v.back); this.gl.deleteTexture(v.inner); this.art.delete(k); }
|
|
234
246
|
a = { front: this.texture(front, true), back: this.texture(back, true), inner: this.texture(inner, true), aspect };
|
|
235
247
|
this.art.set(key, a);
|
|
236
248
|
}
|
|
@@ -271,6 +283,41 @@ export class TypeSurfaceRenderer {
|
|
|
271
283
|
|
|
272
284
|
get geometry(): SceneGeometry | null { return this.lastGeometry; }
|
|
273
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Resolves once everything `config` draws with has loaded: its text font and the artwork's faces.
|
|
288
|
+
* It waits for stylesheets still loading, and (unless `loadFonts` is false) links the lab's Google
|
|
289
|
+
* Fonts stylesheet when the font is one of `TS_FONTS` and the page has not declared it. Any other
|
|
290
|
+
* undeclared font resolves with its fallback. Render after it.
|
|
291
|
+
*/
|
|
292
|
+
async whenReady(config: TypeSurfaceConfig, loadFonts = true): Promise<void> {
|
|
293
|
+
const fonts = document.fonts;
|
|
294
|
+
if (!fonts?.load) return;
|
|
295
|
+
const font = cssFont(config.typography, 100);
|
|
296
|
+
const load = () => fonts.load(font, config.text || 'A').catch(() => [] as FontFace[]);
|
|
297
|
+
const sheetLoaded = (l: HTMLLinkElement) => (l.sheet ? Promise.resolve() : new Promise<void>((done) => {
|
|
298
|
+
l.addEventListener('load', () => done(), { once: true });
|
|
299
|
+
l.addEventListener('error', () => done(), { once: true });
|
|
300
|
+
}));
|
|
301
|
+
// No face for the font yet: `load` resolves empty, which would pass a fallback off as ready. A
|
|
302
|
+
// stylesheet may still be on its way; failing that, a lab font is fetched from its own stylesheet.
|
|
303
|
+
if ((await load()).length === 0) {
|
|
304
|
+
await Promise.all(Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).map(sheetLoaded));
|
|
305
|
+
if ((await load()).length === 0 && loadFonts && findTypeSurfaceFont(config.typography.fontFamily.split(',')[0]!)) {
|
|
306
|
+
const href = typeSurfaceFontsUrl();
|
|
307
|
+
let link = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).find((l) => l.href === href);
|
|
308
|
+
if (!link) {
|
|
309
|
+
link = document.createElement('link');
|
|
310
|
+
link.rel = 'stylesheet'; link.href = href;
|
|
311
|
+
document.head.appendChild(link);
|
|
312
|
+
}
|
|
313
|
+
await sheetLoaded(link);
|
|
314
|
+
await load();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
await Promise.all(ART_FONTS.map((f) => fonts.load(f).catch(() => undefined)));
|
|
318
|
+
await fonts.ready;
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
/** Draw `config` (already at its timeline moment) at `timeMs`. */
|
|
275
322
|
render(config: TypeSurfaceConfig, timeMs: number, debug: TSDebug = {}): SceneGeometry | null {
|
|
276
323
|
if (!this.ok) return null;
|
|
@@ -280,11 +327,15 @@ export class TypeSurfaceRenderer {
|
|
|
280
327
|
const W = Math.max(2, Math.round(cv.clientWidth * dpr)), H = Math.max(2, Math.round(cv.clientHeight * dpr));
|
|
281
328
|
if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; }
|
|
282
329
|
|
|
283
|
-
const def =
|
|
330
|
+
const def = surfaceFor(config);
|
|
331
|
+
// Buffers are keyed by surface; every constructed object shares one set, re-uploaded when it changes.
|
|
332
|
+
const bk = def.solid ? 'object' : def.id;
|
|
284
333
|
const layout = this.layoutFor(config);
|
|
285
334
|
const g = this.engine(def).evaluate(config, timeMs, layout, { guides: !!(debug.folds || debug.bounds || debug.baseline || debug.controls) });
|
|
286
335
|
this.lastGeometry = g;
|
|
287
|
-
const
|
|
336
|
+
const gv = this.geometryVersion;
|
|
337
|
+
const sheetOn = def.sheet !== 'hidden';
|
|
338
|
+
const art = this.artFor(def, config.surface.seed, def.prop || def.solid ? def.color : config.surface.color ?? def.color);
|
|
288
339
|
const textTex = layout && g.text ? this.textTexture(config, layout) : null;
|
|
289
340
|
|
|
290
341
|
const cam = cameraFor(config, def, g, W / H);
|
|
@@ -331,14 +382,14 @@ export class TypeSurfaceRenderer {
|
|
|
331
382
|
const bindSheet = (positionsKey: string, positions: Float32Array) => {
|
|
332
383
|
this.attr(0, positionsKey, positions, 3);
|
|
333
384
|
this.attrKeep(1, 'sheet.n', 3);
|
|
334
|
-
this.attrKeep(2, `sheet.uv:${
|
|
385
|
+
this.attrKeep(2, `sheet.uv:${bk}`, 2);
|
|
335
386
|
this.attrKeep(3, 'sheet.a', 4);
|
|
336
387
|
gl.disableVertexAttribArray(4);
|
|
337
|
-
this.elements(`sheet.i:${
|
|
388
|
+
this.elements(`sheet.i:${bk}`, g.indices, g.indices.length, gv);
|
|
338
389
|
};
|
|
339
390
|
this.buffer('sheet.n', g.sheet.normals, gl.ARRAY_BUFFER);
|
|
340
391
|
this.buffer('sheet.a', g.sheet.attrs, gl.ARRAY_BUFFER);
|
|
341
|
-
if (
|
|
392
|
+
if (this.uvVersions.get(bk) !== gv) { this.buffer(`sheet.uv:${bk}`, g.uvs, gl.ARRAY_BUFFER); this.uvVersions.set(bk, gv); }
|
|
342
393
|
const bindProp = () => {
|
|
343
394
|
const pr = g.prop!;
|
|
344
395
|
this.attr(0, 'prop.p', pr.positions, 3);
|
|
@@ -347,7 +398,7 @@ export class TypeSurfaceRenderer {
|
|
|
347
398
|
this.attr(2, 'prop.uv', this.propUv, 2);
|
|
348
399
|
this.attr(3, 'prop.a', pr.attrs, 4);
|
|
349
400
|
gl.disableVertexAttribArray(4);
|
|
350
|
-
this.elements(`prop.i:${
|
|
401
|
+
this.elements(`prop.i:${bk}`, pr.indices, pr.indices.length, gv);
|
|
351
402
|
};
|
|
352
403
|
const drawSheetGeom = () => gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0);
|
|
353
404
|
const bindText = () => {
|
|
@@ -357,7 +408,7 @@ export class TypeSurfaceRenderer {
|
|
|
357
408
|
this.attr(2, 'text.uv', t.uvs, 2, t.vertexCount);
|
|
358
409
|
this.attr(3, 'text.a', t.attrs, 4, t.vertexCount);
|
|
359
410
|
this.attr(4, 'text.tuv', t.tuv, 2, t.vertexCount);
|
|
360
|
-
this.elements('text.i', t.indices, t.indexCount, t.indexVersion
|
|
411
|
+
this.elements('text.i', t.indices, t.indexCount, `${def.id}:${gv}:${t.indexVersion}:${t.indexCount}`);
|
|
361
412
|
};
|
|
362
413
|
const bindQuad = (key: string, pos: Float32Array, uvs: Float32Array) => {
|
|
363
414
|
this.attr(0, `${key}.p`, pos, 3);
|
|
@@ -380,7 +431,7 @@ export class TypeSurfaceRenderer {
|
|
|
380
431
|
gl.useProgram(dp.p);
|
|
381
432
|
gl.uniformMatrix4fv(this.loc(dp, 'uVP'), false, lightVP);
|
|
382
433
|
gl.uniform1i(this.loc(dp, 'uIsText'), 0);
|
|
383
|
-
if (!debug.noShadows) { bindSheet('sheet.p', g.sheet.positions); drawSheetGeom(); }
|
|
434
|
+
if (!debug.noShadows && sheetOn) { bindSheet('sheet.p', g.sheet.positions); drawSheetGeom(); }
|
|
384
435
|
if (!debug.noShadows) g.stack.forEach((s, i) => { bindSheet(`stack${i}.p`, s); drawSheetGeom(); });
|
|
385
436
|
if (g.prop && !debug.noShadows) { bindProp(); gl.drawElements(gl.TRIANGLES, g.prop.indices.length, gl.UNSIGNED_INT, 0); }
|
|
386
437
|
if (layout && g.text && textTex && config.material.shadow > 0.02 && !debug.noShadows) {
|
|
@@ -444,13 +495,22 @@ export class TypeSurfaceRenderer {
|
|
|
444
495
|
g.stack.forEach((s, i) => { bindSheet(`stack${i}.p`, s); drawSheetGeom(); });
|
|
445
496
|
gl.uniform1i(this.loc(sp, 'uDebug'), debug.deformation ? 1 : debug.mapping ? 2 : 0);
|
|
446
497
|
tex(art.front, art.back);
|
|
447
|
-
bindSheet('sheet.p', g.sheet.positions);
|
|
448
|
-
drawSheetGeom();
|
|
498
|
+
if (sheetOn) { bindSheet('sheet.p', g.sheet.positions); drawSheetGeom(); }
|
|
449
499
|
if (g.prop) {
|
|
450
500
|
gl.uniform1i(this.loc(sp, 'uMode'), 2);
|
|
451
501
|
gl.uniform1i(this.loc(sp, 'uDebug'), 0);
|
|
452
502
|
bindProp();
|
|
453
|
-
|
|
503
|
+
// One draw per part: each has its own material.
|
|
504
|
+
for (const r of g.prop.ranges) {
|
|
505
|
+
const mt = r.material;
|
|
506
|
+
gl.uniform1i(this.loc(sp, 'uPartOn'), mt ? 1 : 0);
|
|
507
|
+
if (mt) {
|
|
508
|
+
gl.uniform3fv(this.loc(sp, 'uPropColor'), hexRgb(mt.color));
|
|
509
|
+
gl.uniform4f(this.loc(sp, 'uPart'), mt.metalness, mt.roughness, mt.reflection, mt.gain);
|
|
510
|
+
}
|
|
511
|
+
gl.drawElements(gl.TRIANGLES, r.count, gl.UNSIGNED_INT, r.start * 4);
|
|
512
|
+
}
|
|
513
|
+
gl.uniform1i(this.loc(sp, 'uPartOn'), 0);
|
|
454
514
|
gl.uniform1i(this.loc(sp, 'uMode'), 0);
|
|
455
515
|
}
|
|
456
516
|
|
|
@@ -57,6 +57,12 @@ vec3 envAt(vec3 r) {
|
|
|
57
57
|
float strip = smoothstep(0.975, 0.992, dot(normalize(r - ax * along), sd)) * (1.0 - smoothstep(0.4, 0.62, abs(along)));
|
|
58
58
|
return base + vec3(1.0, 0.98, 0.95) * key * 3.2 + vec3(0.95, 0.97, 1.0) * strip * 2.4;
|
|
59
59
|
}
|
|
60
|
+
/** The same studio seen in a rough surface: the lights spread into a broad glow. */
|
|
61
|
+
vec3 envSoft(vec3 r) {
|
|
62
|
+
vec3 base = mix(vec3(0.07, 0.07, 0.075), vec3(0.36, 0.37, 0.39), smoothstep(-0.6, 1.0, r.z));
|
|
63
|
+
float key = smoothstep(0.3, 1.0, dot(r, uLightDir));
|
|
64
|
+
return base + vec3(1.0, 0.98, 0.95) * key * key * 0.9;
|
|
65
|
+
}
|
|
60
66
|
float fresnel(float ndv, float f0) { return f0 + (1.0 - f0) * pow(1.0 - ndv, 5.0); }
|
|
61
67
|
|
|
62
68
|
float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
|
|
@@ -79,7 +85,9 @@ void main() { vW = aPos; vN = aNrm; vUv = aUv; vAttr = aAttr; gl_Position = uVP
|
|
|
79
85
|
|
|
80
86
|
export const SURFACE_FS = COMMON + /* glsl */ `
|
|
81
87
|
in vec3 vW; in vec3 vN; in vec2 vUv; in vec4 vAttr;
|
|
82
|
-
uniform int uMode; // 0 sheet, 1 table, 2 prop (bottle)
|
|
88
|
+
uniform int uMode; // 0 sheet, 1 table, 2 prop (the bottle, or a constructed part)
|
|
89
|
+
uniform int uPartOn; // 1: a constructed part, shaded by uPart; 0: the wine bottle
|
|
90
|
+
uniform vec4 uPart; // metalness, roughness, reflection, albedo gain
|
|
83
91
|
uniform int uShading; // 0 paper, 1 label, 2 film, 3 fabric
|
|
84
92
|
uniform vec3 uPropColor;
|
|
85
93
|
uniform vec3 uCapColor;
|
|
@@ -119,6 +127,20 @@ void main() {
|
|
|
119
127
|
return;
|
|
120
128
|
}
|
|
121
129
|
vec3 V = normalize(uEye - vW);
|
|
130
|
+
if (uMode == 2 && uPartOn == 1) {
|
|
131
|
+
if (!gl_FrontFacing) n = -n;
|
|
132
|
+
float ndv = max(dot(n, V), 0.0);
|
|
133
|
+
vec3 alb = toLin(uPropColor) * uPart.w;
|
|
134
|
+
float metal = uPart.x, rough = uPart.y;
|
|
135
|
+
vec3 body = shade(alb * (1.0 - metal), n, vW, 0.0, uPart.z * (1.0 - rough) * 1.2, rough, 0.0);
|
|
136
|
+
// A dielectric reflects the studio through its Fresnel term; a metal reflects it in its own colour.
|
|
137
|
+
vec3 F = mix(vec3(fresnel(ndv, 0.05)), alb + (1.0 - alb) * pow(1.0 - ndv, 5.0), metal);
|
|
138
|
+
vec3 R = reflect(-V, n);
|
|
139
|
+
vec3 env = mix(envAt(R), envSoft(R), smoothstep(0.08, 0.5, rough));
|
|
140
|
+
col = body + env * F * uPart.z;
|
|
141
|
+
outColor = vec4(toSrgb(tone(col)), 1.0);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
122
144
|
if (uMode == 2) {
|
|
123
145
|
float ndv = max(dot(n, V), 0.0);
|
|
124
146
|
vec3 R = reflect(-V, n);
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* Its `id` becomes a valid `surface.type`, and the renderer paints it with the named `art` (or a
|
|
11
11
|
* plain colour if it has no painter). Pure; no React.
|
|
12
12
|
*/
|
|
13
|
-
import type { TSDeformation, TSMaterial, TSSurfaceType, TSView } from './config';
|
|
13
|
+
import type { TSDeformation, TSMaterial, TSSurfaceType, TSView, TypeSurfaceConfig } from './config';
|
|
14
|
+
import { buildObject, createObject, sanitizeObject, type PartRange, type TSObjectSpec } from './construct';
|
|
14
15
|
import { smoothstep, type Vec3 } from './ops';
|
|
15
16
|
|
|
16
17
|
/** How the substrate itself shades. The renderer maps each to a lighting model. */
|
|
@@ -51,6 +52,17 @@ export type SurfaceDef = {
|
|
|
51
52
|
looseSheet?: boolean;
|
|
52
53
|
/** A solid object the surface is wrapped on. */
|
|
53
54
|
prop?: 'bottle';
|
|
55
|
+
/**
|
|
56
|
+
* A constructed object the surface is wrapped on (`objectSurface`): its mesh, already in the
|
|
57
|
+
* sheet's frame (the wrap axis parallel to y at z = −curveRadius), one triangle range per part.
|
|
58
|
+
*/
|
|
59
|
+
solid?: { positions: Float32Array; normals: Float32Array; indices: Uint32Array; count: number; ranges: PartRange[] };
|
|
60
|
+
/** The wrapped sheet's radius at a height y (world): it follows a tapered or bulged part. */
|
|
61
|
+
radiusAt?: (y: number) => number;
|
|
62
|
+
/** `hidden`: the sheet is not drawn, so the text reads as printed straight onto the object. */
|
|
63
|
+
sheet?: 'visible' | 'hidden';
|
|
64
|
+
/** Radius that frames the object (camera fit); sheets work theirs out from their size. */
|
|
65
|
+
frameRadius?: number;
|
|
54
66
|
/** How much fine wrinkles lift the surface, as a share of its width. */
|
|
55
67
|
wrinkleDepth: number;
|
|
56
68
|
/** The surface's own shape, before the shared operators. `x, y` are sheet units (1 wide). */
|
|
@@ -221,3 +233,68 @@ export function hasSurface(id: string): boolean {
|
|
|
221
233
|
export function listSurfaces(): SurfaceDef[] {
|
|
222
234
|
return [...REGISTRY.values()];
|
|
223
235
|
}
|
|
236
|
+
|
|
237
|
+
/* ---------------- constructed objects ---------------- */
|
|
238
|
+
|
|
239
|
+
/** The gap between a part and the text area wrapped on it: enough depth to keep them apart. */
|
|
240
|
+
const OBJECT_GAP = 0.0025;
|
|
241
|
+
const OBJECTS = new Map<string, SurfaceDef>();
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A constructed object as a surface: the parts become the solid, and a sheet the size of the text
|
|
245
|
+
* area wraps the part that carries the text, following its radius at every height. The sheet is
|
|
246
|
+
* hidden unless the object asks for a paper label, so the words read as printed on the object. An
|
|
247
|
+
* invalid spec is repaired first (`sanitizeObject`), so this always returns something drawable.
|
|
248
|
+
*/
|
|
249
|
+
export function objectSurface(input: TSObjectSpec): SurfaceDef {
|
|
250
|
+
const key = JSON.stringify(input);
|
|
251
|
+
const hit = OBJECTS.get(key);
|
|
252
|
+
if (hit) return hit;
|
|
253
|
+
const spec = sanitizeObject(input).object ?? sanitizeObject(createObject('')).object!;
|
|
254
|
+
const built = buildObject(spec);
|
|
255
|
+
const { host } = built;
|
|
256
|
+
const hostH = host.y1 - host.y0;
|
|
257
|
+
const height = Math.max(0.02, spec.text.height * hostH);
|
|
258
|
+
// Keep the text area on the part.
|
|
259
|
+
const yc = Math.min(host.y1 - height / 2, Math.max(host.y0 + height / 2, host.y0 + spec.text.y * hostH));
|
|
260
|
+
const R = host.radiusAt(yc) + OBJECT_GAP;
|
|
261
|
+
const width = (spec.text.arc * Math.PI / 180) * R;
|
|
262
|
+
// Into the sheet's frame: the text area's centre at the origin, the axis behind it.
|
|
263
|
+
const positions = new Float32Array(built.positions);
|
|
264
|
+
for (let k = 0; k < built.count; k++) { positions[k * 3 + 1] = positions[k * 3 + 1]! - yc; positions[k * 3 + 2] = positions[k * 3 + 2]! - R; }
|
|
265
|
+
const { min, max } = built.bounds;
|
|
266
|
+
const nu = Math.round(Math.min(260, Math.max(96, spec.text.arc * 1.1)));
|
|
267
|
+
const def: SurfaceDef = {
|
|
268
|
+
id: `object:${hashString(key)}`, name: spec.name, blurb: `Built from ${spec.parts.map((p) => p.shape).join(' + ')}.`,
|
|
269
|
+
width, height, grid: [nu, Math.round(Math.min(220, Math.max(24, (nu * height) / width)))],
|
|
270
|
+
art: 'plain', shading: 'label', table: spec.scene.table, backdrop: spec.scene.backdrop,
|
|
271
|
+
color: spec.text.label ?? host.color,
|
|
272
|
+
material: { type: 'print', halftone: 0, grainInfluence: spec.text.label ? 0.25 : 0.05, paperInfluence: spec.text.label ? 0.35 : 0.15 },
|
|
273
|
+
curveRadius: R, curveMode: 'cylinder', wrinkleDepth: 0.004,
|
|
274
|
+
solid: { positions, normals: built.normals, indices: built.indices, count: built.count, ranges: built.ranges },
|
|
275
|
+
radiusAt: (y) => host.radiusAt(y + yc) + OBJECT_GAP,
|
|
276
|
+
sheet: spec.text.label ? 'visible' : 'hidden',
|
|
277
|
+
frameRadius: 0.5 * Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) * 1.06,
|
|
278
|
+
states: {
|
|
279
|
+
wrapped: s('Standing', { curve: 1 }),
|
|
280
|
+
lying: s('Lying on the table', { curve: 1 }, { roll: -28 }),
|
|
281
|
+
turned: s('Turned away', { curve: 1, tiltX: 0.5 }),
|
|
282
|
+
tipped: s('Tipped toward you', { curve: 1, tiltY: -0.7 }),
|
|
283
|
+
},
|
|
284
|
+
defaultState: 'wrapped', camera: { flat: 0, deep: 48 },
|
|
285
|
+
};
|
|
286
|
+
if (OBJECTS.size >= 12) OBJECTS.delete(OBJECTS.keys().next().value!);
|
|
287
|
+
OBJECTS.set(key, def);
|
|
288
|
+
return def;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function hashString(v: string): string {
|
|
292
|
+
let h = 0x811c9dc5;
|
|
293
|
+
for (let i = 0; i < v.length; i++) h = Math.imul(h ^ v.charCodeAt(i), 0x01000193);
|
|
294
|
+
return (h >>> 0).toString(36);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** What a config's text meets: its constructed `object` when it has one, else the surface it names. */
|
|
298
|
+
export function surfaceFor(config: Pick<TypeSurfaceConfig, 'surface' | 'object'>): SurfaceDef {
|
|
299
|
+
return config.object ? objectSurface(config.object) : getSurface(config.surface.type);
|
|
300
|
+
}
|