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.
@@ -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
+ }
@@ -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 cubeWire(opts.size ?? 0.5);
552
- case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6);
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);