@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
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/* eslint-disable comppack/no-raw-colour -- object, material and backdrop colours are scene content, not theme colours. */
|
|
2
|
+
/**
|
|
3
|
+
* **The construction layer** — objects built from parts, not a catalogue of finished objects.
|
|
4
|
+
*
|
|
5
|
+
* primitives cylinder, sphere, ring, revolve (any profile), tube (along a path), box
|
|
6
|
+
* manipulators taper, flare, bulge, twist, bend, scale: applied to a part, in order
|
|
7
|
+
* materials plastic, matte, ceramic, glass, metal, chrome, in any colour
|
|
8
|
+
* text wraps one round part: printed straight onto it, or on a paper label
|
|
9
|
+
* recipes tumbler, mug, can, bottle: compositions of the above, nothing they alone can do
|
|
10
|
+
*
|
|
11
|
+
* An object is JSON (`TSObjectSpec`) and lives in the config as `object`, so what an agent builds
|
|
12
|
+
* is a preset it can save and edit later. `describeConstruction()` is the machine-readable
|
|
13
|
+
* manifest: every primitive, what can be done to it, and every parameter with its range, so an
|
|
14
|
+
* agent composes a tumbler (cylinder + taper + ring) without one having been written for it.
|
|
15
|
+
* `sanitizeObject` reads the same manifest: it never throws and always returns something drawable.
|
|
16
|
+
*
|
|
17
|
+
* Frame: y is up and is the object's axis; x is to the right of the camera, z toward it. Units are
|
|
18
|
+
* the lab's world units (the wine bottle's body is 0.36 in radius); the camera frames whatever is
|
|
19
|
+
* built. Pure; no DOM, no React.
|
|
20
|
+
*/
|
|
21
|
+
import { smoothstep, type Vec3 } from './ops';
|
|
22
|
+
|
|
23
|
+
/* ---------------- the manifest ---------------- */
|
|
24
|
+
|
|
25
|
+
export type TSParam =
|
|
26
|
+
| { type: 'number'; min: number; max: number; default: number; unit?: string; hint: string }
|
|
27
|
+
| { type: 'boolean'; default: boolean; hint: string }
|
|
28
|
+
| { type: 'enum'; options: readonly string[]; default: string; hint: string }
|
|
29
|
+
/** A list of points: `dims` numbers each, at least `min` of them. */
|
|
30
|
+
| { type: 'points'; dims: 2 | 3; min: number; max: number; hint: string };
|
|
31
|
+
|
|
32
|
+
const num = (min: number, max: number, def: number, hint: string, unit?: string): TSParam => ({ type: 'number', min, max, default: def, hint, ...(unit ? { unit } : {}) });
|
|
33
|
+
|
|
34
|
+
export const TS_MANIPULATOR_IDS = ['taper', 'flare', 'bulge', 'twist', 'bend', 'scale'] as const;
|
|
35
|
+
export type TSManipulatorId = (typeof TS_MANIPULATOR_IDS)[number];
|
|
36
|
+
|
|
37
|
+
/** What can be done to a part. Each acts in the part's own frame, before it is turned and placed. */
|
|
38
|
+
export const TS_MANIPULATORS: Record<TSManipulatorId, { summary: string; keepsRound: boolean; params: Record<string, TSParam> }> = {
|
|
39
|
+
taper: {
|
|
40
|
+
summary: 'Scales the part about its axis, from one factor at the bottom to another at the top: a cone, a tapered cup.',
|
|
41
|
+
keepsRound: true,
|
|
42
|
+
params: { bottom: num(0.05, 3, 1, 'radius factor at the bottom of the part'), top: num(0.05, 3, 1, 'radius factor at the top of the part') },
|
|
43
|
+
},
|
|
44
|
+
flare: {
|
|
45
|
+
summary: 'Widens (or, negative, narrows) the part toward one end: a lip, a shoulder, a can’s necked top.',
|
|
46
|
+
keepsRound: true,
|
|
47
|
+
params: {
|
|
48
|
+
amount: num(-0.9, 2, 0.2, 'extra radius at the end, as a share of the radius; negative narrows'),
|
|
49
|
+
start: num(0, 0.98, 0.7, 'where along the part the flare begins, 0–1 toward the end'),
|
|
50
|
+
end: { type: 'enum', options: ['top', 'bottom'], default: 'top', hint: 'which end flares' },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
bulge: {
|
|
54
|
+
summary: 'Swells (or, negative, pinches) the part around a height: a belly, a waist, a grip.',
|
|
55
|
+
keepsRound: true,
|
|
56
|
+
params: {
|
|
57
|
+
amount: num(-0.9, 2, 0.2, 'extra radius at the centre of the bulge; negative pinches'),
|
|
58
|
+
center: num(0, 1, 0.5, 'where along the part, 0 bottom – 1 top'),
|
|
59
|
+
width: num(0.03, 1, 0.25, 'how much of the part it spans'),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
twist: { summary: 'Turns the part about its axis, more with height.', keepsRound: true, params: { angle: num(-720, 720, 90, 'turn reached at the top', '°') } },
|
|
63
|
+
bend: { summary: 'Bends the part’s axis into an arc toward +x: a spout, a curved neck.', keepsRound: false, params: { angle: num(-270, 270, 45, 'the arc the axis turns through', '°') } },
|
|
64
|
+
scale: {
|
|
65
|
+
summary: 'Stretches the part along x, y and z: an oval body, a squashed sphere.',
|
|
66
|
+
keepsRound: false,
|
|
67
|
+
params: { x: num(0.05, 5, 1, 'across'), y: num(0.05, 5, 1, 'along the axis'), z: num(0.05, 5, 1, 'toward the camera') },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const TS_PRIMITIVE_IDS = ['cylinder', 'sphere', 'ring', 'revolve', 'tube', 'box'] as const;
|
|
72
|
+
export type TSPrimitiveId = (typeof TS_PRIMITIVE_IDS)[number];
|
|
73
|
+
/** A manipulator a primitive accepts, plus `material` (every part) and `text-wrap` (it can carry the text). */
|
|
74
|
+
export type TSCapability = TSManipulatorId | 'material' | 'text-wrap';
|
|
75
|
+
|
|
76
|
+
const ALL_OPS: TSCapability[] = [...TS_MANIPULATOR_IDS, 'material'];
|
|
77
|
+
|
|
78
|
+
/** What an object is built from. `origin` says where `at` puts the part. */
|
|
79
|
+
export const TS_PRIMITIVES: Record<TSPrimitiveId, { summary: string; origin: 'base' | 'centre' | 'path'; capabilities: readonly TSCapability[]; params: Record<string, TSParam> }> = {
|
|
80
|
+
cylinder: {
|
|
81
|
+
summary: 'A round body standing on its base. `hollow` makes it a cup with a wall, a floor and a rounded rim.',
|
|
82
|
+
origin: 'base', capabilities: [...ALL_OPS, 'text-wrap'],
|
|
83
|
+
params: {
|
|
84
|
+
radius: num(0.01, 2, 0.36, 'outer radius'),
|
|
85
|
+
height: num(0.005, 4, 1, 'from base to top'),
|
|
86
|
+
hollow: num(0, 0.5, 0, 'wall thickness; 0 is solid, anything else is open at the top'),
|
|
87
|
+
round: num(0, 0.5, 0, 'radius of the rounded top and bottom edges'),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
sphere: { summary: 'A ball, centred.', origin: 'centre', capabilities: [...ALL_OPS, 'text-wrap'], params: { radius: num(0.01, 2, 0.3, 'radius') } },
|
|
91
|
+
ring: {
|
|
92
|
+
summary: 'A torus lying flat around the axis: a rim, a lip, a band, a gasket.',
|
|
93
|
+
origin: 'centre', capabilities: ALL_OPS,
|
|
94
|
+
params: { radius: num(0.01, 2, 0.36, 'from the axis to the centre of the band'), thickness: num(0.002, 0.5, 0.02, 'radius of the band itself') },
|
|
95
|
+
},
|
|
96
|
+
revolve: {
|
|
97
|
+
summary: 'Any profile turned about the axis: a vase, a bottle with shoulders, a lampshade, a bowl. Draw the outline as [radius, y] points; draw down the inside too for a hollow one.',
|
|
98
|
+
origin: 'base', capabilities: [...ALL_OPS, 'text-wrap'],
|
|
99
|
+
params: {
|
|
100
|
+
profile: { type: 'points', dims: 2, min: 2, max: 64, hint: '[radius, y] points along the outline, in order' },
|
|
101
|
+
smooth: { type: 'boolean', default: false, hint: 'run a smooth curve through the points instead of straight segments' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
tube: {
|
|
105
|
+
summary: 'A round bar along a path: a handle, a straw, a spout, a strap.',
|
|
106
|
+
origin: 'path', capabilities: ALL_OPS,
|
|
107
|
+
params: {
|
|
108
|
+
path: { type: 'points', dims: 3, min: 2, max: 32, hint: '[x, y, z] points the bar runs through, in order' },
|
|
109
|
+
radius: num(0.003, 1, 0.04, 'radius of the bar'),
|
|
110
|
+
smooth: { type: 'boolean', default: true, hint: 'run a smooth curve through the points' },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
box: {
|
|
114
|
+
summary: 'A block, centred: a lid tab, a foot, a slab, a clip.',
|
|
115
|
+
origin: 'centre', capabilities: ALL_OPS,
|
|
116
|
+
params: { width: num(0.005, 4, 0.5, 'along x'), height: num(0.005, 4, 0.5, 'along y'), depth: num(0.005, 4, 0.5, 'along z') },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const TS_OBJECT_MATERIAL_IDS = ['plastic', 'matte', 'ceramic', 'glass', 'metal', 'chrome'] as const;
|
|
121
|
+
export type TSObjectMaterialId = (typeof TS_OBJECT_MATERIAL_IDS)[number];
|
|
122
|
+
|
|
123
|
+
/** How a part shades. `metalness` tints the reflection with the colour; `reflection` is how much of the studio it shows. */
|
|
124
|
+
export const TS_OBJECT_MATERIALS: Record<TSObjectMaterialId, { summary: string; metalness: number; roughness: number; reflection: number; gain: number }> = {
|
|
125
|
+
plastic: { summary: 'Moulded plastic: coloured, a soft highlight.', metalness: 0, roughness: 0.38, reflection: 0.35, gain: 1 },
|
|
126
|
+
matte: { summary: 'Powder coat, rubber, card: colour with almost no shine.', metalness: 0, roughness: 0.9, reflection: 0.06, gain: 1 },
|
|
127
|
+
ceramic: { summary: 'Glazed ceramic or enamel: colour under a glossy coat.', metalness: 0, roughness: 0.12, reflection: 0.7, gain: 1 },
|
|
128
|
+
glass: { summary: 'Dark, tinted glass: nearly black in the body, the studio in its reflection. Not see-through.', metalness: 0, roughness: 0.04, reflection: 1.1, gain: 0.55 },
|
|
129
|
+
metal: { summary: 'Brushed or satin metal (stainless, aluminium); a colour anodises it.', metalness: 1, roughness: 0.4, reflection: 1, gain: 1 },
|
|
130
|
+
chrome: { summary: 'Polished metal: a mirror of the studio.', metalness: 1, roughness: 0.05, reflection: 1.15, gain: 1 },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const TS_OBJECT_TABLES = ['studio', 'desk', 'wood'] as const;
|
|
134
|
+
|
|
135
|
+
/* ---------------- the spec ---------------- */
|
|
136
|
+
|
|
137
|
+
export type TSOp = { op: TSManipulatorId } & Record<string, number | string>;
|
|
138
|
+
|
|
139
|
+
export type TSPartMaterial = {
|
|
140
|
+
type: TSObjectMaterialId;
|
|
141
|
+
color: string;
|
|
142
|
+
/** Override the material's own roughness / metalness, 0–1. */
|
|
143
|
+
roughness?: number;
|
|
144
|
+
metalness?: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type TSPart = {
|
|
148
|
+
/** Unique within the object; `text.on` names one. */
|
|
149
|
+
id: string;
|
|
150
|
+
shape: TSPrimitiveId;
|
|
151
|
+
/** The primitive's parameters (`TS_PRIMITIVES[shape].params`); anything omitted takes its default. */
|
|
152
|
+
params: Record<string, unknown>;
|
|
153
|
+
/** Manipulators, applied in order. */
|
|
154
|
+
ops: TSOp[];
|
|
155
|
+
material: TSPartMaterial;
|
|
156
|
+
/** Where the part goes, in the object's frame. */
|
|
157
|
+
at: [number, number, number];
|
|
158
|
+
/** Degrees about x, then y, then z. */
|
|
159
|
+
rotate: [number, number, number];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type TSObjectText = {
|
|
163
|
+
/** The part the text wraps. It must be round, on the axis and unturned. */
|
|
164
|
+
on: string;
|
|
165
|
+
/** Centre of the text area along the part's height, 0 bottom – 1 top. */
|
|
166
|
+
y: number;
|
|
167
|
+
/** Height of the text area, as a share of the part's height. */
|
|
168
|
+
height: number;
|
|
169
|
+
/** How far round the part the text area reaches, degrees (360 goes all the way). */
|
|
170
|
+
arc: number;
|
|
171
|
+
/** A paper label in this colour; null prints straight onto the part. */
|
|
172
|
+
label: string | null;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export type TSObjectSpec = {
|
|
176
|
+
name: string;
|
|
177
|
+
parts: TSPart[];
|
|
178
|
+
text: TSObjectText;
|
|
179
|
+
scene: { table: (typeof TS_OBJECT_TABLES)[number]; backdrop: [string, string] };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const TS_OBJECT_TEXT_PARAMS: Record<'y' | 'height' | 'arc', TSParam> = {
|
|
183
|
+
y: num(0, 1, 0.5, 'centre of the text area along the part, 0 bottom – 1 top'),
|
|
184
|
+
height: num(0.05, 1, 0.6, 'height of the text area, as a share of the part'),
|
|
185
|
+
arc: num(20, 360, 160, 'how far round the part the text area reaches', '°'),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const MAX_OBJECT_PARTS = 24;
|
|
189
|
+
/** How much of a round part's circumference reads from the front, degrees: text wider than this runs out of sight. */
|
|
190
|
+
export const OBJECT_FRONT_ARC = 110;
|
|
191
|
+
const DEFAULT_SCENE: TSObjectSpec['scene'] = { table: 'studio', backdrop: ['#d9dadc', '#b9bbbf'] };
|
|
192
|
+
const FALLBACK_COLOR = '#8A8F98';
|
|
193
|
+
|
|
194
|
+
/* ---------------- sanitize / validate ---------------- */
|
|
195
|
+
|
|
196
|
+
export type TSObjectError = { path: string; message: string };
|
|
197
|
+
|
|
198
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
199
|
+
const isHex = (v: unknown): v is string => typeof v === 'string' && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(v);
|
|
200
|
+
const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
201
|
+
|
|
202
|
+
function fixParam(spec: TSParam, v: unknown, path: string, errors: TSObjectError[], required = false): unknown {
|
|
203
|
+
switch (spec.type) {
|
|
204
|
+
case 'number':
|
|
205
|
+
if (v === undefined && !required) return spec.default;
|
|
206
|
+
if (!isNum(v)) { errors.push({ path, message: `Must be a number between ${spec.min} and ${spec.max}.` }); return spec.default; }
|
|
207
|
+
if (v < spec.min || v > spec.max) { errors.push({ path, message: `Must be between ${spec.min} and ${spec.max}.` }); return Math.min(spec.max, Math.max(spec.min, v)); }
|
|
208
|
+
return v;
|
|
209
|
+
case 'boolean':
|
|
210
|
+
if (v === undefined) return spec.default;
|
|
211
|
+
if (typeof v !== 'boolean') { errors.push({ path, message: 'Must be true or false.' }); return spec.default; }
|
|
212
|
+
return v;
|
|
213
|
+
case 'enum':
|
|
214
|
+
if (v === undefined) return spec.default;
|
|
215
|
+
if (typeof v !== 'string' || !spec.options.includes(v)) { errors.push({ path, message: `Must be one of: ${spec.options.join(', ')}.` }); return spec.default; }
|
|
216
|
+
return v;
|
|
217
|
+
case 'points': {
|
|
218
|
+
const ok = Array.isArray(v) && v.length >= spec.min && v.length <= spec.max
|
|
219
|
+
&& v.every((p) => Array.isArray(p) && p.length === spec.dims && p.every((n) => isNum(n) && Math.abs(n) <= 8));
|
|
220
|
+
if (!ok) { errors.push({ path, message: `Must be ${spec.min}–${spec.max} points of ${spec.dims} numbers each.` }); return null; }
|
|
221
|
+
return (v as number[][]).map((p) => [...p]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function fixVec3(v: unknown, limit: number, path: string, errors: TSObjectError[]): [number, number, number] {
|
|
227
|
+
if (v === undefined) return [0, 0, 0];
|
|
228
|
+
if (!Array.isArray(v) || v.length !== 3 || !v.every((n) => isNum(n) && Math.abs(n) <= limit)) {
|
|
229
|
+
errors.push({ path, message: `Must be three numbers within ±${limit}.` });
|
|
230
|
+
return [0, 0, 0];
|
|
231
|
+
}
|
|
232
|
+
return [v[0] as number, v[1] as number, v[2] as number];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A part that can carry the text: a round primitive, on the axis, unturned, with only round-keeping manipulators. */
|
|
236
|
+
function textCapable(p: TSPart): string | null {
|
|
237
|
+
if (!TS_PRIMITIVES[p.shape].capabilities.includes('text-wrap')) return `a ${p.shape} cannot carry text; use a cylinder, sphere or revolve`;
|
|
238
|
+
if (p.at[0] !== 0 || p.at[2] !== 0 || p.rotate.some((r) => r !== 0)) return 'the part that carries text must sit on the axis, unturned (at x = z = 0, rotate 0)';
|
|
239
|
+
const bad = p.ops.find((o) => !TS_MANIPULATORS[o.op].keepsRound);
|
|
240
|
+
if (bad) return `the part that carries text must stay round; remove its ${bad.op}`;
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A spec that always builds: every bad field is replaced (numbers are clamped, the rest take their
|
|
246
|
+
* default, parts and manipulators that make no sense are dropped) and named in `errors`. Returns
|
|
247
|
+
* `object: null` only when nothing in it can be drawn.
|
|
248
|
+
*/
|
|
249
|
+
export function sanitizeObject(input: unknown, base = 'object'): { object: TSObjectSpec | null; errors: TSObjectError[] } {
|
|
250
|
+
const errors: TSObjectError[] = [];
|
|
251
|
+
if (!isObj(input)) return { object: null, errors: [{ path: base, message: 'An object must be { name, parts, text }.' }] };
|
|
252
|
+
const rawParts = Array.isArray(input.parts) ? input.parts : [];
|
|
253
|
+
if (!Array.isArray(input.parts) || rawParts.length === 0) errors.push({ path: `${base}.parts`, message: 'An object needs at least one part.' });
|
|
254
|
+
if (rawParts.length > MAX_OBJECT_PARTS) errors.push({ path: `${base}.parts`, message: `An object has at most ${MAX_OBJECT_PARTS} parts.` });
|
|
255
|
+
|
|
256
|
+
const parts: TSPart[] = [];
|
|
257
|
+
const ids = new Set<string>();
|
|
258
|
+
rawParts.slice(0, MAX_OBJECT_PARTS).forEach((raw, i) => {
|
|
259
|
+
const p = `${base}.parts.${i}`;
|
|
260
|
+
if (!isObj(raw)) { errors.push({ path: p, message: 'A part must be an object.' }); return; }
|
|
261
|
+
const shape = raw.shape as TSPrimitiveId;
|
|
262
|
+
if (!TS_PRIMITIVE_IDS.includes(shape)) { errors.push({ path: `${p}.shape`, message: `A part is one of: ${TS_PRIMITIVE_IDS.join(', ')}.` }); return; }
|
|
263
|
+
let id = typeof raw.id === 'string' && raw.id.trim() ? raw.id : '';
|
|
264
|
+
if (!id || ids.has(id)) {
|
|
265
|
+
errors.push({ path: `${p}.id`, message: id ? `Two parts are called “${id}”.` : 'A part needs an id.' });
|
|
266
|
+
id = `${shape}-${i}`;
|
|
267
|
+
}
|
|
268
|
+
ids.add(id);
|
|
269
|
+
const prim = TS_PRIMITIVES[shape];
|
|
270
|
+
const rawParams = isObj(raw.params) ? raw.params : {};
|
|
271
|
+
if (raw.params !== undefined && !isObj(raw.params)) errors.push({ path: `${p}.params`, message: 'params must be an object.' });
|
|
272
|
+
const params: Record<string, unknown> = {};
|
|
273
|
+
let drawable = true;
|
|
274
|
+
for (const [k, spec] of Object.entries(prim.params)) {
|
|
275
|
+
const v = fixParam(spec, rawParams[k], `${p}.params.${k}`, errors);
|
|
276
|
+
if (v === null) drawable = false; else params[k] = v;
|
|
277
|
+
}
|
|
278
|
+
for (const k of Object.keys(rawParams)) if (!(k in prim.params)) errors.push({ path: `${p}.params.${k}`, message: `A ${shape} has no “${k}”; it takes: ${Object.keys(prim.params).join(', ')}.` });
|
|
279
|
+
if (!drawable) return;
|
|
280
|
+
|
|
281
|
+
const ops: TSOp[] = [];
|
|
282
|
+
if (raw.ops !== undefined && !Array.isArray(raw.ops)) errors.push({ path: `${p}.ops`, message: 'ops must be a list.' });
|
|
283
|
+
(Array.isArray(raw.ops) ? raw.ops : []).forEach((o, k) => {
|
|
284
|
+
const op = `${p}.ops.${k}`;
|
|
285
|
+
const name = isObj(o) ? (o.op as TSManipulatorId) : undefined;
|
|
286
|
+
if (!isObj(o) || !name || !TS_MANIPULATOR_IDS.includes(name)) { errors.push({ path: op, message: `A manipulator is { op } with op one of: ${TS_MANIPULATOR_IDS.join(', ')}.` }); return; }
|
|
287
|
+
if (!prim.capabilities.includes(name)) { errors.push({ path: op, message: `A ${shape} cannot ${name}.` }); return; }
|
|
288
|
+
const out: TSOp = { op: name };
|
|
289
|
+
for (const [key, spec] of Object.entries(TS_MANIPULATORS[name].params)) out[key] = fixParam(spec, o[key], `${op}.${key}`, errors) as number | string;
|
|
290
|
+
for (const key of Object.keys(o)) if (key !== 'op' && !(key in TS_MANIPULATORS[name].params)) errors.push({ path: `${op}.${key}`, message: `${name} has no “${key}”; it takes: ${Object.keys(TS_MANIPULATORS[name].params).join(', ')}.` });
|
|
291
|
+
ops.push(out);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const rm = isObj(raw.material) ? raw.material : {};
|
|
295
|
+
if (!isObj(raw.material)) errors.push({ path: `${p}.material`, message: 'A part needs a material: { type, color }.' });
|
|
296
|
+
let type = rm.type as TSObjectMaterialId;
|
|
297
|
+
if (!TS_OBJECT_MATERIAL_IDS.includes(type)) {
|
|
298
|
+
if (isObj(raw.material)) errors.push({ path: `${p}.material.type`, message: `A material is one of: ${TS_OBJECT_MATERIAL_IDS.join(', ')}.` });
|
|
299
|
+
type = 'plastic';
|
|
300
|
+
}
|
|
301
|
+
let color = rm.color as string;
|
|
302
|
+
if (!isHex(color)) {
|
|
303
|
+
if (isObj(raw.material)) errors.push({ path: `${p}.material.color`, message: 'A colour is a hex like #1F8A8A.' });
|
|
304
|
+
color = FALLBACK_COLOR;
|
|
305
|
+
}
|
|
306
|
+
const material: TSPartMaterial = { type, color };
|
|
307
|
+
for (const k of ['roughness', 'metalness'] as const) {
|
|
308
|
+
if (rm[k] === undefined) continue;
|
|
309
|
+
if (!isNum(rm[k]) || (rm[k] as number) < 0 || (rm[k] as number) > 1) errors.push({ path: `${p}.material.${k}`, message: 'Must be between 0 and 1.' });
|
|
310
|
+
else material[k] = rm[k] as number;
|
|
311
|
+
}
|
|
312
|
+
parts.push({ id, shape, params, ops, material, at: fixVec3(raw.at, 8, `${p}.at`, errors), rotate: fixVec3(raw.rotate, 360, `${p}.rotate`, errors) });
|
|
313
|
+
});
|
|
314
|
+
if (parts.length === 0) return { object: null, errors };
|
|
315
|
+
|
|
316
|
+
// The text: on a part that can carry it; otherwise the first that can.
|
|
317
|
+
const rt = isObj(input.text) ? input.text : {};
|
|
318
|
+
if (input.text !== undefined && !isObj(input.text)) errors.push({ path: `${base}.text`, message: 'text must be { on, y, height, arc, label }.' });
|
|
319
|
+
const firstCapable = parts.find((p) => textCapable(p) === null);
|
|
320
|
+
let host = parts.find((p) => p.id === rt.on);
|
|
321
|
+
if (!host) {
|
|
322
|
+
if (rt.on !== undefined) errors.push({ path: `${base}.text.on`, message: `No part is called “${String(rt.on)}”; parts: ${parts.map((p) => p.id).join(', ')}.` });
|
|
323
|
+
host = firstCapable;
|
|
324
|
+
} else {
|
|
325
|
+
const why = textCapable(host);
|
|
326
|
+
if (why) { errors.push({ path: `${base}.text.on`, message: `Text cannot wrap “${host.id}”: ${why}.` }); host = firstCapable; }
|
|
327
|
+
}
|
|
328
|
+
if (!host) {
|
|
329
|
+
errors.push({ path: `${base}.text.on`, message: 'No part can carry text: the object needs a cylinder, sphere or revolve on the axis.' });
|
|
330
|
+
return { object: null, errors };
|
|
331
|
+
}
|
|
332
|
+
let label: string | null = null;
|
|
333
|
+
if (rt.label !== undefined && rt.label !== null) {
|
|
334
|
+
if (isHex(rt.label)) label = rt.label; else errors.push({ path: `${base}.text.label`, message: 'label is a hex colour, or null to print straight onto the part.' });
|
|
335
|
+
}
|
|
336
|
+
const text: TSObjectText = {
|
|
337
|
+
on: host.id,
|
|
338
|
+
y: fixParam(TS_OBJECT_TEXT_PARAMS.y, rt.y, `${base}.text.y`, errors) as number,
|
|
339
|
+
height: fixParam(TS_OBJECT_TEXT_PARAMS.height, rt.height, `${base}.text.height`, errors) as number,
|
|
340
|
+
arc: fixParam(TS_OBJECT_TEXT_PARAMS.arc, rt.arc, `${base}.text.arc`, errors) as number,
|
|
341
|
+
label,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const rs = isObj(input.scene) ? input.scene : {};
|
|
345
|
+
let table = rs.table as TSObjectSpec['scene']['table'];
|
|
346
|
+
if (!TS_OBJECT_TABLES.includes(table)) {
|
|
347
|
+
if (rs.table !== undefined) errors.push({ path: `${base}.scene.table`, message: `table is one of: ${TS_OBJECT_TABLES.join(', ')}.` });
|
|
348
|
+
table = DEFAULT_SCENE.table;
|
|
349
|
+
}
|
|
350
|
+
let backdrop = DEFAULT_SCENE.backdrop;
|
|
351
|
+
if (rs.backdrop !== undefined) {
|
|
352
|
+
if (Array.isArray(rs.backdrop) && rs.backdrop.length === 2 && rs.backdrop.every(isHex)) backdrop = [rs.backdrop[0] as string, rs.backdrop[1] as string];
|
|
353
|
+
else errors.push({ path: `${base}.scene.backdrop`, message: 'backdrop is two hex colours, top and bottom.' });
|
|
354
|
+
}
|
|
355
|
+
let name = input.name as string;
|
|
356
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 80) {
|
|
357
|
+
if (input.name !== undefined) errors.push({ path: `${base}.name`, message: 'name is a short label.' });
|
|
358
|
+
name = 'Object';
|
|
359
|
+
}
|
|
360
|
+
return { object: { name, parts, text, scene: { table, backdrop } }, errors };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `[]` when the spec is drawable as written; otherwise every problem as `{ path, message }`. Never throws. */
|
|
364
|
+
export function validateObject(spec: unknown, base = 'object'): TSObjectError[] {
|
|
365
|
+
return sanitizeObject(spec, base).errors;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ---------------- geometry ---------------- */
|
|
369
|
+
|
|
370
|
+
type P2 = [number, number];
|
|
371
|
+
type Grid = { positions: number[]; rows: number; cols: number };
|
|
372
|
+
|
|
373
|
+
/** Round the interior corners of a polyline; `radii[k]` is the cut for corner k (0 leaves it sharp). */
|
|
374
|
+
function fillet(pts: P2[], radii: number[]): P2[] {
|
|
375
|
+
const out: P2[] = [pts[0]!];
|
|
376
|
+
for (let k = 1; k < pts.length - 1; k++) {
|
|
377
|
+
const a = pts[k - 1]!, b = pts[k]!, c = pts[k + 1]!;
|
|
378
|
+
const la = Math.hypot(a[0] - b[0], a[1] - b[1]), lc = Math.hypot(c[0] - b[0], c[1] - b[1]);
|
|
379
|
+
const t = Math.min(radii[k] ?? 0, la * 0.5, lc * 0.5);
|
|
380
|
+
if (t < 1e-5) { out.push(b); continue; }
|
|
381
|
+
const p0: P2 = [b[0] + ((a[0] - b[0]) / la) * t, b[1] + ((a[1] - b[1]) / la) * t];
|
|
382
|
+
const p1: P2 = [b[0] + ((c[0] - b[0]) / lc) * t, b[1] + ((c[1] - b[1]) / lc) * t];
|
|
383
|
+
const steps = 6;
|
|
384
|
+
for (let s = 0; s <= steps; s++) {
|
|
385
|
+
const u = s / steps, v = 1 - u;
|
|
386
|
+
out.push([v * v * p0[0] + 2 * u * v * b[0] + u * u * p1[0], v * v * p0[1] + 2 * u * v * b[1] + u * u * p1[1]]);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
out.push(pts[pts.length - 1]!);
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A Catmull-Rom curve through the points. */
|
|
394
|
+
function smoothPath(pts: number[][], sub: number): number[][] {
|
|
395
|
+
if (pts.length < 3) return pts;
|
|
396
|
+
const out: number[][] = [];
|
|
397
|
+
for (let k = 0; k < pts.length - 1; k++) {
|
|
398
|
+
const p0 = pts[Math.max(0, k - 1)]!, p1 = pts[k]!, p2 = pts[k + 1]!, p3 = pts[Math.min(pts.length - 1, k + 2)]!;
|
|
399
|
+
for (let s = 0; s < sub; s++) {
|
|
400
|
+
const t = s / sub, t2 = t * t, t3 = t2 * t;
|
|
401
|
+
out.push(p1.map((_, d) => 0.5 * (2 * p1[d]! + (p2[d]! - p0[d]!) * t + (2 * p0[d]! - 5 * p1[d]! + 4 * p2[d]! - p3[d]!) * t2 + (3 * p1[d]! - p0[d]! - 3 * p2[d]! + p3[d]!) * t3)));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
out.push(pts[pts.length - 1]!);
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Split long segments so manipulators have rows to bend. */
|
|
409
|
+
function densify(pts: P2[], step: number): P2[] {
|
|
410
|
+
const out: P2[] = [];
|
|
411
|
+
for (let k = 0; k < pts.length - 1; k++) {
|
|
412
|
+
const a = pts[k]!, b = pts[k + 1]!;
|
|
413
|
+
const n = Math.max(1, Math.min(48, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step)));
|
|
414
|
+
for (let s = 0; s < n; s++) out.push([a[0] + ((b[0] - a[0]) * s) / n, a[1] + ((b[1] - a[1]) * s) / n]);
|
|
415
|
+
}
|
|
416
|
+
out.push(pts[pts.length - 1]!);
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const SEGS = 64;
|
|
421
|
+
|
|
422
|
+
/** Turn a profile about the y axis. The profile runs anticlockwise (up the outside), so normals face out. */
|
|
423
|
+
function revolve(profile: P2[]): Grid {
|
|
424
|
+
const cols = SEGS + 1;
|
|
425
|
+
const positions: number[] = [];
|
|
426
|
+
for (const [r0, y] of profile) {
|
|
427
|
+
const r = Math.max(0, r0);
|
|
428
|
+
for (let q = 0; q <= SEGS; q++) {
|
|
429
|
+
const a = (q / SEGS) * Math.PI * 2;
|
|
430
|
+
positions.push(r * Math.sin(a), y, r * Math.cos(a));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { positions, rows: profile.length, cols };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function profileFor(shape: TSPrimitiveId, pr: Record<string, unknown>): P2[] {
|
|
437
|
+
if (shape === 'cylinder') {
|
|
438
|
+
const r = pr.radius as number, h = pr.height as number, w = Math.min(pr.hollow as number, r * 0.9), e = Math.min(pr.round as number, r * 0.95, h * 0.5);
|
|
439
|
+
if (w <= 0) return densify(fillet([[0, 0], [r, 0], [r, h], [0, h]], [0, e, e, 0]), h / 28);
|
|
440
|
+
const floor = Math.min(w, h * 0.5);
|
|
441
|
+
return densify(fillet([[0, 0], [r, 0], [r, h], [r - w, h], [r - w, floor], [0, floor]], [0, e, w * 0.5, w * 0.5, Math.min(e, (r - w) * 0.9), 0]), h / 28);
|
|
442
|
+
}
|
|
443
|
+
if (shape === 'sphere') {
|
|
444
|
+
const r = pr.radius as number, n = 28;
|
|
445
|
+
return Array.from({ length: n + 1 }, (_, k): P2 => { const a = -Math.PI / 2 + (Math.PI * k) / n; return [k === 0 || k === n ? 0 : r * Math.cos(a), r * Math.sin(a)]; });
|
|
446
|
+
}
|
|
447
|
+
if (shape === 'ring') {
|
|
448
|
+
const R = pr.radius as number, t = Math.min(pr.thickness as number, R), n = 20;
|
|
449
|
+
return Array.from({ length: n + 1 }, (_, k): P2 => { const a = (Math.PI * 2 * k) / n - Math.PI / 2; return [R + t * Math.cos(a), t * Math.sin(a)]; });
|
|
450
|
+
}
|
|
451
|
+
// revolve: the user's outline, made to run anticlockwise so the outside faces out.
|
|
452
|
+
let pts = (pr.profile as number[][]).map((p): P2 => [Math.max(0, p[0]!), p[1]!]);
|
|
453
|
+
if (pr.smooth) pts = smoothPath(pts, 6).map((p): P2 => [Math.max(0, p[0]!), p[1]!]);
|
|
454
|
+
let area = 0;
|
|
455
|
+
for (let k = 0; k < pts.length; k++) { const a = pts[k]!, b = pts[(k + 1) % pts.length]!; area += a[0] * b[1] - b[0] * a[1]; }
|
|
456
|
+
if (area < 0) pts.reverse();
|
|
457
|
+
const span = Math.max(1e-3, Math.max(...pts.map((p) => p[1])) - Math.min(...pts.map((p) => p[1])));
|
|
458
|
+
return densify(pts, span / 28);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A bar of `radius` along a path, capped at both ends. */
|
|
462
|
+
function tube(path: number[][], radius: number): Grid {
|
|
463
|
+
const segs = 20, cols = segs + 1;
|
|
464
|
+
const n = path.length;
|
|
465
|
+
const positions: number[] = [];
|
|
466
|
+
const tangent = (k: number): Vec3 => {
|
|
467
|
+
const a = path[Math.max(0, k - 1)]!, b = path[Math.min(n - 1, k + 1)]!;
|
|
468
|
+
const l = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) || 1;
|
|
469
|
+
return [(b[0]! - a[0]!) / l, (b[1]! - a[1]!) / l, (b[2]! - a[2]!) / l];
|
|
470
|
+
};
|
|
471
|
+
// A frame carried along the path (parallel transport), so the bar doesn't twist.
|
|
472
|
+
const t0 = tangent(0);
|
|
473
|
+
const ref: Vec3 = Math.abs(t0[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
|
|
474
|
+
let N = norm3(cross3(cross3(t0, ref), t0));
|
|
475
|
+
const ring = (k: number, r: number) => {
|
|
476
|
+
const T = tangent(k);
|
|
477
|
+
N = norm3([N[0] - T[0] * dot3(N, T), N[1] - T[1] * dot3(N, T), N[2] - T[2] * dot3(N, T)]);
|
|
478
|
+
const B = cross3(T, N);
|
|
479
|
+
const c = path[k]!;
|
|
480
|
+
for (let q = 0; q <= segs; q++) {
|
|
481
|
+
const a = (q / segs) * Math.PI * 2, ca = Math.cos(a) * r, sa = Math.sin(a) * r;
|
|
482
|
+
positions.push(c[0]! + N[0] * ca + B[0] * sa, c[1]! + N[1] * ca + B[1] * sa, c[2]! + N[2] * ca + B[2] * sa);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
ring(0, 0);
|
|
486
|
+
for (let k = 0; k < n; k++) ring(k, radius);
|
|
487
|
+
ring(n - 1, 0);
|
|
488
|
+
return { positions, rows: n + 2, cols };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A block: six faces, each its own grid so the edges stay sharp. */
|
|
492
|
+
function box(w: number, h: number, d: number): Grid[] {
|
|
493
|
+
const half: Vec3 = [w / 2, h / 2, d / 2];
|
|
494
|
+
const n = 6;
|
|
495
|
+
// normal axis, u axis, v axis, sign: u × v = sign · normal.
|
|
496
|
+
const faces: [number, number, number, number][] = [[0, 1, 2, 1], [0, 2, 1, -1], [1, 2, 0, 1], [1, 0, 2, -1], [2, 0, 1, 1], [2, 1, 0, -1]];
|
|
497
|
+
return faces.map(([an, au, av, sign]) => {
|
|
498
|
+
const positions: number[] = [];
|
|
499
|
+
for (let j = 0; j <= n; j++) for (let i = 0; i <= n; i++) {
|
|
500
|
+
const p: Vec3 = [0, 0, 0];
|
|
501
|
+
p[an] = sign * half[an]!;
|
|
502
|
+
p[au] = (i / n - 0.5) * 2 * half[au]!;
|
|
503
|
+
p[av] = (j / n - 0.5) * 2 * half[av]!;
|
|
504
|
+
positions.push(p[0], p[1], p[2]);
|
|
505
|
+
}
|
|
506
|
+
return { positions, rows: n + 1, cols: n + 1 };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const cross3 = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
511
|
+
const dot3 = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
512
|
+
const norm3 = (a: Vec3): Vec3 => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
|
|
513
|
+
|
|
514
|
+
const RAD = Math.PI / 180;
|
|
515
|
+
|
|
516
|
+
/** Apply a part's manipulators, in order, in its own frame. `v` runs 0–1 up the part as it was before any of them. */
|
|
517
|
+
function manipulate(pos: number[], ops: TSOp[]): void {
|
|
518
|
+
if (ops.length === 0) return;
|
|
519
|
+
let y0 = Infinity, y1 = -Infinity;
|
|
520
|
+
for (let k = 1; k < pos.length; k += 3) { if (pos[k]! < y0) y0 = pos[k]!; if (pos[k]! > y1) y1 = pos[k]!; }
|
|
521
|
+
const H = Math.max(1e-6, y1 - y0);
|
|
522
|
+
for (let k = 0; k < pos.length; k += 3) {
|
|
523
|
+
const v = (pos[k + 1]! - y0) / H;
|
|
524
|
+
let x = pos[k]!, y = pos[k + 1]!, z = pos[k + 2]!;
|
|
525
|
+
for (const o of ops) {
|
|
526
|
+
const g = (key: string) => o[key] as number;
|
|
527
|
+
if (o.op === 'taper') { const s = g('bottom') + (g('top') - g('bottom')) * v; x *= s; z *= s; }
|
|
528
|
+
else if (o.op === 'flare') { const s = 1 + g('amount') * smoothstep(g('start'), 1, o.end === 'bottom' ? 1 - v : v); x *= s; z *= s; }
|
|
529
|
+
else if (o.op === 'bulge') { const s = 1 + g('amount') * Math.exp(-(((v - g('center')) / g('width')) ** 2)); x *= s; z *= s; }
|
|
530
|
+
else if (o.op === 'twist') { const a = g('angle') * RAD * v, c = Math.cos(a), s = Math.sin(a); const nx = x * c + z * s; z = -x * s + z * c; x = nx; }
|
|
531
|
+
else if (o.op === 'bend') {
|
|
532
|
+
const th = g('angle') * RAD;
|
|
533
|
+
if (Math.abs(th) > 1e-4) { const R = H / th, phi = th * v, d = R - x; x = R - d * Math.cos(phi); y = y0 + d * Math.sin(phi); }
|
|
534
|
+
} else if (o.op === 'scale') { x *= g('x'); y = y0 + (y - y0) * g('y'); z *= g('z'); }
|
|
535
|
+
}
|
|
536
|
+
pos[k] = x; pos[k + 1] = y; pos[k + 2] = z;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Turn about x, then y, then z (degrees). */
|
|
541
|
+
function turn(p: Vec3, r: readonly [number, number, number]): void {
|
|
542
|
+
if (r[0]) { const c = Math.cos(r[0] * RAD), s = Math.sin(r[0] * RAD); const y = p[1] * c - p[2] * s; p[2] = p[1] * s + p[2] * c; p[1] = y; }
|
|
543
|
+
if (r[1]) { const c = Math.cos(r[1] * RAD), s = Math.sin(r[1] * RAD); const x = p[0] * c + p[2] * s; p[2] = -p[0] * s + p[2] * c; p[0] = x; }
|
|
544
|
+
if (r[2]) { const c = Math.cos(r[2] * RAD), s = Math.sin(r[2] * RAD); const x = p[0] * c - p[1] * s; p[1] = p[0] * s + p[1] * c; p[0] = x; }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export type ResolvedMaterial = { color: string; metalness: number; roughness: number; reflection: number; gain: number };
|
|
548
|
+
/** One part's triangles in the object's index buffer, and how they shade. */
|
|
549
|
+
export type PartRange = { id: string; start: number; count: number; material: ResolvedMaterial };
|
|
550
|
+
|
|
551
|
+
export type BuiltObject = {
|
|
552
|
+
positions: Float32Array;
|
|
553
|
+
normals: Float32Array;
|
|
554
|
+
indices: Uint32Array;
|
|
555
|
+
count: number;
|
|
556
|
+
ranges: PartRange[];
|
|
557
|
+
bounds: { min: Vec3; max: Vec3 };
|
|
558
|
+
/** The part that carries the text: its extent along y and its outer radius at any height. */
|
|
559
|
+
host: { id: string; y0: number; y1: number; radiusAt: (y: number) => number; color: string };
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
export function resolveMaterial(m: TSPartMaterial): ResolvedMaterial {
|
|
563
|
+
const base = TS_OBJECT_MATERIALS[m.type];
|
|
564
|
+
return { color: m.color, metalness: m.metalness ?? base.metalness, roughness: m.roughness ?? base.roughness, reflection: base.reflection, gain: base.gain };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Build a spec into one mesh in the object's frame: every part through its manipulators, turned and
|
|
569
|
+
* placed, with smooth normals and a triangle range per part. The spec is repaired first (omitted
|
|
570
|
+
* parameters take their defaults); it throws only when nothing in it can be drawn.
|
|
571
|
+
*/
|
|
572
|
+
export function buildObject(input: TSObjectSpec): BuiltObject {
|
|
573
|
+
const spec = sanitizeObject(input).object;
|
|
574
|
+
if (!spec) throw new Error('construct: nothing in this object can be drawn (see validateObject).');
|
|
575
|
+
const positions: number[] = [], normals: number[] = [], indices: number[] = [];
|
|
576
|
+
const ranges: PartRange[] = [];
|
|
577
|
+
const min: Vec3 = [Infinity, Infinity, Infinity], max: Vec3 = [-Infinity, -Infinity, -Infinity];
|
|
578
|
+
let host: BuiltObject['host'] | null = null;
|
|
579
|
+
|
|
580
|
+
for (const part of spec.parts) {
|
|
581
|
+
const pr = part.params;
|
|
582
|
+
const grids: Grid[] = part.shape === 'tube'
|
|
583
|
+
? [tube(pr.smooth ? smoothPath(pr.path as number[][], 8) : (pr.path as number[][]), pr.radius as number)]
|
|
584
|
+
: part.shape === 'box' ? box(pr.width as number, pr.height as number, pr.depth as number)
|
|
585
|
+
: [revolve(profileFor(part.shape, pr))];
|
|
586
|
+
const base = positions.length / 3;
|
|
587
|
+
const start = indices.length;
|
|
588
|
+
const local: number[] = [];
|
|
589
|
+
const tris: number[] = [];
|
|
590
|
+
for (const g of grids) {
|
|
591
|
+
const off = local.length / 3;
|
|
592
|
+
for (const v of g.positions) local.push(v);
|
|
593
|
+
for (let p = 0; p < g.rows - 1; p++) for (let q = 0; q < g.cols - 1; q++) {
|
|
594
|
+
const a = off + p * g.cols + q, b = a + 1, c = a + g.cols, d = c + 1;
|
|
595
|
+
tris.push(a, b, c, b, d, c);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
manipulate(local, part.ops);
|
|
599
|
+
const n = local.length / 3;
|
|
600
|
+
// Face normals summed onto vertices, then welded across coincident vertices (seams, poles) unless
|
|
601
|
+
// the faces meet at a hard edge.
|
|
602
|
+
const raw = new Float64Array(n * 3);
|
|
603
|
+
for (let t = 0; t < tris.length; t += 3) {
|
|
604
|
+
const a = tris[t]! * 3, b = tris[t + 1]! * 3, c = tris[t + 2]! * 3;
|
|
605
|
+
const fn = cross3([local[b]! - local[a]!, local[b + 1]! - local[a + 1]!, local[b + 2]! - local[a + 2]!], [local[c]! - local[a]!, local[c + 1]! - local[a + 1]!, local[c + 2]! - local[a + 2]!]);
|
|
606
|
+
for (const i of [a, b, c]) { raw[i] = raw[i]! + fn[0]; raw[i + 1] = raw[i + 1]! + fn[1]; raw[i + 2] = raw[i + 2]! + fn[2]; }
|
|
607
|
+
}
|
|
608
|
+
const unit = new Float64Array(n * 3);
|
|
609
|
+
const groups = new Map<string, number[]>();
|
|
610
|
+
for (let i = 0; i < n; i++) {
|
|
611
|
+
const u = norm3([raw[i * 3]!, raw[i * 3 + 1]!, raw[i * 3 + 2]!]);
|
|
612
|
+
const zero = Math.hypot(raw[i * 3]!, raw[i * 3 + 1]!, raw[i * 3 + 2]!) < 1e-12;
|
|
613
|
+
unit[i * 3] = zero ? 0 : u[0]; unit[i * 3 + 1] = zero ? 0 : u[1]; unit[i * 3 + 2] = zero ? 0 : u[2];
|
|
614
|
+
const key = `${Math.round(local[i * 3]! * 2e4)},${Math.round(local[i * 3 + 1]! * 2e4)},${Math.round(local[i * 3 + 2]! * 2e4)}`;
|
|
615
|
+
const g = groups.get(key);
|
|
616
|
+
if (g) g.push(i); else groups.set(key, [i]);
|
|
617
|
+
}
|
|
618
|
+
const smooth = new Float64Array(n * 3);
|
|
619
|
+
for (const g of groups.values()) {
|
|
620
|
+
for (const i of g) {
|
|
621
|
+
const ui: Vec3 = [unit[i * 3]!, unit[i * 3 + 1]!, unit[i * 3 + 2]!];
|
|
622
|
+
const none = ui[0] === 0 && ui[1] === 0 && ui[2] === 0;
|
|
623
|
+
let sx = 0, sy = 0, sz = 0;
|
|
624
|
+
for (const j of g) {
|
|
625
|
+
if (!none && j !== i && ui[0] * unit[j * 3]! + ui[1] * unit[j * 3 + 1]! + ui[2] * unit[j * 3 + 2]! < 0.4) continue;
|
|
626
|
+
sx += raw[j * 3]!; sy += raw[j * 3 + 1]!; sz += raw[j * 3 + 2]!;
|
|
627
|
+
}
|
|
628
|
+
const s = norm3([sx, sy, sz]);
|
|
629
|
+
smooth[i * 3] = s[0]; smooth[i * 3 + 1] = s[1]; smooth[i * 3 + 2] = s[2];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const p: Vec3 = [0, 0, 0];
|
|
633
|
+
let py0 = Infinity, py1 = -Infinity;
|
|
634
|
+
for (let i = 0; i < n; i++) {
|
|
635
|
+
p[0] = local[i * 3]!; p[1] = local[i * 3 + 1]!; p[2] = local[i * 3 + 2]!;
|
|
636
|
+
turn(p, part.rotate);
|
|
637
|
+
p[0] += part.at[0]; p[1] += part.at[1]; p[2] += part.at[2];
|
|
638
|
+
positions.push(p[0], p[1], p[2]);
|
|
639
|
+
for (let a = 0; a < 3; a++) { if (p[a]! < min[a]!) min[a] = p[a]!; if (p[a]! > max[a]!) max[a] = p[a]!; }
|
|
640
|
+
if (p[1] < py0) py0 = p[1]; if (p[1] > py1) py1 = p[1];
|
|
641
|
+
p[0] = smooth[i * 3]!; p[1] = smooth[i * 3 + 1]!; p[2] = smooth[i * 3 + 2]!;
|
|
642
|
+
turn(p, part.rotate);
|
|
643
|
+
normals.push(p[0], p[1], p[2]);
|
|
644
|
+
}
|
|
645
|
+
for (const t of tris) indices.push(base + t);
|
|
646
|
+
ranges.push({ id: part.id, start, count: indices.length - start, material: resolveMaterial(part.material) });
|
|
647
|
+
|
|
648
|
+
if (part.id === spec.text.on) {
|
|
649
|
+
// The outer radius at each height, from one meridian of the placed part.
|
|
650
|
+
const BINS = 128;
|
|
651
|
+
const table = new Float32Array(BINS + 1).fill(-1);
|
|
652
|
+
const g = grids[0]!;
|
|
653
|
+
const span = Math.max(1e-6, py1 - py0);
|
|
654
|
+
const at = (row: number): P2 => { const k = (base + row * g.cols) * 3; return [Math.hypot(positions[k]!, positions[k + 2]!), positions[k + 1]!]; };
|
|
655
|
+
for (let row = 0; row < g.rows - 1; row++) {
|
|
656
|
+
const [ra, ya] = at(row), [rb, yb] = at(row + 1);
|
|
657
|
+
const b0 = Math.round(((Math.min(ya, yb) - py0) / span) * BINS), b1 = Math.round(((Math.max(ya, yb) - py0) / span) * BINS);
|
|
658
|
+
for (let b = b0; b <= b1; b++) {
|
|
659
|
+
const y = py0 + (b / BINS) * span;
|
|
660
|
+
const f = Math.abs(yb - ya) < 1e-9 ? 0.5 : Math.min(1, Math.max(0, (y - ya) / (yb - ya)));
|
|
661
|
+
const r = Math.abs(yb - ya) < 1e-9 ? Math.max(ra, rb) : ra + (rb - ra) * f;
|
|
662
|
+
if (r > table[b]!) table[b] = r;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (let b = 1; b <= BINS; b++) if (table[b]! < 0) table[b] = table[b - 1]!;
|
|
666
|
+
for (let b = BINS - 1; b >= 0; b--) if (table[b]! < 0) table[b] = table[b + 1]!;
|
|
667
|
+
const y0 = py0;
|
|
668
|
+
host = {
|
|
669
|
+
id: part.id, y0: py0, y1: py1, color: part.material.color,
|
|
670
|
+
radiusAt: (y) => {
|
|
671
|
+
const f = Math.min(1, Math.max(0, (y - y0) / span)) * BINS;
|
|
672
|
+
const b = Math.min(BINS - 1, Math.floor(f));
|
|
673
|
+
return Math.max(1e-3, table[b]! + (table[b + 1]! - table[b]!) * (f - b));
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (!host) throw new Error(`construct: no part “${spec.text.on}” to carry the text.`);
|
|
679
|
+
return { positions: new Float32Array(positions), normals: new Float32Array(normals), indices: new Uint32Array(indices), count: positions.length / 3, ranges, bounds: { min, max }, host };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/* ---------------- recipes ---------------- */
|
|
683
|
+
|
|
684
|
+
export type TSRecipeOptions = { color?: string; material?: TSObjectMaterialId; accent?: string; label?: string | null };
|
|
685
|
+
|
|
686
|
+
const part = (id: string, shape: TSPrimitiveId, params: Record<string, unknown>, material: TSPartMaterial, rest: Partial<Pick<TSPart, 'ops' | 'at' | 'rotate'>> = {}): TSPart =>
|
|
687
|
+
({ id, shape, params, material, ops: rest.ops ?? [], at: rest.at ?? [0, 0, 0], rotate: rest.rotate ?? [0, 0, 0] });
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Semantic starting points. A recipe is only a composition of the primitives and manipulators above,
|
|
691
|
+
* returned as a plain spec to edit: nothing here is special-cased anywhere else, so an object with
|
|
692
|
+
* no recipe is built the same way, part by part.
|
|
693
|
+
*/
|
|
694
|
+
export const TS_RECIPES: Record<string, { summary: string; composition: string; build: (o?: TSRecipeOptions) => TSObjectSpec }> = {
|
|
695
|
+
tumbler: {
|
|
696
|
+
summary: 'A tapered travel tumbler with a metal rim and a dark foot.',
|
|
697
|
+
composition: 'cylinder (hollow, taper) + ring (rim) + cylinder (foot)',
|
|
698
|
+
build: (o = {}) => ({
|
|
699
|
+
name: 'Tumbler',
|
|
700
|
+
parts: [
|
|
701
|
+
part('body', 'cylinder', { radius: 0.38, height: 1.5, hollow: 0.025, round: 0.05 }, { type: o.material ?? 'matte', color: o.color ?? '#1F8A8A' }, { ops: [{ op: 'taper', bottom: 0.78, top: 1 }] }),
|
|
702
|
+
part('rim', 'ring', { radius: 0.372, thickness: 0.02 }, { type: 'chrome', color: o.accent ?? '#D9DCE1' }, { at: [0, 1.5, 0] }),
|
|
703
|
+
part('foot', 'cylinder', { radius: 0.305, height: 0.07, round: 0.02 }, { type: 'matte', color: '#1B1D20' }, { at: [0, -0.04, 0] }),
|
|
704
|
+
],
|
|
705
|
+
text: { on: 'body', y: 0.52, height: 0.62, arc: 170, label: o.label ?? null },
|
|
706
|
+
scene: DEFAULT_SCENE,
|
|
707
|
+
}),
|
|
708
|
+
},
|
|
709
|
+
mug: {
|
|
710
|
+
summary: 'A coffee mug: a hollow body and a handle.',
|
|
711
|
+
composition: 'cylinder (hollow) + tube (handle)',
|
|
712
|
+
build: (o = {}) => {
|
|
713
|
+
const m: TSPartMaterial = { type: o.material ?? 'ceramic', color: o.color ?? '#17181A' };
|
|
714
|
+
return {
|
|
715
|
+
name: 'Mug',
|
|
716
|
+
parts: [
|
|
717
|
+
part('body', 'cylinder', { radius: 0.42, height: 0.95, hollow: 0.035, round: 0.06 }, m),
|
|
718
|
+
part('handle', 'tube', { path: [[0.39, 0.74, 0], [0.62, 0.78, 0], [0.74, 0.5, 0], [0.64, 0.2, 0], [0.39, 0.22, 0]], radius: 0.05, smooth: true }, m),
|
|
719
|
+
],
|
|
720
|
+
text: { on: 'body', y: 0.5, height: 0.7, arc: 170, label: o.label ?? null },
|
|
721
|
+
scene: DEFAULT_SCENE,
|
|
722
|
+
};
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
can: {
|
|
726
|
+
summary: 'A drinks can: a body necked in at both ends, a lid and its rim.',
|
|
727
|
+
composition: 'cylinder (flare in, both ends) + cylinder (lid) + ring (rim)',
|
|
728
|
+
build: (o = {}) => ({
|
|
729
|
+
name: 'Can',
|
|
730
|
+
parts: [
|
|
731
|
+
part('body', 'cylinder', { radius: 0.33, height: 1.22, round: 0.02 }, { type: o.material ?? 'metal', color: o.color ?? '#C8102E' }, {
|
|
732
|
+
ops: [{ op: 'flare', amount: -0.14, start: 0.88, end: 'top' }, { op: 'flare', amount: -0.16, start: 0.93, end: 'bottom' }],
|
|
733
|
+
}),
|
|
734
|
+
part('lid', 'cylinder', { radius: 0.27, height: 0.012 }, { type: 'metal', color: o.accent ?? '#C9CDD3' }, { at: [0, 1.214, 0] }),
|
|
735
|
+
part('rim', 'ring', { radius: 0.277, thickness: 0.012 }, { type: 'chrome', color: o.accent ?? '#C9CDD3' }, { at: [0, 1.224, 0] }),
|
|
736
|
+
],
|
|
737
|
+
text: { on: 'body', y: 0.5, height: 0.7, arc: 200, label: o.label ?? null },
|
|
738
|
+
scene: DEFAULT_SCENE,
|
|
739
|
+
}),
|
|
740
|
+
},
|
|
741
|
+
bottle: {
|
|
742
|
+
summary: 'A shampoo or lotion bottle: a rounded body, a neck and a cap.',
|
|
743
|
+
composition: 'cylinder (rounded) + cylinder (neck) + cylinder (cap)',
|
|
744
|
+
build: (o = {}) => ({
|
|
745
|
+
name: 'Bottle',
|
|
746
|
+
parts: [
|
|
747
|
+
part('body', 'cylinder', { radius: 0.36, height: 1.25, round: 0.12 }, { type: o.material ?? 'plastic', color: o.color ?? '#E9E4DA' }),
|
|
748
|
+
part('neck', 'cylinder', { radius: 0.13, height: 0.14 }, { type: o.material ?? 'plastic', color: o.color ?? '#E9E4DA' }, { at: [0, 1.22, 0] }),
|
|
749
|
+
part('cap', 'cylinder', { radius: 0.17, height: 0.24, round: 0.03 }, { type: 'plastic', color: o.accent ?? '#2B2D31' }, { at: [0, 1.3, 0] }),
|
|
750
|
+
],
|
|
751
|
+
text: { on: 'body', y: 0.5, height: 0.6, arc: 170, label: o.label ?? null },
|
|
752
|
+
scene: DEFAULT_SCENE,
|
|
753
|
+
}),
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
/** A recipe's spec, ready to edit; unknown names fall back to a plain cylinder so something always draws. */
|
|
758
|
+
export function createObject(recipe: string, options: TSRecipeOptions = {}): TSObjectSpec {
|
|
759
|
+
const r = TS_RECIPES[recipe];
|
|
760
|
+
if (r) return r.build(options);
|
|
761
|
+
return {
|
|
762
|
+
name: recipe || 'Object',
|
|
763
|
+
parts: [part('body', 'cylinder', { radius: 0.36, height: 1.2, round: 0.03 }, { type: options.material ?? 'plastic', color: options.color ?? FALLBACK_COLOR })],
|
|
764
|
+
text: { on: 'body', y: 0.5, height: 0.6, arc: 160, label: options.label ?? null },
|
|
765
|
+
scene: DEFAULT_SCENE,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Everything an agent needs to compose an object, as JSON: the primitives and what each can take,
|
|
771
|
+
* the manipulators, the materials, how text attaches, the recipes and what cannot be done.
|
|
772
|
+
*/
|
|
773
|
+
export function describeConstruction() {
|
|
774
|
+
return {
|
|
775
|
+
frame: 'y is up and is the object’s axis; x is to the right of the camera, z toward it. Units are world units: a wine bottle’s body is 0.36 in radius. The camera frames whatever is built.',
|
|
776
|
+
part: {
|
|
777
|
+
shape: 'id, shape, params, ops (manipulators, in order), material { type, color, roughness?, metalness? }, at [x, y, z], rotate [x, y, z] in degrees',
|
|
778
|
+
origin: { base: '`at` is the centre of its base; it grows up', centre: '`at` is its centre', path: 'the path is already in the object’s frame; `at` shifts it' },
|
|
779
|
+
},
|
|
780
|
+
primitives: TS_PRIMITIVES,
|
|
781
|
+
manipulators: TS_MANIPULATORS,
|
|
782
|
+
materials: TS_OBJECT_MATERIALS,
|
|
783
|
+
text: {
|
|
784
|
+
summary: 'The text wraps one round part. It is its own layer: every typography, placement, deformation and material control of the config still applies to it.',
|
|
785
|
+
params: TS_OBJECT_TEXT_PARAMS,
|
|
786
|
+
label: 'a hex colour for a paper label under the text, or null to print straight onto the part',
|
|
787
|
+
carriers: TS_PRIMITIVE_IDS.filter((id) => TS_PRIMITIVES[id].capabilities.includes('text-wrap')),
|
|
788
|
+
sizing: {
|
|
789
|
+
unit: 'typography.fontSize, letterSpacing and placement.maxWidth are in units where the text area (text.arc degrees round the part) is 1000 wide',
|
|
790
|
+
frontArc: OBJECT_FRONT_ARC,
|
|
791
|
+
rule: 'only about frontArc degrees face the camera, so text wider than 1000 × frontArc / text.arc units runs out of sight; text taller than 1000 × areaHeight / areaWidth units leaves the area',
|
|
792
|
+
helper: 'fitObjectText(config) sets fontSize to the largest that fits the front face; createTypeSurface({ object }) already calls it',
|
|
793
|
+
},
|
|
794
|
+
rules: ['the carrier sits on the axis, unturned (at x = z = 0, rotate 0)', `the carrier takes only: ${TS_MANIPULATOR_IDS.filter((id) => TS_MANIPULATORS[id].keepsRound).join(', ')}`],
|
|
795
|
+
},
|
|
796
|
+
scene: { table: TS_OBJECT_TABLES, backdrop: 'two hex colours, top and bottom' },
|
|
797
|
+
view: {
|
|
798
|
+
summary: 'The camera looks straight at the front of the standing object. To see it from another side, turn the object, not the camera.',
|
|
799
|
+
states: { wrapped: 'standing, front-on (the default)', lying: 'lying on the table, diagonal', turned: 'turned to show its side', tipped: 'tipped toward the camera to show the top or the opening' },
|
|
800
|
+
apply: 'applySurfaceState(config, state)',
|
|
801
|
+
controls: {
|
|
802
|
+
'deformation.tiltX': '−1…1: turns the object about its own axis (left/right); the text goes round with it',
|
|
803
|
+
'deformation.tiltY': '−1…1: tips it; negative brings the top toward the camera, positive the base',
|
|
804
|
+
'view.roll': 'degrees: leans the object in the picture (−28 is the lying pose)',
|
|
805
|
+
'view.zoom': '1 fills the frame',
|
|
806
|
+
},
|
|
807
|
+
avoid: 'view.yaw and view.pitch orbit the camera about the table, not the object: yaw spins the picture and pitch looks up from under the base. Leave both at 0 for objects.',
|
|
808
|
+
},
|
|
809
|
+
recipes: Object.fromEntries(Object.entries(TS_RECIPES).map(([id, r]) => [id, {
|
|
810
|
+
summary: r.summary, composition: r.composition,
|
|
811
|
+
// Every default is only a starting value: pass options, or edit the spec the recipe returns.
|
|
812
|
+
defaults: { color: r.build().parts[0]!.material.color, material: r.build().parts[0]!.material.type, label: null },
|
|
813
|
+
options: {
|
|
814
|
+
color: 'hex: the main part’s colour', material: `one of materials: the main part’s material (${TS_OBJECT_MATERIAL_IDS.join(', ')})`,
|
|
815
|
+
accent: 'hex: the secondary parts (rim, lid, cap)', label: 'hex for a paper label under the text, or null to print on the part',
|
|
816
|
+
},
|
|
817
|
+
use: `createObject('${id}', { color, material }) or createTypeSurface({ text, object: '${id}', objectColor, objectMaterial })`,
|
|
818
|
+
}])),
|
|
819
|
+
limits: [
|
|
820
|
+
`at most ${MAX_OBJECT_PARTS} parts`,
|
|
821
|
+
'no boolean cut or union: parts overlap, they do not subtract. A hollow comes from `hollow` or from a profile drawn down the inside',
|
|
822
|
+
'text wraps round parts only; for a flat face (a card, a box side, a poster) use a sheet surface instead',
|
|
823
|
+
'one text block per object',
|
|
824
|
+
'materials are opaque: glass is dark and reflective, not see-through',
|
|
825
|
+
'the lighting is one studio key light with a soft shadow; it is not configurable',
|
|
826
|
+
],
|
|
827
|
+
};
|
|
828
|
+
}
|