trid3d 0.5.0 → 0.7.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,244 @@
1
+ import { worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { transformPoint, determinantMat4 } from "../math/matrix.js";
3
+ import { worldObjects } from "../sceneGraph.js";
4
+ import { edgeSegments, wireEdges } from "../render/draw.js";
5
+ import { materialsOf, clipPolygon } from "../render/canvasRenderer.js";
6
+ import { newell } from "../render/normals.js";
7
+
8
+ /**
9
+ * A colored line around selected objects, like the selection in a 3D editor.
10
+ * Where something in front hides part of them, that part's outline is drawn
11
+ * faintly, so you can still see where the whole selection is.
12
+ *
13
+ * It draws on top of the finished picture (after post effects), so it looks
14
+ * the same with both renderers. Groups are outlined as a whole: their
15
+ * children's shapes join into one outline.
16
+ *
17
+ * Add it before tools that draw handles (like `TransformGizmo`), so the
18
+ * handles stay on top, or use `TransformGizmo({ outline: true })`.
19
+ *
20
+ * @example
21
+ * const selection = scene.use(new SelectionOutline());
22
+ * selection.targets = [box];
23
+ */
24
+ export class SelectionOutline {
25
+ /**
26
+ * @param {Object} [opts]
27
+ * @param {Object[]|(() => Object[])} [opts.targets=[]] - Objects to outline, or a function
28
+ * asked every frame (like `() => gizmo.targets`)
29
+ * @param {string} [opts.color="#ffa64d"] - Line color
30
+ * @param {number} [opts.width=2] - Line width in CSS pixels
31
+ * @param {number} [opts.hidden=0.35] - Opacity of the outline where the objects are hidden
32
+ * behind others (0: not shown)
33
+ */
34
+ constructor({ targets = [], color = "#ffa64d", width = 2, hidden = 0.35 } = {}) {
35
+ this.targets = targets;
36
+ this.color = color;
37
+ this.width = width;
38
+ this.hidden = hidden;
39
+ /** Hide the outline without removing the tool. */
40
+ this.visible = true;
41
+ this._canvases = [];
42
+ }
43
+
44
+ /** The objects outlined this frame. */
45
+ current() {
46
+ const t = typeof this.targets === "function" ? this.targets() : this.targets;
47
+ return Array.isArray(t) ? t.filter(Boolean) : [];
48
+ }
49
+
50
+ draw(ctx, scene) {
51
+ if (!this.visible) return;
52
+ const targets = this.current();
53
+ if (!targets.length) return;
54
+ const { canvas, camera } = scene;
55
+ const W = canvas.width, H = canvas.height;
56
+ const near = camera.near ?? DEFAULT_NEAR;
57
+ const r = Math.max(0.5, this.width * (W / (canvas.clientWidth || W))); // canvas pixels
58
+
59
+ // The selected objects' faces and lines on screen
60
+ const selected = new Set(worldObjects(targets).filter((o) => o.mesh));
61
+ const sel = [];
62
+ for (const obj of selected) shapes(obj, scene, near, sel, true);
63
+ if (!sel.length) return;
64
+
65
+ // Only the area around them is worked on
66
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
67
+ for (const s of sel) {
68
+ for (const p of s.pts) {
69
+ x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
70
+ x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
71
+ }
72
+ }
73
+ const pad = Math.ceil(r) + 3;
74
+ const rect = {
75
+ x: Math.max(0, Math.floor(x0) - pad), y: Math.max(0, Math.floor(y0) - pad),
76
+ };
77
+ rect.w = Math.min(W, Math.ceil(x1) + pad) - rect.x;
78
+ rect.h = Math.min(H, Math.ceil(y1) + pad) - rect.y;
79
+ if (rect.w <= 0 || rect.h <= 0) return;
80
+
81
+ // Everything else that has faces there, which can hide the selection
82
+ const all = [...sel];
83
+ for (const obj of worldObjects(scene)) {
84
+ if (!obj.mesh || selected.has(obj) || !materialsOf(obj)) continue;
85
+ shapes(obj, scene, near, all, false, rect);
86
+ }
87
+ all.sort((a, b) => b.depth - a.depth);
88
+
89
+ const [full, seen, rim] = [0, 1, 2].map((i) => this._canvas(i, rect.w, rect.h));
90
+ // The whole selection, and the part of it that's in front (drawn back to front:
91
+ // nearer faces of other objects erase it)
92
+ paint(full, sel, rect, () => true);
93
+ paint(seen, all, rect, (s) => s.selected);
94
+
95
+ const out = (mask, alpha) => {
96
+ this._rim(mask, rim, r);
97
+ ctx.save();
98
+ ctx.globalAlpha = alpha;
99
+ ctx.drawImage(rim, rect.x, rect.y);
100
+ ctx.restore();
101
+ };
102
+ if (this.hidden > 0) out(full, this.hidden);
103
+ out(seen, 1);
104
+ }
105
+
106
+ /**
107
+ * The ring around a mask: the mask spread `r` pixels in every direction,
108
+ * colored, minus the mask itself.
109
+ */
110
+ _rim(mask, rim, r) {
111
+ const c = rim.getContext("2d");
112
+ c.globalCompositeOperation = "source-over";
113
+ c.clearRect(0, 0, rim.width, rim.height);
114
+ const radii = [];
115
+ for (let k = 1; k <= r; k++) radii.push(k);
116
+ if (r % 1 || !radii.length) radii.push(r);
117
+ for (const k of radii) {
118
+ const n = Math.max(8, Math.ceil(2 * Math.PI * k));
119
+ for (let i = 0; i < n; i++) {
120
+ const a = (i / n) * 2 * Math.PI;
121
+ c.drawImage(mask, Math.cos(a) * k, Math.sin(a) * k);
122
+ }
123
+ }
124
+ c.globalCompositeOperation = "source-in";
125
+ c.fillStyle = this.color;
126
+ c.fillRect(0, 0, rim.width, rim.height);
127
+ c.globalCompositeOperation = "destination-out";
128
+ c.drawImage(mask, 0, 0);
129
+ c.globalCompositeOperation = "source-over";
130
+ }
131
+
132
+ /** A spare canvas of a size (kept between frames). */
133
+ _canvas(i, w, h) {
134
+ let c = this._canvases[i];
135
+ if (!c) c = this._canvases[i] = document.createElement("canvas");
136
+ if (c.width !== w || c.height !== h) {
137
+ c.width = w;
138
+ c.height = h;
139
+ }
140
+ return c;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Pushes an object's faces seen from the front (and double-sided ones) as
146
+ * screen polygons `{ pts, depth, selected }`, or its lines for a wireframe.
147
+ * With `rect`, only shapes reaching into it.
148
+ */
149
+ function shapes(obj, scene, near, out, selected, rect) {
150
+ const { camera, canvas } = scene;
151
+ const cam = obj.mesh.vertices.map((v) => worldToCamera(transformPoint(obj.world, v), camera));
152
+ const toScreen = (p) => cameraToScreen(p, camera, canvas.width, canvas.height);
153
+ const inRect = (pts) => {
154
+ if (!rect) return true;
155
+ let a = Infinity, b = Infinity, c = -Infinity, d = -Infinity;
156
+ for (const p of pts) {
157
+ a = Math.min(a, p.x); b = Math.min(b, p.y);
158
+ c = Math.max(c, p.x); d = Math.max(d, p.y);
159
+ }
160
+ return c >= rect.x && a <= rect.x + rect.w && d >= rect.y && b <= rect.y + rect.h;
161
+ };
162
+ const mats = materialsOf(obj);
163
+
164
+ if (!mats) {
165
+ // A wireframe: its lines are its shape
166
+ const width = obj.lineWidth ?? scene.lineWidth ?? 1;
167
+ for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
168
+ const seg = clipNear(cam[i], cam[j], near);
169
+ if (!seg) continue;
170
+ const pts = seg.map(toScreen);
171
+ if (inRect(pts)) out.push({ pts, line: width, depth: (seg[0].z + seg[1].z) / 2, selected });
172
+ }
173
+ return;
174
+ }
175
+
176
+ const flip = determinantMat4(obj.world) < 0;
177
+ const faceMats = obj.mesh.faceMaterials;
178
+ for (const [F, face] of (obj.mesh.faces ?? []).entries()) {
179
+ if (face.length < 3) continue;
180
+ const pts = face.map((i) => cam[i]);
181
+ const n = newell(pts);
182
+ let cx = 0, cy = 0, cz = 0;
183
+ for (const p of pts) { cx += p.x; cy += p.y; cz += p.z; }
184
+ const back = (n.x * cx + n.y * cy + n.z * cz >= 0) !== flip;
185
+ if (back && !(mats[faceMats?.[F] ?? 0] ?? mats[0])?.doubleSided) continue;
186
+ const clipped = clipPolygon(pts, near);
187
+ if (clipped.length < 3) continue;
188
+ const screen = clipped.map(toScreen);
189
+ if (inRect(screen)) out.push({ pts: screen, depth: cz / pts.length, selected });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Draws shapes on a mask canvas, in order: the ones passing `keep` add to
195
+ * the mask, the others erase it. Each shape also gets a 1-pixel stroke, so
196
+ * neighboring faces leave no hairline gaps between them.
197
+ */
198
+ function paint(mask, list, rect, keep) {
199
+ const c = mask.getContext("2d");
200
+ c.clearRect(0, 0, mask.width, mask.height);
201
+ c.save();
202
+ c.translate(-rect.x, -rect.y);
203
+ c.fillStyle = c.strokeStyle = "#ffffff";
204
+ c.lineJoin = "round";
205
+ // Runs of shapes of the same kind are drawn as one path
206
+ for (let i = 0; i < list.length;) {
207
+ const add = keep(list[i]);
208
+ c.globalCompositeOperation = add ? "source-over" : "destination-out";
209
+ c.beginPath();
210
+ let lineWidth = 0;
211
+ const faces = new Path2D();
212
+ for (; i < list.length && keep(list[i]) === add; i++) {
213
+ const s = list[i];
214
+ if (s.line) {
215
+ c.moveTo(s.pts[0].x, s.pts[0].y);
216
+ c.lineTo(s.pts[1].x, s.pts[1].y);
217
+ lineWidth = Math.max(lineWidth, s.line);
218
+ continue;
219
+ }
220
+ // Same turning direction for every face, so overlapping ones add up instead of cancelling
221
+ const p = signedArea(s.pts) < 0 ? [...s.pts].reverse() : s.pts;
222
+ faces.moveTo(p[0].x, p[0].y);
223
+ for (let k = 1; k < p.length; k++) faces.lineTo(p[k].x, p[k].y);
224
+ faces.closePath();
225
+ }
226
+ if (lineWidth) {
227
+ c.lineWidth = lineWidth;
228
+ c.stroke();
229
+ }
230
+ c.lineWidth = 1;
231
+ c.fill(faces);
232
+ c.stroke(faces);
233
+ }
234
+ c.restore();
235
+ }
236
+
237
+ function signedArea(pts) {
238
+ let a = 0;
239
+ for (let i = 0; i < pts.length; i++) {
240
+ const p = pts[i], q = pts[(i + 1) % pts.length];
241
+ a += p.x * q.y - q.x * p.y;
242
+ }
243
+ return a;
244
+ }
@@ -4,6 +4,7 @@ import { meshBounds } from "../render/meshUtils.js";
4
4
  import { transformOpToLocal } from "../math/matrix.js";
5
5
  import { worldMatrix, worldPosition, ancestors } from "../sceneGraph.js";
6
6
  import { worldPerPixel } from "./helpers.js";
7
+ import { SelectionOutline } from "./selectionOutline.js";
7
8
 
8
9
  const AXES = [
9
10
  { dir: { x: 1, y: 0, z: 0 }, color: "#e8605a" },
@@ -49,6 +50,9 @@ const RING = 64;
49
50
  * grabbing a handle doesn't also orbit. Clicks that miss the handles fall
50
51
  * through to the tools below.
51
52
  *
53
+ * `outline: true` also draws a `SelectionOutline` around the targets, under
54
+ * the handles (give options instead of true to change its look).
55
+ *
52
56
  * @example
53
57
  * scene.use(new OrbitCamera());
54
58
  * const gizmo = scene.use(new TransformGizmo({ mode: "rotate" }));
@@ -67,8 +71,9 @@ export class TransformGizmo {
67
71
  * @param {(targets:Object[]) => void} [opts.onGrab] - A handle was grabbed
68
72
  * @param {(op:Object, targets:Object[]) => void} [opts.onChange] - While dragging: the operation since the grab
69
73
  * @param {(op:Object, targets:Object[]) => void} [opts.onDrop] - On release: the whole operation
74
+ * @param {boolean|Object} [opts.outline=false] - Outline the targets (options: see `SelectionOutline`)
70
75
  */
71
- constructor({ mode = "move", targets = [], size = 70, apply = true, pivot = "bounds", onGrab, onChange, onDrop } = {}) {
76
+ constructor({ mode = "move", targets = [], size = 70, apply = true, pivot = "bounds", onGrab, onChange, onDrop, outline = false } = {}) {
72
77
  this.mode = mode;
73
78
  this.targets = targets;
74
79
  this.size = size;
@@ -81,6 +86,8 @@ export class TransformGizmo {
81
86
  this.visible = true;
82
87
  /** True while a handle is being dragged. */
83
88
  this.dragging = false;
89
+ /** The outline around the targets (`outline` option), or null. */
90
+ this.outline = outline ? new SelectionOutline({ ...(outline === true ? {} : outline), targets: () => this.targets }) : null;
84
91
 
85
92
  this._handles = []; // from the last draw
86
93
  this._hover = -1;
@@ -114,6 +121,7 @@ export class TransformGizmo {
114
121
 
115
122
  draw(ctx, scene) {
116
123
  this._handles = [];
124
+ this.outline?.draw(ctx, scene); // even with the handles hidden: the targets are still selected
117
125
  if (!this.visible) return;
118
126
  // Rings and scale handles stay where the drag started; arrows follow the move
119
127
  const c = this._drag && this.mode !== "move" ? this._drag.center : this.center();