trid3d 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -7
- package/package.json +1 -1
- package/trid/index.d.ts +565 -11
- package/trid/index.js +18 -1
- package/trid/modeling/bevel.js +596 -0
- package/trid/modeling/blobs.js +465 -0
- package/trid/modeling/cleanup.js +471 -0
- package/trid/modeling/curves.js +156 -0
- package/trid/modeling/deform.js +191 -0
- package/trid/modeling/frames.js +97 -0
- package/trid/modeling/loft.js +363 -0
- package/trid/modeling/subdivide.js +215 -0
- package/trid/modeling/sweep.js +169 -0
- package/trid/modeling/vec.js +35 -0
- package/trid/render/canvasRenderer.js +99 -48
- package/trid/render/effects.js +16 -9
- package/trid/render/grid.js +177 -0
- package/trid/render/lights.js +162 -0
- package/trid/render/post.js +325 -0
- package/trid/render/shapes.js +91 -3
- package/trid/render/webglRenderer.js +341 -19
- package/trid/scene.js +48 -6
- package/trid/tools/helpers.js +107 -9
- package/trid/tools/index.js +1 -1
- package/trid/tools/transformGizmo.js +40 -6
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post effects: shaders run on the whole picture after the scene is drawn
|
|
3
|
+
* (bloom, vignette, color grading, film grain...). Tools draw on top
|
|
4
|
+
* afterwards, so handles and overlays aren't affected.
|
|
5
|
+
*
|
|
6
|
+
* scene.post = [bloom(), grade({ saturation: 1.2 }), vignette()];
|
|
7
|
+
*
|
|
8
|
+
* They run in the list's order, each on the result of the one before.
|
|
9
|
+
* A post effect is a plain object, like a material effect:
|
|
10
|
+
* - `glsl`: GLSL code defining `vec4 post(vec4 color, Frame f)`, run once
|
|
11
|
+
* per pixel (WebGL renderer). `color` is the picture at `f.uv`
|
|
12
|
+
* - `passes`: instead of `glsl`, a list of passes run one after the other,
|
|
13
|
+
* each `{ glsl, scale }` (`scale`: size of that pass's picture, 0.5 = half,
|
|
14
|
+
* cheaper for blurs). Bloom is made this way
|
|
15
|
+
* - `canvas`: optional `(ctx, frame, uniforms) => void` that changes the 2D
|
|
16
|
+
* canvas directly. The canvas renderer uses it (WebGL too, for effects
|
|
17
|
+
* without `glsl` or `passes`). Without it, the canvas renderer skips the effect
|
|
18
|
+
* - `uniforms`: settings used by the code, like `{ uStrength: 0.5 }`. Same
|
|
19
|
+
* types as material effects; read every frame
|
|
20
|
+
* - `enabled: false` turns the effect off without removing it
|
|
21
|
+
*
|
|
22
|
+
* In GLSL, every pass can call:
|
|
23
|
+
* - `frame(uv)`: the previous pass's picture (the first pass: the picture
|
|
24
|
+
* this effect starts from)
|
|
25
|
+
* - `source(uv)`: the picture this effect starts from
|
|
26
|
+
* Colors are premultiplied by alpha (the same as plain colors where the
|
|
27
|
+
* picture is opaque, which it is when the scene has an opaque background).
|
|
28
|
+
*
|
|
29
|
+
* With post effects on, the WebGL renderer draws the scene into a texture
|
|
30
|
+
* first. On WebGL 2 it keeps antialiasing (multisampling); on WebGL 1 edges
|
|
31
|
+
* get a little rougher.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { parseColor } from "./color.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a post effect knows about the picture. GLSL fields and the canvas
|
|
38
|
+
* function's `frame` argument have the same names.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {Object} Frame
|
|
41
|
+
* @property {{x:number,y:number}} uv - GLSL only: position in the picture, 0..1 (0, 0 = bottom left)
|
|
42
|
+
* @property {{x:number,y:number}} size - Size of the picture being made, in pixels (GLSL: `vec2`;
|
|
43
|
+
* canvas: `width` / `height` instead)
|
|
44
|
+
* @property {{x:number,y:number}} texel - GLSL only: one pixel of the input picture, in uv units
|
|
45
|
+
* @property {number} time - Seconds since the renderer started
|
|
46
|
+
* @property {number[]} background - The scene's background color [r, g, b, a]
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The GLSL declaration of `Frame`, and the `frame` / `source` helpers. */
|
|
50
|
+
export const FRAME_GLSL = `
|
|
51
|
+
struct Frame {
|
|
52
|
+
vec2 uv;
|
|
53
|
+
vec2 size;
|
|
54
|
+
vec2 texel;
|
|
55
|
+
float time;
|
|
56
|
+
vec4 background;
|
|
57
|
+
};
|
|
58
|
+
uniform sampler2D uFrame;
|
|
59
|
+
uniform sampler2D uSource;
|
|
60
|
+
vec4 frame(vec2 uv) { return texture2D(uFrame, uv); }
|
|
61
|
+
vec4 source(vec2 uv) { return texture2D(uSource, uv); }
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
/** A post effect's passes, as `{ glsl, scale }`. Empty when it has no GLSL (canvas only). */
|
|
65
|
+
export function postPasses(effect) {
|
|
66
|
+
if (Array.isArray(effect.passes)) return effect.passes.filter((p) => p?.glsl).map((p) => ({ glsl: p.glsl, scale: p.scale > 0 ? p.scale : 1 }));
|
|
67
|
+
return effect.glsl ? [{ glsl: effect.glsl, scale: 1 }] : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The effects of a list that are on. */
|
|
71
|
+
export function activePost(list) {
|
|
72
|
+
return Array.isArray(list) ? list.filter((e) => e && e.enabled !== false) : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Runs post effects' `canvas` functions on a scene's 2D canvas.
|
|
77
|
+
* Used by the renderers after drawing.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} scene
|
|
80
|
+
* @param {Object[]} list - Post effects (the ones off, or without `canvas`, are skipped)
|
|
81
|
+
* @param {number} start - When the renderer started (`performance.now()`), for `time`
|
|
82
|
+
*/
|
|
83
|
+
export function runCanvasPost(scene, list, start) {
|
|
84
|
+
const effects = activePost(list).filter((e) => typeof e.canvas === "function");
|
|
85
|
+
if (!effects.length) return;
|
|
86
|
+
const { ctx, canvas } = scene;
|
|
87
|
+
const frame = {
|
|
88
|
+
width: canvas.width,
|
|
89
|
+
height: canvas.height,
|
|
90
|
+
time: (performance.now() - start) / 1000,
|
|
91
|
+
canvas,
|
|
92
|
+
rgba: (v) => (typeof v === "string" ? parseColor(ctx, v).map((x, i) => (i < 3 ? x / 255 : x)) : v),
|
|
93
|
+
};
|
|
94
|
+
frame.background = frame.rgba(scene.background ?? "#000000");
|
|
95
|
+
for (const e of effects) {
|
|
96
|
+
ctx.save();
|
|
97
|
+
try {
|
|
98
|
+
e.canvas(ctx, frame, e.uniforms ?? {});
|
|
99
|
+
} finally {
|
|
100
|
+
ctx.restore();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Built-in post effects ---
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Darkens the corners of the picture.
|
|
109
|
+
*
|
|
110
|
+
* @param {Object} [opts]
|
|
111
|
+
* @param {number} [opts.strength=0.5] - How dark the corners get (0..1)
|
|
112
|
+
* @param {number} [opts.radius=0.5] - Where the darkening starts: 0 = center, 1 = corners
|
|
113
|
+
* @param {number} [opts.softness=0.5] - How far it takes to reach `strength`
|
|
114
|
+
* @param {string} [opts.color="#000000"] - Color the corners turn to
|
|
115
|
+
*/
|
|
116
|
+
export function vignette({ strength = 0.5, radius = 0.5, softness = 0.5, color = "#000000" } = {}) {
|
|
117
|
+
return {
|
|
118
|
+
name: "vignette",
|
|
119
|
+
uniforms: { uStrength: strength, uRadius: radius, uSoftness: softness, uColor: color },
|
|
120
|
+
glsl: `
|
|
121
|
+
vec4 post(vec4 color, Frame f) {
|
|
122
|
+
float r = length((f.uv - 0.5) * 2.0) / 1.41421356; // 0 at the center, 1 in the corners
|
|
123
|
+
float v = smoothstep(uRadius, uRadius + max(uSoftness, 1e-3), r) * uStrength;
|
|
124
|
+
return vec4(mix(color.rgb, uColor.rgb * color.a, v), color.a);
|
|
125
|
+
}`,
|
|
126
|
+
canvas(ctx, f, u) {
|
|
127
|
+
const [r, g, b] = f.rgba(u.uColor);
|
|
128
|
+
const rgba = (a) => `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
|
|
129
|
+
ctx.translate(f.width / 2, f.height / 2);
|
|
130
|
+
ctx.scale(f.width / 2, f.height / 2); // the picture is -1..1 both ways: the circle becomes an ellipse
|
|
131
|
+
const r0 = u.uRadius * Math.SQRT2, r1 = (u.uRadius + Math.max(u.uSoftness, 1e-3)) * Math.SQRT2;
|
|
132
|
+
const grad = ctx.createRadialGradient(0, 0, r0, 0, 0, r1);
|
|
133
|
+
for (const t of [0, 0.25, 0.5, 0.75, 1]) grad.addColorStop(t, rgba(t * t * (3 - 2 * t) * u.uStrength));
|
|
134
|
+
ctx.fillStyle = grad;
|
|
135
|
+
ctx.fillRect(-1, -1, 2, 2);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Color grading: brightness, contrast, saturation and a tint, in that order
|
|
142
|
+
* (like the CSS filters of the same names).
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} [opts]
|
|
145
|
+
* @param {number} [opts.brightness=1] - Multiplies the colors (1 = unchanged)
|
|
146
|
+
* @param {number} [opts.contrast=1] - Pushes colors away from (> 1) or toward (< 1) middle gray
|
|
147
|
+
* @param {number} [opts.saturation=1] - 0 = gray, 1 = unchanged, > 1 = more vivid
|
|
148
|
+
* @param {string} [opts.tint="#ffffff"] - Multiplies the colors by this one
|
|
149
|
+
*/
|
|
150
|
+
export function grade({ brightness = 1, contrast = 1, saturation = 1, tint = "#ffffff" } = {}) {
|
|
151
|
+
return {
|
|
152
|
+
name: "grade",
|
|
153
|
+
uniforms: { uBrightness: brightness, uContrast: contrast, uSaturation: saturation, uTint: tint },
|
|
154
|
+
glsl: `
|
|
155
|
+
vec4 post(vec4 color, Frame f) {
|
|
156
|
+
if (color.a <= 0.0) return color;
|
|
157
|
+
vec3 c = color.rgb / color.a;
|
|
158
|
+
c = clamp(c * uBrightness, 0.0, 1.0);
|
|
159
|
+
c = clamp((c - 0.5) * uContrast + 0.5, 0.0, 1.0);
|
|
160
|
+
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
|
161
|
+
c = clamp(mix(vec3(l), c, uSaturation), 0.0, 1.0) * uTint.rgb;
|
|
162
|
+
return vec4(c * color.a, color.a);
|
|
163
|
+
}`,
|
|
164
|
+
canvas(ctx, f, u) {
|
|
165
|
+
if (supportsFilter(ctx) && (u.uBrightness !== 1 || u.uContrast !== 1 || u.uSaturation !== 1)) {
|
|
166
|
+
const copy = scratch(f.width, f.height);
|
|
167
|
+
copy.getContext("2d").drawImage(f.canvas, 0, 0);
|
|
168
|
+
ctx.filter = `brightness(${u.uBrightness}) contrast(${u.uContrast}) saturate(${u.uSaturation})`;
|
|
169
|
+
ctx.globalCompositeOperation = "copy";
|
|
170
|
+
ctx.drawImage(copy, 0, 0);
|
|
171
|
+
ctx.filter = "none";
|
|
172
|
+
}
|
|
173
|
+
const t = f.rgba(u.uTint);
|
|
174
|
+
if (t[0] < 1 || t[1] < 1 || t[2] < 1) {
|
|
175
|
+
ctx.globalCompositeOperation = "multiply";
|
|
176
|
+
ctx.fillStyle = u.uTint;
|
|
177
|
+
ctx.fillRect(0, 0, f.width, f.height);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Film grain: a little random noise over the picture.
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} [opts]
|
|
187
|
+
* @param {number} [opts.amount=0.08] - How strong the noise is (0..1)
|
|
188
|
+
* @param {number} [opts.size=1] - Size of a grain, in pixels
|
|
189
|
+
* @param {boolean} [opts.animated=true] - New noise every frame (false: the same noise stays)
|
|
190
|
+
*/
|
|
191
|
+
export function grain({ amount = 0.08, size = 1, animated = true } = {}) {
|
|
192
|
+
return {
|
|
193
|
+
name: "grain",
|
|
194
|
+
uniforms: { uAmount: amount, uGrain: size, uAnimated: animated },
|
|
195
|
+
glsl: `
|
|
196
|
+
float grainHash(vec2 p) {
|
|
197
|
+
vec3 q = fract(vec3(p.xyx) * 0.1031);
|
|
198
|
+
q += dot(q, q.yzx + 33.33);
|
|
199
|
+
return fract((q.x + q.y) * q.z);
|
|
200
|
+
}
|
|
201
|
+
vec4 post(vec4 color, Frame f) {
|
|
202
|
+
vec2 cell = floor(f.uv * f.size / max(uGrain, 1.0));
|
|
203
|
+
float t = uAnimated > 0.5 ? floor(f.time * 24.0) : 0.0;
|
|
204
|
+
float n = grainHash(cell + vec2(t * 17.0, t * 31.0)) - 0.5;
|
|
205
|
+
return vec4(clamp(color.rgb + n * uAmount * color.a, 0.0, 1.0), color.a);
|
|
206
|
+
}`,
|
|
207
|
+
canvas(ctx, f, u) {
|
|
208
|
+
const tile = noiseTile();
|
|
209
|
+
const pattern = ctx.createPattern(tile, "repeat");
|
|
210
|
+
const s = Math.max(1, u.uGrain);
|
|
211
|
+
const frameNo = u.uAnimated ? Math.floor(f.time * 24) : 0;
|
|
212
|
+
// A different spot of the tile each frame
|
|
213
|
+
const ox = (frameNo * 37) % tile.width, oy = (frameNo * 61) % tile.height;
|
|
214
|
+
pattern.setTransform?.(new DOMMatrix([s, 0, 0, s, -ox * s, -oy * s]));
|
|
215
|
+
ctx.globalCompositeOperation = "overlay";
|
|
216
|
+
ctx.globalAlpha = Math.min(1, u.uAmount * 4);
|
|
217
|
+
ctx.fillStyle = pattern;
|
|
218
|
+
ctx.fillRect(0, 0, f.width, f.height);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Glow around bright parts: what's brighter than `threshold` is blurred
|
|
225
|
+
* and added back on top.
|
|
226
|
+
*
|
|
227
|
+
* On the canvas renderer it's an approximation (CSS filters can't cut at a
|
|
228
|
+
* brightness exactly), and it needs a browser with canvas `filter` support.
|
|
229
|
+
*
|
|
230
|
+
* @param {Object} [opts]
|
|
231
|
+
* @param {number} [opts.threshold=0.7] - Brightness (0..1) where the glow starts
|
|
232
|
+
* @param {number} [opts.strength=0.8] - How bright the glow is
|
|
233
|
+
* @param {number} [opts.radius=1] - How far it spreads (1 = about 25 pixels)
|
|
234
|
+
*/
|
|
235
|
+
export function bloom({ threshold = 0.7, strength = 0.8, radius = 1 } = {}) {
|
|
236
|
+
// 9-tap Gaussian blur, along one direction
|
|
237
|
+
const blur = (dir) => `
|
|
238
|
+
vec4 post(vec4 color, Frame f) {
|
|
239
|
+
vec2 step = ${dir} * f.texel * uRadius * 1.5;
|
|
240
|
+
vec3 c = frame(f.uv).rgb * 0.227027;
|
|
241
|
+
c += (frame(f.uv + step).rgb + frame(f.uv - step).rgb) * 0.1945946;
|
|
242
|
+
c += (frame(f.uv + step * 2.0).rgb + frame(f.uv - step * 2.0).rgb) * 0.1216216;
|
|
243
|
+
c += (frame(f.uv + step * 3.0).rgb + frame(f.uv - step * 3.0).rgb) * 0.054054;
|
|
244
|
+
c += (frame(f.uv + step * 4.0).rgb + frame(f.uv - step * 4.0).rgb) * 0.016216;
|
|
245
|
+
return vec4(c, 1.0);
|
|
246
|
+
}`;
|
|
247
|
+
return {
|
|
248
|
+
name: "bloom",
|
|
249
|
+
uniforms: { uThreshold: threshold, uStrength: strength, uRadius: radius },
|
|
250
|
+
passes: [
|
|
251
|
+
{
|
|
252
|
+
// The bright parts only, at half size (four pixels averaged into one)
|
|
253
|
+
scale: 0.5,
|
|
254
|
+
glsl: `
|
|
255
|
+
vec4 post(vec4 color, Frame f) {
|
|
256
|
+
vec2 o = f.texel * 0.5;
|
|
257
|
+
vec3 c = (frame(f.uv - o).rgb + frame(f.uv + o).rgb
|
|
258
|
+
+ frame(f.uv + vec2(o.x, -o.y)).rgb + frame(f.uv + vec2(-o.x, o.y)).rgb) * 0.25;
|
|
259
|
+
float b = max(c.r, max(c.g, c.b));
|
|
260
|
+
return vec4(c * smoothstep(uThreshold - 0.1, uThreshold + 0.1, b), 1.0);
|
|
261
|
+
}`,
|
|
262
|
+
},
|
|
263
|
+
{ scale: 0.25, glsl: blur("vec2(1.0, 0.0)") },
|
|
264
|
+
{ scale: 0.25, glsl: blur("vec2(0.0, 1.0)") },
|
|
265
|
+
{
|
|
266
|
+
// Added on top of the picture
|
|
267
|
+
scale: 1,
|
|
268
|
+
glsl: `
|
|
269
|
+
vec4 post(vec4 color, Frame f) {
|
|
270
|
+
vec4 s = source(f.uv);
|
|
271
|
+
vec3 g = frame(f.uv).rgb * uStrength;
|
|
272
|
+
return vec4(min(s.rgb + g, 1.0), min(1.0, max(s.a, max(g.r, max(g.g, g.b)))));
|
|
273
|
+
}`,
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
canvas(ctx, f, u) {
|
|
277
|
+
if (!supportsFilter(ctx)) return;
|
|
278
|
+
const copy = scratch(f.width, f.height);
|
|
279
|
+
const c = copy.getContext("2d");
|
|
280
|
+
c.clearRect(0, 0, f.width, f.height);
|
|
281
|
+
// brightness, then strong contrast: what's darker than about `threshold` goes black
|
|
282
|
+
const t = Math.min(0.99, Math.max(0.05, u.uThreshold));
|
|
283
|
+
c.filter = `brightness(${0.375 / t}) contrast(4) blur(${Math.max(1, 10 * u.uRadius)}px)`;
|
|
284
|
+
c.drawImage(f.canvas, 0, 0);
|
|
285
|
+
c.filter = "none";
|
|
286
|
+
ctx.globalCompositeOperation = "lighter";
|
|
287
|
+
ctx.globalAlpha = Math.min(1, u.uStrength);
|
|
288
|
+
ctx.drawImage(copy, 0, 0);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Helpers for the canvas versions ---
|
|
294
|
+
|
|
295
|
+
function supportsFilter(ctx) {
|
|
296
|
+
return typeof ctx.filter === "string";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let scratchCanvas = null;
|
|
300
|
+
/** A spare canvas of a size (one, reused). */
|
|
301
|
+
function scratch(w, h) {
|
|
302
|
+
scratchCanvas ??= document.createElement("canvas");
|
|
303
|
+
if (scratchCanvas.width !== w || scratchCanvas.height !== h) {
|
|
304
|
+
scratchCanvas.width = w;
|
|
305
|
+
scratchCanvas.height = h;
|
|
306
|
+
}
|
|
307
|
+
return scratchCanvas;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let tile = null;
|
|
311
|
+
/** 128 × 128 gray noise, made once. */
|
|
312
|
+
function noiseTile() {
|
|
313
|
+
if (tile) return tile;
|
|
314
|
+
tile = document.createElement("canvas");
|
|
315
|
+
tile.width = tile.height = 128;
|
|
316
|
+
const c = tile.getContext("2d");
|
|
317
|
+
const img = c.createImageData(128, 128);
|
|
318
|
+
for (let i = 0; i < img.data.length; i += 4) {
|
|
319
|
+
const v = Math.floor(Math.random() * 256);
|
|
320
|
+
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
|
|
321
|
+
img.data[i + 3] = 255;
|
|
322
|
+
}
|
|
323
|
+
c.putImageData(img, 0, 0);
|
|
324
|
+
return tile;
|
|
325
|
+
}
|
package/trid/render/shapes.js
CHANGED
|
@@ -13,7 +13,8 @@ function cubeWire(size = 0.5) {
|
|
|
13
13
|
* @param {number} h - height (y)
|
|
14
14
|
* @param {number} d - depth (z)
|
|
15
15
|
*/
|
|
16
|
-
function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
|
|
16
|
+
function rectPrismWire(w = 0.6, h = 0.3, d = 0.4, radius = 0, segments = 4) {
|
|
17
|
+
if (radius > 0) return roundedBox(w, h, d, radius, segments);
|
|
17
18
|
const x = w / 2, y = h / 2, z = d / 2;
|
|
18
19
|
|
|
19
20
|
const mesh = {
|
|
@@ -51,6 +52,91 @@ function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
|
|
|
51
52
|
return withFaceUVs(mesh);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A box with rounded edges and corners, centered on the origin: each side is
|
|
57
|
+
* a grid whose outer rows bend around the edges (`segments` rows per
|
|
58
|
+
* rounded edge, evenly spread over the quarter circle). Vertices along the
|
|
59
|
+
* edges are shared, so smooth shading has no seams. Like the plain box,
|
|
60
|
+
* each side has its own material number (front, back, top, bottom, right,
|
|
61
|
+
* left) and the whole texture, upright as seen from outside.
|
|
62
|
+
*/
|
|
63
|
+
function roundedBox(w, h, d, radius, segments) {
|
|
64
|
+
const half = { x: Math.abs(w) / 2, y: Math.abs(h) / 2, z: Math.abs(d) / 2 };
|
|
65
|
+
const r = Math.min(radius, half.x, half.y, half.z);
|
|
66
|
+
const s = Math.max(1, Math.floor(segments));
|
|
67
|
+
const inner = { x: half.x - r, y: half.y - r, z: half.z - r };
|
|
68
|
+
// Grid lines across a side: `s` rows around each rounded edge, one flat span between
|
|
69
|
+
// (where the edges meet, as on a cube of radius = half its size, the flat span is skipped)
|
|
70
|
+
const ticks = (a) => {
|
|
71
|
+
const arc = Array.from({ length: s }, (_, i) => Math.tan((Math.PI / 4) * (1 - i / s)) * r);
|
|
72
|
+
const lo = arc.map((t) => -inner[a] - t), hi = [...arc].reverse().map((t) => inner[a] + t);
|
|
73
|
+
return inner[a] > 1e-12 ? [...lo, -inner[a], inner[a], ...hi] : [...lo, 0, ...hi];
|
|
74
|
+
};
|
|
75
|
+
// The point on the flat side pulled onto the rounded surface
|
|
76
|
+
const round = (p) => {
|
|
77
|
+
const c = { x: Math.max(-inner.x, Math.min(inner.x, p.x)), y: Math.max(-inner.y, Math.min(inner.y, p.y)), z: Math.max(-inner.z, Math.min(inner.z, p.z)) };
|
|
78
|
+
const dx = p.x - c.x, dy = p.y - c.y, dz = p.z - c.z, l = Math.hypot(dx, dy, dz);
|
|
79
|
+
return l > 1e-12 ? { x: c.x + (dx / l) * r, y: c.y + (dy / l) * r, z: c.z + (dz / l) * r } : p;
|
|
80
|
+
};
|
|
81
|
+
// Sides in the plain box's order: front (-z), back (+z), top, bottom, right (+x), left (-x).
|
|
82
|
+
// The texture goes like on the plain box (withFaceUVs): up is +Y (±Z on the top and bottom), right = normal × up
|
|
83
|
+
const axisOf = (v) => (v.x ? ["x", Math.sign(v.x)] : v.y ? ["y", Math.sign(v.y)] : ["z", Math.sign(v.z)]);
|
|
84
|
+
const sides = [["z", -1], ["z", 1], ["y", 1], ["y", -1], ["x", 1], ["x", -1]].map(([n, sign]) => {
|
|
85
|
+
const nv = { x: 0, y: 0, z: 0 };
|
|
86
|
+
nv[n] = sign;
|
|
87
|
+
const up = n === "y" ? { x: 0, y: 0, z: sign } : { x: 0, y: 1, z: 0 };
|
|
88
|
+
const right = { x: nv.y * up.z - nv.z * up.y, y: nv.z * up.x - nv.x * up.z, z: nv.x * up.y - nv.y * up.x };
|
|
89
|
+
return { n, sign, u: axisOf(right), v: axisOf(up) };
|
|
90
|
+
});
|
|
91
|
+
const vertices = [], faces = [], uvs = [], faceUVs = [], faceMaterials = [];
|
|
92
|
+
const shared = new Map();
|
|
93
|
+
const key = (p) => `${p.x.toFixed(9)},${p.y.toFixed(9)},${p.z.toFixed(9)}`;
|
|
94
|
+
sides.forEach((side, m) => {
|
|
95
|
+
const [ua, us] = side.u, [va, vs] = side.v;
|
|
96
|
+
const tu = ticks(ua), tv = ticks(va);
|
|
97
|
+
const ids = [], uvIds = [];
|
|
98
|
+
for (const b of tv) {
|
|
99
|
+
for (const a of tu) {
|
|
100
|
+
const p = { x: 0, y: 0, z: 0 };
|
|
101
|
+
p[side.n] = side.sign * half[side.n];
|
|
102
|
+
p[ua] = a;
|
|
103
|
+
p[va] = b;
|
|
104
|
+
const q = round(p);
|
|
105
|
+
const k = key(q);
|
|
106
|
+
if (!shared.has(k)) shared.set(k, vertices.push(q) - 1);
|
|
107
|
+
ids.push(shared.get(k));
|
|
108
|
+
uvIds.push(uvs.push({ u: (us * a + half[ua]) / (2 * half[ua] || 1), v: (vs * b + half[va]) / (2 * half[va] || 1) }) - 1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const nu = tu.length;
|
|
112
|
+
for (let j = 0; j + 1 < tv.length; j++) {
|
|
113
|
+
for (let i = 0; i + 1 < nu; i++) {
|
|
114
|
+
const c = [j * nu + i, j * nu + i + 1, (j + 1) * nu + i + 1, (j + 1) * nu + i];
|
|
115
|
+
// Clockwise from outside
|
|
116
|
+
const p = c.map((t) => vertices[ids[t]]);
|
|
117
|
+
const e1 = { x: p[1].x - p[0].x, y: p[1].y - p[0].y, z: p[1].z - p[0].z };
|
|
118
|
+
const e2 = { x: p[3].x - p[0].x, y: p[3].y - p[0].y, z: p[3].z - p[0].z };
|
|
119
|
+
const nz = { x: e1.y * e2.z - e1.z * e2.y, y: e1.z * e2.x - e1.x * e2.z, z: e1.x * e2.y - e1.y * e2.x }[side.n] * side.sign;
|
|
120
|
+
const order = nz < 0 ? [...c].reverse() : c;
|
|
121
|
+
faces.push(order.map((t) => ids[t]));
|
|
122
|
+
faceUVs.push(order.map((t) => uvIds[t]));
|
|
123
|
+
faceMaterials.push(m);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// Faces that collapsed where grid points met (a cube rounded into a ball) are dropped
|
|
128
|
+
const keep = faces.map((f) => new Set(f).size >= 3);
|
|
129
|
+
const out = {
|
|
130
|
+
vertices,
|
|
131
|
+
faces: faces.filter((_, i) => keep[i]).map((f) => f.filter((v, i) => v !== f[(i + f.length - 1) % f.length])),
|
|
132
|
+
uvs,
|
|
133
|
+
faceUVs: faceUVs.filter((_, i) => keep[i]),
|
|
134
|
+
faceMaterials: faceMaterials.filter((_, i) => keep[i]),
|
|
135
|
+
};
|
|
136
|
+
out.faceUVs = out.faceUVs.map((uv, i) => (uv.length === out.faces[i].length ? uv : uv.slice(0, out.faces[i].length)));
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
54
140
|
/**
|
|
55
141
|
* Gives every face the whole texture, upright as seen from outside:
|
|
56
142
|
* side faces have +Y up, the top face has +Z up, the bottom face -Z.
|
|
@@ -548,8 +634,10 @@ export function scaleMesh(mesh, sx = 1, sy = 1, sz = 1) {
|
|
|
548
634
|
*/
|
|
549
635
|
export function makeShape(kind, opts = {}) {
|
|
550
636
|
switch (kind.toLowerCase()) {
|
|
551
|
-
case "cube": return
|
|
552
|
-
|
|
637
|
+
case "cube": return opts.radius > 0
|
|
638
|
+
? rectPrismWire(opts.size ?? 0.5, opts.size ?? 0.5, opts.size ?? 0.5, opts.radius, opts.segments ?? 4)
|
|
639
|
+
: cubeWire(opts.size ?? 0.5);
|
|
640
|
+
case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6, opts.radius ?? 0, opts.segments ?? 4);
|
|
553
641
|
case "sphere":return sphereWire(opts.r ?? 0.45, opts.rings ?? 12, opts.segments ?? 24);
|
|
554
642
|
case "cylinder": return cylinder(opts.r, opts.h, opts.segments);
|
|
555
643
|
case "cone": return cone(opts.r, opts.h, opts.segments, opts.rTop);
|