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.
- package/README.md +43 -20
- package/package.json +1 -1
- package/trid/index.d.ts +210 -7
- package/trid/index.js +10 -3
- package/trid/math/projection.js +8 -2
- package/trid/math/transform.js +4 -5
- package/trid/modeling/query.js +706 -0
- package/trid/render/canvasRenderer.js +268 -5
- package/trid/render/draw.js +12 -2
- package/trid/render/lights.js +14 -1
- package/trid/render/post.js +155 -1
- package/trid/render/shadows.js +184 -0
- package/trid/render/shapes.js +42 -23
- package/trid/render/webglRenderer.js +348 -17
- package/trid/scene.js +1 -1
- package/trid/tools/index.js +1 -0
- package/trid/tools/selectionOutline.js +244 -0
- package/trid/tools/transformGizmo.js +9 -1
|
@@ -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();
|