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