3draft 26.11.2005 → 26.11.4010
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/APP.md +121 -0
- package/README.md +69 -200
- package/dist/csg.d.ts +35 -0
- package/dist/csg.js +330 -0
- package/dist/gpu.d.ts +14 -2
- package/dist/gpu.js +139 -17
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -1
- package/dist/material.d.ts +32 -0
- package/dist/material.js +18 -0
- package/dist/svg.d.ts +9 -0
- package/dist/svg.js +117 -4
- package/dist/types.d.ts +2 -0
- package/dist/wire.d.ts +57 -0
- package/dist/wire.js +244 -0
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export declare const name: "3draft";
|
|
2
|
-
export declare const version = "26.11.
|
|
2
|
+
export declare const version = "26.11.4010";
|
|
3
3
|
export type { Vec3, Face, Mesh, Axes, ViewName, ViewSpec, Fit, Camera, Hit, Mark } from './types.js';
|
|
4
4
|
export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
|
|
5
5
|
export type { BoxOptions } from './mesh.js';
|
|
6
6
|
export { validate } from './check.js';
|
|
7
|
+
export { boolean, union, subtract, intersect, health } from './csg.js';
|
|
8
|
+
export type { BooleanOp, Health, Part } from './csg.js';
|
|
7
9
|
export type { Validation } from './check.js';
|
|
8
10
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
9
11
|
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
@@ -16,6 +18,8 @@ export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight,
|
|
|
16
18
|
export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
|
|
17
19
|
export { renderView, renderViews, renderSheet } from './svg.js';
|
|
18
20
|
export type { RenderOptions } from './svg.js';
|
|
21
|
+
export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
|
|
22
|
+
export type { Material, Palette, Surface } from './material.js';
|
|
19
23
|
export { displayVersion } from './version.js';
|
|
20
24
|
export interface Info {
|
|
21
25
|
name: string;
|
|
@@ -27,3 +31,5 @@ export interface Info {
|
|
|
27
31
|
export declare const info: () => Info;
|
|
28
32
|
export { initKernel } from './kernel.js';
|
|
29
33
|
export type { Kernel } from './kernel.js';
|
|
34
|
+
export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
|
|
35
|
+
export type { Wire } from './wire.js';
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// a target cross that lies on the surface under the cursor. No textures, no
|
|
5
5
|
// shading yet.
|
|
6
6
|
export const name = '3draft';
|
|
7
|
-
export const version = '26.11.
|
|
7
|
+
export const version = '26.11.4010';
|
|
8
8
|
export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
|
|
9
9
|
export { validate } from './check.js';
|
|
10
|
+
export { boolean, union, subtract, intersect, health } from './csg.js';
|
|
10
11
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
11
12
|
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
12
13
|
export { pick, crosshair } from './pick.js';
|
|
@@ -14,6 +15,7 @@ export { standOn } from './place.js';
|
|
|
14
15
|
export { paintOrder } from './order.js';
|
|
15
16
|
export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
|
|
16
17
|
export { renderView, renderViews, renderSheet } from './svg.js';
|
|
18
|
+
export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
|
|
17
19
|
export { displayVersion } from './version.js';
|
|
18
20
|
/** Short, machine-readable description of the current state of the package. */
|
|
19
21
|
export const info = () => ({
|
|
@@ -28,3 +30,4 @@ export const info = () => ({
|
|
|
28
30
|
],
|
|
29
31
|
});
|
|
30
32
|
export { initKernel } from './kernel.js';
|
|
33
|
+
export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Face } from './types.js';
|
|
2
|
+
export interface Material {
|
|
3
|
+
/** #rrggbb. */
|
|
4
|
+
color: string;
|
|
5
|
+
/** 0 = paint, 1 = metal (reflects the sky and the ground, tinted by the color). */
|
|
6
|
+
metal?: number;
|
|
7
|
+
/** 0 = mirror-like, 1 = matte; widens the highlight. */
|
|
8
|
+
rough?: number;
|
|
9
|
+
/** 1 = solid, 0 = invisible. Blended over what is behind it (WebGPU: a second pass, far to near). */
|
|
10
|
+
alpha?: number;
|
|
11
|
+
/** 0..5: how much the color glows on its own, unlit. Up to 1 it fades from lit to flat colour; above 1 (WebGPU `tonemap`) it is a light source: that many times brighter. */
|
|
12
|
+
emit?: number;
|
|
13
|
+
}
|
|
14
|
+
export type Palette = Record<string, Material>;
|
|
15
|
+
/** What a face is made of, all numbers filled in. */
|
|
16
|
+
export interface Surface {
|
|
17
|
+
color: string;
|
|
18
|
+
metal: number;
|
|
19
|
+
rough: number;
|
|
20
|
+
alpha: number;
|
|
21
|
+
emit: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const EMIT_MAX = 5;
|
|
24
|
+
export declare const FALLBACK_COLOR = "#c8c8c8";
|
|
25
|
+
export declare const DEFAULTS: {
|
|
26
|
+
readonly metal: 0;
|
|
27
|
+
readonly rough: 0.7;
|
|
28
|
+
readonly alpha: 1;
|
|
29
|
+
readonly emit: 0;
|
|
30
|
+
};
|
|
31
|
+
/** The surface of a face: its palette entry when it names one, else just its color with default properties. */
|
|
32
|
+
export declare const surfaceOf: (face: Pick<Face, "color" | "mat">, palette?: Palette) => Surface;
|
package/dist/material.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Materials are data. A palette is a plain object { name: Material }; a face points at an entry by name
|
|
2
|
+
// (`face.mat`) and keeps its own `color` as a fallback, so a mesh still looks right without the palette.
|
|
3
|
+
// One shader draws all of them; the numbers below are the only knobs.
|
|
4
|
+
const clamp = (n, fallback, max = 1) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(max, n)) : fallback);
|
|
5
|
+
export const EMIT_MAX = 5;
|
|
6
|
+
export const FALLBACK_COLOR = '#c8c8c8';
|
|
7
|
+
export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0 };
|
|
8
|
+
/** The surface of a face: its palette entry when it names one, else just its color with default properties. */
|
|
9
|
+
export const surfaceOf = (face, palette) => {
|
|
10
|
+
const m = face.mat !== undefined ? palette?.[face.mat] : undefined;
|
|
11
|
+
return {
|
|
12
|
+
color: m?.color ?? face.color ?? FALLBACK_COLOR,
|
|
13
|
+
metal: clamp(m?.metal, DEFAULTS.metal),
|
|
14
|
+
rough: clamp(m?.rough, DEFAULTS.rough),
|
|
15
|
+
alpha: clamp(m?.alpha, DEFAULTS.alpha),
|
|
16
|
+
emit: clamp(m?.emit, DEFAULTS.emit, EMIT_MAX),
|
|
17
|
+
};
|
|
18
|
+
};
|
package/dist/svg.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Palette } from './material.js';
|
|
1
2
|
import type { Fit, Mark, Mesh, ViewName, ViewSpec } from './types.js';
|
|
2
3
|
export interface RenderOptions {
|
|
3
4
|
/** Panel size in px (default 400). */
|
|
@@ -6,6 +7,14 @@ export interface RenderOptions {
|
|
|
6
7
|
cull?: boolean;
|
|
7
8
|
/** Draw the vertices as circles (default true). */
|
|
8
9
|
points?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Send only what can be seen (default false). Faces are first painted into a small id buffer, far to
|
|
12
|
+
* near; a face none of whose pixels survives is covered by nearer ones and is left out, and so is a
|
|
13
|
+
* point no visible face reaches. The picture stays the same, the SVG gets much smaller.
|
|
14
|
+
*/
|
|
15
|
+
occlude?: boolean;
|
|
16
|
+
/** Named materials (`face.mat`). The SVG shows their color and alpha; metal, rough and emit are for the GPU view. */
|
|
17
|
+
palette?: Palette;
|
|
9
18
|
stroke?: string;
|
|
10
19
|
pointColor?: string;
|
|
11
20
|
background?: string;
|
package/dist/svg.js
CHANGED
|
@@ -5,6 +5,74 @@ import { dot, norm, minus } from './vec.js';
|
|
|
5
5
|
import { faceNormal } from './mesh.js';
|
|
6
6
|
import { paintOrder } from './order.js';
|
|
7
7
|
import { VIEW_ORDER, makeCamera, project } from './camera.js';
|
|
8
|
+
import { surfaceOf } from './material.js';
|
|
9
|
+
/**
|
|
10
|
+
* Depth-tested scanline fill of one polygon into an id buffer. `w` grows towards the eye (1/depth in a
|
|
11
|
+
* perspective view, -depth in an orthographic one) and is linear across a planar face on the screen.
|
|
12
|
+
* A pixel that ties with what is there (coplanar faces) keeps both ids alive for the painter's order.
|
|
13
|
+
*/
|
|
14
|
+
const fill = (ids, zb, size, pts, w, id, alive, solid = true) => {
|
|
15
|
+
const test = (x, y, v) => {
|
|
16
|
+
if (x < 0 || y < 0 || x >= size || y >= size)
|
|
17
|
+
return;
|
|
18
|
+
const at = y * size + x;
|
|
19
|
+
const old = zb[at];
|
|
20
|
+
const tie = Math.abs(v - old) <= 1e-5 * Math.abs(old);
|
|
21
|
+
if (!solid) {
|
|
22
|
+
// a see-through face is visible where nothing nearer hides it, and hides nothing itself
|
|
23
|
+
if (v > old || tie)
|
|
24
|
+
alive.add(id);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (v > old || tie) {
|
|
28
|
+
if (tie && ids[at] >= 0)
|
|
29
|
+
alive.add(ids[at]);
|
|
30
|
+
zb[at] = Math.max(v, old);
|
|
31
|
+
ids[at] = id;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
// the outline too: a face thinner than a pixel still shows as a line
|
|
35
|
+
for (let a = 0; a < pts.length; a++) {
|
|
36
|
+
const b = (a + 1) % pts.length;
|
|
37
|
+
const n = Math.ceil(Math.max(Math.abs(pts[b].x - pts[a].x), Math.abs(pts[b].y - pts[a].y)));
|
|
38
|
+
for (let i = 0; i <= n; i++) {
|
|
39
|
+
const t = n ? i / n : 0;
|
|
40
|
+
test(Math.floor(pts[a].x + t * (pts[b].x - pts[a].x)), Math.floor(pts[a].y + t * (pts[b].y - pts[a].y)), w[a] + t * (w[b] - w[a]));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let lo = Infinity;
|
|
44
|
+
let hi = -Infinity;
|
|
45
|
+
for (const p of pts)
|
|
46
|
+
(lo = Math.min(lo, p.y), (hi = Math.max(hi, p.y)));
|
|
47
|
+
const y0 = Math.max(0, Math.ceil(lo - 0.5));
|
|
48
|
+
const y1 = Math.min(size - 1, Math.floor(hi - 0.5));
|
|
49
|
+
const cross = [];
|
|
50
|
+
for (let y = y0; y <= y1; y++) {
|
|
51
|
+
const yc = y + 0.5;
|
|
52
|
+
cross.length = 0;
|
|
53
|
+
for (let a = 0; a < pts.length; a++) {
|
|
54
|
+
const b = (a + 1) % pts.length;
|
|
55
|
+
const p = pts[a];
|
|
56
|
+
const q = pts[b];
|
|
57
|
+
if ((p.y <= yc && q.y > yc) || (q.y <= yc && p.y > yc)) {
|
|
58
|
+
const t = (yc - p.y) / (q.y - p.y);
|
|
59
|
+
cross.push({ x: p.x + t * (q.x - p.x), w: w[a] + t * (w[b] - w[a]) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
cross.sort((a, b) => a.x - b.x);
|
|
63
|
+
for (let k = 0; k + 1 < cross.length; k += 2) {
|
|
64
|
+
const l = cross[k];
|
|
65
|
+
const r = cross[k + 1];
|
|
66
|
+
const x0 = Math.max(0, Math.ceil(l.x - 0.5));
|
|
67
|
+
const x1 = Math.min(size - 1, Math.floor(r.x - 0.5));
|
|
68
|
+
const span = r.x - l.x || 1;
|
|
69
|
+
for (let x = x0; x <= x1; x++) {
|
|
70
|
+
const v = l.w + ((x + 0.5 - l.x) / span) * (r.w - l.w);
|
|
71
|
+
test(x, y, v);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
8
76
|
const defaults = {
|
|
9
77
|
size: 400,
|
|
10
78
|
cull: true,
|
|
@@ -58,6 +126,7 @@ const panel = (mesh, view, o, x = 0, y = 0) => {
|
|
|
58
126
|
}
|
|
59
127
|
if (!seen(screen))
|
|
60
128
|
return; // outside the panel
|
|
129
|
+
const surface = surfaceOf(face, o.palette);
|
|
61
130
|
items.push({
|
|
62
131
|
i,
|
|
63
132
|
vs: face.v,
|
|
@@ -66,22 +135,66 @@ const panel = (mesh, view, o, x = 0, y = 0) => {
|
|
|
66
135
|
n: norm(normal),
|
|
67
136
|
p: world[0],
|
|
68
137
|
eye: facing ? 1 : -1,
|
|
69
|
-
color:
|
|
138
|
+
color: surface.color,
|
|
139
|
+
alpha: surface.alpha,
|
|
70
140
|
depth: screen.reduce((s, p) => s + p.depth, 0) / screen.length,
|
|
71
141
|
pts: screen.map((p) => `${num(p.x)},${num(p.y)}`).join(' '),
|
|
72
142
|
});
|
|
73
143
|
});
|
|
74
|
-
|
|
144
|
+
// Occlusion: a depth buffer finds the faces that own a pixel; only those go on to the painter's order.
|
|
145
|
+
let idBuf = null;
|
|
146
|
+
let visible = items;
|
|
147
|
+
if (o.occlude) {
|
|
148
|
+
const buf = new Int32Array(o.size * o.size).fill(-1);
|
|
149
|
+
const zb = new Float32Array(o.size * o.size).fill(-Infinity);
|
|
150
|
+
const alive = new Set();
|
|
151
|
+
items.forEach((it, idx) => {
|
|
152
|
+
const w = it.screen.map((p) => (cam.ortho ? -p.depth : 1 / p.depth));
|
|
153
|
+
fill(buf, zb, o.size, it.screen, w, idx, alive, it.alpha >= 1);
|
|
154
|
+
});
|
|
155
|
+
for (const id of buf)
|
|
156
|
+
if (id >= 0)
|
|
157
|
+
alive.add(id);
|
|
158
|
+
const remap = new Map(); // item index -> index among the visible faces
|
|
159
|
+
visible = items.filter((_, idx) => alive.has(idx) && (remap.set(idx, remap.size), true));
|
|
160
|
+
idBuf = buf.map((id) => (id < 0 ? -1 : (remap.get(id) ?? -1)));
|
|
161
|
+
}
|
|
162
|
+
let ordered = paintOrder(visible); // far to near
|
|
163
|
+
if (idBuf) {
|
|
164
|
+
// ids point into `visible`; the painter's order permutes it
|
|
165
|
+
const at = new Map();
|
|
166
|
+
visible.forEach((it, n) => at.set(it, n));
|
|
167
|
+
const perm = new Int32Array(visible.length);
|
|
168
|
+
ordered.forEach((it, n) => (perm[at.get(it)] = n));
|
|
169
|
+
idBuf = idBuf.map((id) => (id < 0 ? -1 : perm[id]));
|
|
170
|
+
}
|
|
75
171
|
// A point is drawn right after the nearest painted face that holds it, so
|
|
76
172
|
// faces painted later (closer to the camera) still cover it.
|
|
77
173
|
const last = new Map();
|
|
78
174
|
ordered.forEach((it, idx) => it.vs.forEach((k) => last.set(k, idx)));
|
|
175
|
+
// a point is visible when a visible face that holds it owns a pixel at (or right next to) it
|
|
176
|
+
const reached = (k) => {
|
|
177
|
+
const px = Math.round(proj[k].x);
|
|
178
|
+
const py = Math.round(proj[k].y);
|
|
179
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
180
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
181
|
+
const x = px + dx;
|
|
182
|
+
const y = py + dy;
|
|
183
|
+
if (x < 0 || y < 0 || x >= o.size || y >= o.size)
|
|
184
|
+
continue;
|
|
185
|
+
const id = idBuf[y * o.size + x];
|
|
186
|
+
if (id >= 0 && ordered[id].vs.includes(k))
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
};
|
|
79
192
|
const body = ordered.flatMap((it, idx) => {
|
|
80
193
|
const polygon = `<polygon data-face="${it.i}" data-depth="${num(it.depth)}" points="${it.pts}" ` +
|
|
81
|
-
`fill="${it.color}" stroke="${o.stroke}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
|
194
|
+
`fill="${it.color}"${it.alpha < 1 ? ` fill-opacity="${num(it.alpha)}"` : ''} stroke="${o.stroke}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
|
82
195
|
const dots = o.points
|
|
83
196
|
? it.vs
|
|
84
|
-
.filter((k) => last.get(k) === idx && (cam.ortho || proj[k].depth >= NEAR))
|
|
197
|
+
.filter((k) => last.get(k) === idx && (cam.ortho || proj[k].depth >= NEAR) && (!idBuf || reached(k)))
|
|
85
198
|
.sort((a, b) => a - b)
|
|
86
199
|
.map((k) => `<circle data-v="${k}" cx="${num(proj[k].x)}" cy="${num(proj[k].y)}" r="3" fill="${o.pointColor}"/>`)
|
|
87
200
|
: [];
|
package/dist/types.d.ts
CHANGED
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Mesh, Vec3, Face } from './types.js';
|
|
2
|
+
/** Lines drawn on / off a mesh: pairs of vertex indices. */
|
|
3
|
+
export type Wire = [number, number][];
|
|
4
|
+
/**
|
|
5
|
+
* Puts a vertex at `point`, which lies on face `face`. On an existing vertex it returns that one; on an edge it is
|
|
6
|
+
* added to both faces that share the edge (no crack); inside the face it splits the face into triangles around it.
|
|
7
|
+
* { mesh, index }
|
|
8
|
+
*/
|
|
9
|
+
export declare const insertVertex: (mesh: Mesh, face: number, point: Vec3) => {
|
|
10
|
+
mesh: Mesh;
|
|
11
|
+
index: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Cuts the face that holds vertices `a` and `b` (not next to each other) along the line a-b: two convex faces.
|
|
15
|
+
* `cut` is false when no face holds both, or they are already neighbours on every face.
|
|
16
|
+
*/
|
|
17
|
+
export declare const cutFace: (mesh: Mesh, a: number, b: number) => {
|
|
18
|
+
mesh: Mesh;
|
|
19
|
+
cut: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Moves vertex `i` to `to`. Polygons that use it become triangles, so every face stays planar. */
|
|
22
|
+
export declare const moveVertex: (mesh: Mesh, i: number, to: Vec3) => Mesh;
|
|
23
|
+
/**
|
|
24
|
+
* A face from a closed loop of vertices. It is wound to point away from `awayFrom` (default: the middle of the
|
|
25
|
+
* mesh), which is what a patch closing a gap in a surface wants; 4 or more vertices become a fan of triangles.
|
|
26
|
+
*/
|
|
27
|
+
export declare const fillLoop: (mesh: Mesh, loop: number[], face?: Partial<Face>, awayFrom?: Vec3) => Mesh;
|
|
28
|
+
/** Adds the line a-b (not twice, not a point). */
|
|
29
|
+
export declare const addLine: (wire: Wire, a: number, b: number) => Wire;
|
|
30
|
+
/**
|
|
31
|
+
* The shortest closed loop of lines through line [a, b], at most `max` vertices: [a, ..., b], or null.
|
|
32
|
+
* Close it with fillLoop.
|
|
33
|
+
*/
|
|
34
|
+
export declare const findLoop: (wire: Wire, [a, b]: [number, number], max?: number) => number[] | null;
|
|
35
|
+
/**
|
|
36
|
+
* The surface point of the mesh nearest to `p`, if it is within `tolerance`: { face, point } (point lies on the face),
|
|
37
|
+
* otherwise null. For placing a point that was drawn near a surface on the surface.
|
|
38
|
+
*/
|
|
39
|
+
export declare const nearestSurface: (mesh: Mesh, p: Vec3, tolerance: number) => {
|
|
40
|
+
face: number;
|
|
41
|
+
point: Vec3;
|
|
42
|
+
} | null;
|
|
43
|
+
/**
|
|
44
|
+
* Turns line segments (pairs of points) into wire on the mesh: each end becomes a vertex (an existing one within
|
|
45
|
+
* `tolerance`, a new one on the surface when it is near it, otherwise a free point in the air), each segment a line,
|
|
46
|
+
* and two ends on one face cut that face. Returns the new mesh and wire.
|
|
47
|
+
*/
|
|
48
|
+
export declare const wireFromSegments: (mesh: Mesh, wire: Wire, segments: [Vec3, Vec3][], tolerance: number) => {
|
|
49
|
+
mesh: Mesh;
|
|
50
|
+
wire: Wire;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Moves several vertices at once: `moves` is [[index, newPosition], ...]. A polygon whose vertices all moved by the
|
|
54
|
+
* same step (a face dragged as a whole) stays as it is; any other polygon that uses a moved vertex becomes triangles,
|
|
55
|
+
* so every face stays planar.
|
|
56
|
+
*/
|
|
57
|
+
export declare const moveVertexSet: (mesh: Mesh, moves: [number, Vec3][]) => Mesh;
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Drawing on a mesh: put a vertex on a surface, cut a face along a line between two vertices,
|
|
2
|
+
// move a vertex, close a loop of lines into a face. The wire is a list of [a, b] vertex pairs next to the mesh:
|
|
3
|
+
// its ends are ordinary mesh vertices, so moving a vertex moves every line (and every face) that uses it.
|
|
4
|
+
// Faces stay convex and planar: a vertex placed inside a face splits it into triangles, a cut splits a convex
|
|
5
|
+
// polygon into two convex ones, and a moved vertex turns the polygons it touches into triangles.
|
|
6
|
+
import { sub, add, scale, dot, len } from './vec.js';
|
|
7
|
+
import { faceNormal, bounds } from './mesh.js';
|
|
8
|
+
const sizeOf = (mesh) => bounds(mesh).radius || 1;
|
|
9
|
+
/** Distance from `p` to the segment a-b, and where along it (0..1). */
|
|
10
|
+
const toSegment = (p, a, b) => {
|
|
11
|
+
const ab = sub(b, a);
|
|
12
|
+
const l2 = dot(ab, ab) || 1;
|
|
13
|
+
const t = Math.max(0, Math.min(1, dot(sub(p, a), ab) / l2));
|
|
14
|
+
return { distance: len(sub(p, add(a, scale(ab, t)))), t };
|
|
15
|
+
};
|
|
16
|
+
const withFaces = (mesh, faces, positions = mesh.positions) => ({ ...mesh, positions, faces });
|
|
17
|
+
/**
|
|
18
|
+
* Puts a vertex at `point`, which lies on face `face`. On an existing vertex it returns that one; on an edge it is
|
|
19
|
+
* added to both faces that share the edge (no crack); inside the face it splits the face into triangles around it.
|
|
20
|
+
* { mesh, index }
|
|
21
|
+
*/
|
|
22
|
+
export const insertVertex = (mesh, face, point) => {
|
|
23
|
+
const eps = 1e-6 * sizeOf(mesh);
|
|
24
|
+
const f = mesh.faces[face];
|
|
25
|
+
const { positions } = mesh;
|
|
26
|
+
for (const v of f.v)
|
|
27
|
+
if (len(sub(positions[v], point)) <= eps * 10)
|
|
28
|
+
return { mesh, index: v };
|
|
29
|
+
const index = positions.length;
|
|
30
|
+
const next = [...positions, point];
|
|
31
|
+
const m = f.v.length;
|
|
32
|
+
for (let j = 0; j < m; j++) {
|
|
33
|
+
const a = f.v[j];
|
|
34
|
+
const b = f.v[(j + 1) % m];
|
|
35
|
+
if (toSegment(point, positions[a], positions[b]).distance > eps * 10)
|
|
36
|
+
continue;
|
|
37
|
+
// on the edge a-b: every face holding that edge (same positions, either direction) gets the new vertex
|
|
38
|
+
const faces = mesh.faces.map((g, gi) => {
|
|
39
|
+
const n = g.v.length;
|
|
40
|
+
for (let k = 0; k < n; k++) {
|
|
41
|
+
const p = positions[g.v[k]];
|
|
42
|
+
const q = positions[g.v[(k + 1) % n]];
|
|
43
|
+
const same = (len(sub(p, positions[a])) <= eps && len(sub(q, positions[b])) <= eps) || (len(sub(p, positions[b])) <= eps && len(sub(q, positions[a])) <= eps);
|
|
44
|
+
if (same)
|
|
45
|
+
return { ...g, v: [...g.v.slice(0, k + 1), index, ...g.v.slice(k + 1)] };
|
|
46
|
+
}
|
|
47
|
+
return gi === face ? g : g;
|
|
48
|
+
});
|
|
49
|
+
return { mesh: withFaces(mesh, faces, next), index };
|
|
50
|
+
}
|
|
51
|
+
// inside: a fan of triangles around the new vertex, same side as the face
|
|
52
|
+
const fan = f.v.map((v, j) => ({ ...f, v: [v, f.v[(j + 1) % m], index] }));
|
|
53
|
+
return { mesh: withFaces(mesh, [...mesh.faces.slice(0, face), ...fan, ...mesh.faces.slice(face + 1)], next), index };
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Cuts the face that holds vertices `a` and `b` (not next to each other) along the line a-b: two convex faces.
|
|
57
|
+
* `cut` is false when no face holds both, or they are already neighbours on every face.
|
|
58
|
+
*/
|
|
59
|
+
export const cutFace = (mesh, a, b) => {
|
|
60
|
+
for (let fi = 0; fi < mesh.faces.length; fi++) {
|
|
61
|
+
const f = mesh.faces[fi];
|
|
62
|
+
const ia = f.v.indexOf(a);
|
|
63
|
+
const ib = f.v.indexOf(b);
|
|
64
|
+
if (ia < 0 || ib < 0)
|
|
65
|
+
continue;
|
|
66
|
+
const m = f.v.length;
|
|
67
|
+
if ((ia - ib + m) % m === 1 || (ib - ia + m) % m === 1)
|
|
68
|
+
continue;
|
|
69
|
+
const walk = (from, to) => {
|
|
70
|
+
const out = [f.v[from]];
|
|
71
|
+
for (let k = from; k !== to;) {
|
|
72
|
+
k = (k + 1) % m;
|
|
73
|
+
out.push(f.v[k]);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const faces = [...mesh.faces.slice(0, fi), { ...f, v: walk(ia, ib) }, { ...f, v: walk(ib, ia) }, ...mesh.faces.slice(fi + 1)];
|
|
78
|
+
return { mesh: withFaces(mesh, faces), cut: true };
|
|
79
|
+
}
|
|
80
|
+
return { mesh, cut: false };
|
|
81
|
+
};
|
|
82
|
+
/** Moves vertex `i` to `to`. Polygons that use it become triangles, so every face stays planar. */
|
|
83
|
+
export const moveVertex = (mesh, i, to) => {
|
|
84
|
+
const positions = mesh.positions.map((p, k) => (k === i ? to : p));
|
|
85
|
+
const faces = [];
|
|
86
|
+
for (const f of mesh.faces) {
|
|
87
|
+
if (f.v.length <= 3 || !f.v.includes(i))
|
|
88
|
+
faces.push(f);
|
|
89
|
+
else
|
|
90
|
+
for (let j = 1; j < f.v.length - 1; j++)
|
|
91
|
+
faces.push({ ...f, v: [f.v[0], f.v[j], f.v[j + 1]] });
|
|
92
|
+
}
|
|
93
|
+
return withFaces(mesh, faces, positions);
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* A face from a closed loop of vertices. It is wound to point away from `awayFrom` (default: the middle of the
|
|
97
|
+
* mesh), which is what a patch closing a gap in a surface wants; 4 or more vertices become a fan of triangles.
|
|
98
|
+
*/
|
|
99
|
+
export const fillLoop = (mesh, loop, face = {}, awayFrom = bounds(mesh).center) => {
|
|
100
|
+
if (loop.length < 3)
|
|
101
|
+
return mesh;
|
|
102
|
+
const pts = loop.map((v) => mesh.positions[v]);
|
|
103
|
+
const centre = scale(pts.reduce((s, p) => add(s, p), [0, 0, 0]), 1 / pts.length);
|
|
104
|
+
const ordered = dot(faceNormal(pts), sub(centre, awayFrom)) >= 0 ? loop : [...loop].reverse();
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let j = 1; j < ordered.length - 1; j++)
|
|
107
|
+
out.push({ ...face, v: [ordered[0], ordered[j], ordered[j + 1]] });
|
|
108
|
+
return withFaces(mesh, [...mesh.faces, ...out]);
|
|
109
|
+
};
|
|
110
|
+
/** Adds the line a-b (not twice, not a point). */
|
|
111
|
+
export const addLine = (wire, a, b) => (a === b || wire.some(([p, q]) => (p === a && q === b) || (p === b && q === a)) ? wire : [...wire, [a, b]]);
|
|
112
|
+
/**
|
|
113
|
+
* The shortest closed loop of lines through line [a, b], at most `max` vertices: [a, ..., b], or null.
|
|
114
|
+
* Close it with fillLoop.
|
|
115
|
+
*/
|
|
116
|
+
export const findLoop = (wire, [a, b], max = 8) => {
|
|
117
|
+
const near = new Map();
|
|
118
|
+
for (const [p, q] of wire) {
|
|
119
|
+
if ((p === a && q === b) || (p === b && q === a))
|
|
120
|
+
continue; // not through the line itself
|
|
121
|
+
near.set(p, [...(near.get(p) ?? []), q]);
|
|
122
|
+
near.set(q, [...(near.get(q) ?? []), p]);
|
|
123
|
+
}
|
|
124
|
+
const from = new Map([[a, -1]]);
|
|
125
|
+
const queue = [a];
|
|
126
|
+
for (let head = 0; head < queue.length; head++) {
|
|
127
|
+
const v = queue[head];
|
|
128
|
+
if (v === b)
|
|
129
|
+
break;
|
|
130
|
+
for (const w of near.get(v) ?? [])
|
|
131
|
+
if (!from.has(w))
|
|
132
|
+
(from.set(w, v), queue.push(w));
|
|
133
|
+
}
|
|
134
|
+
if (!from.has(b))
|
|
135
|
+
return null;
|
|
136
|
+
const path = [];
|
|
137
|
+
for (let v = b; v !== -1; v = from.get(v))
|
|
138
|
+
path.unshift(v);
|
|
139
|
+
return path.length <= max ? path : null;
|
|
140
|
+
};
|
|
141
|
+
/** The point of triangle a-b-c closest to p (Ericson, Real-Time Collision Detection). */
|
|
142
|
+
const closestOnTriangle = (p, a, b, c) => {
|
|
143
|
+
const ab = sub(b, a);
|
|
144
|
+
const ac = sub(c, a);
|
|
145
|
+
const ap = sub(p, a);
|
|
146
|
+
const d1 = dot(ab, ap);
|
|
147
|
+
const d2 = dot(ac, ap);
|
|
148
|
+
if (d1 <= 0 && d2 <= 0)
|
|
149
|
+
return a;
|
|
150
|
+
const bp = sub(p, b);
|
|
151
|
+
const d3 = dot(ab, bp);
|
|
152
|
+
const d4 = dot(ac, bp);
|
|
153
|
+
if (d3 >= 0 && d4 <= d3)
|
|
154
|
+
return b;
|
|
155
|
+
const vc = d1 * d4 - d3 * d2;
|
|
156
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0)
|
|
157
|
+
return add(a, scale(ab, d1 / (d1 - d3)));
|
|
158
|
+
const cp = sub(p, c);
|
|
159
|
+
const d5 = dot(ab, cp);
|
|
160
|
+
const d6 = dot(ac, cp);
|
|
161
|
+
if (d6 >= 0 && d5 <= d6)
|
|
162
|
+
return c;
|
|
163
|
+
const vb = d5 * d2 - d1 * d6;
|
|
164
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0)
|
|
165
|
+
return add(a, scale(ac, d2 / (d2 - d6)));
|
|
166
|
+
const va = d3 * d6 - d5 * d4;
|
|
167
|
+
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
|
|
168
|
+
return add(b, scale(sub(c, b), (d4 - d3) / (d4 - d3 + (d5 - d6))));
|
|
169
|
+
const denom = 1 / (va + vb + vc);
|
|
170
|
+
return add(a, add(scale(ab, vb * denom), scale(ac, vc * denom)));
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* The surface point of the mesh nearest to `p`, if it is within `tolerance`: { face, point } (point lies on the face),
|
|
174
|
+
* otherwise null. For placing a point that was drawn near a surface on the surface.
|
|
175
|
+
*/
|
|
176
|
+
export const nearestSurface = (mesh, p, tolerance) => {
|
|
177
|
+
let best = null;
|
|
178
|
+
mesh.faces.forEach((f, face) => {
|
|
179
|
+
const pts = f.v.map((v) => mesh.positions[v]);
|
|
180
|
+
for (let j = 1; j < pts.length - 1; j++) {
|
|
181
|
+
const q = closestOnTriangle(p, pts[0], pts[j], pts[j + 1]);
|
|
182
|
+
const d = len(sub(q, p));
|
|
183
|
+
if (d <= tolerance && (!best || d < best.d))
|
|
184
|
+
best = { face, point: q, d };
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return best ? { face: best.face, point: best.point } : null;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Turns line segments (pairs of points) into wire on the mesh: each end becomes a vertex (an existing one within
|
|
191
|
+
* `tolerance`, a new one on the surface when it is near it, otherwise a free point in the air), each segment a line,
|
|
192
|
+
* and two ends on one face cut that face. Returns the new mesh and wire.
|
|
193
|
+
*/
|
|
194
|
+
export const wireFromSegments = (mesh, wire, segments, tolerance) => {
|
|
195
|
+
let m = mesh;
|
|
196
|
+
let w = wire;
|
|
197
|
+
const vertexAt = (p) => {
|
|
198
|
+
const near = m.positions.findIndex((q) => len(sub(q, p)) <= tolerance);
|
|
199
|
+
if (near >= 0)
|
|
200
|
+
return near;
|
|
201
|
+
const s = nearestSurface(m, p, tolerance);
|
|
202
|
+
if (s) {
|
|
203
|
+
const r = insertVertex(m, s.face, s.point);
|
|
204
|
+
m = r.mesh;
|
|
205
|
+
return r.index;
|
|
206
|
+
}
|
|
207
|
+
m = { ...m, positions: [...m.positions, p] };
|
|
208
|
+
return m.positions.length - 1;
|
|
209
|
+
};
|
|
210
|
+
for (const [a, b] of segments) {
|
|
211
|
+
const ia = vertexAt(a);
|
|
212
|
+
const ib = vertexAt(b);
|
|
213
|
+
if (ia === ib)
|
|
214
|
+
continue;
|
|
215
|
+
w = addLine(w, ia, ib);
|
|
216
|
+
m = cutFace(m, ia, ib).mesh;
|
|
217
|
+
}
|
|
218
|
+
return { mesh: m, wire: w };
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Moves several vertices at once: `moves` is [[index, newPosition], ...]. A polygon whose vertices all moved by the
|
|
222
|
+
* same step (a face dragged as a whole) stays as it is; any other polygon that uses a moved vertex becomes triangles,
|
|
223
|
+
* so every face stays planar.
|
|
224
|
+
*/
|
|
225
|
+
export const moveVertexSet = (mesh, moves) => {
|
|
226
|
+
const to = new Map(moves);
|
|
227
|
+
const positions = mesh.positions.map((p, k) => to.get(k) ?? p);
|
|
228
|
+
const step = (v) => sub(positions[v], mesh.positions[v]);
|
|
229
|
+
const faces = [];
|
|
230
|
+
for (const f of mesh.faces) {
|
|
231
|
+
if (f.v.length <= 3 || !f.v.some((v) => to.has(v))) {
|
|
232
|
+
faces.push(f);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const first = step(f.v[0]);
|
|
236
|
+
const rigid = f.v.every((v) => len(sub(step(v), first)) < 1e-9 * (bounds(mesh).radius || 1));
|
|
237
|
+
if (rigid)
|
|
238
|
+
faces.push(f);
|
|
239
|
+
else
|
|
240
|
+
for (let j = 1; j < f.v.length - 1; j++)
|
|
241
|
+
faces.push({ ...f, v: [f.v[0], f.v[j], f.v[j + 1]] });
|
|
242
|
+
}
|
|
243
|
+
return withFaces(mesh, faces, positions);
|
|
244
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "3draft",
|
|
3
|
-
"version": "26.11.
|
|
3
|
+
"version": "26.11.4010",
|
|
4
4
|
"description": "Minimalist AI first 3D engine: palette-texture, WASM core, WebGPU/WebGL2 renderer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dist",
|
|
17
17
|
"!dist/**/*.test.*",
|
|
18
18
|
"README.md",
|
|
19
|
+
"APP.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|