3draft 26.11.4010 → 26.11.11010

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/dist/index.d.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  export declare const name: "3draft";
2
- export declare const version = "26.11.4010";
2
+ export declare const version = "26.11.11010";
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';
7
+ export { boolean, union, subtract, intersect, health, splitParts } from './csg.js';
8
+ export { saw, planeThroughLine, planeThroughTriangle, sectionSegments } from './saw.js';
9
+ export type { CutPlane, Sawn } from './saw.js';
8
10
  export type { BooleanOp, Health, Part } from './csg.js';
9
11
  export type { Validation } from './check.js';
10
12
  export { extrudeFace, moveVertices } from './edit.js';
11
13
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
12
14
  export { pick, crosshair } from './pick.js';
15
+ export { instantiate, lodLevel } from './instances.js';
16
+ export type { Prototype, Placement } from './instances.js';
13
17
  export { standOn } from './place.js';
14
18
  export type { StandOptions, Frame } from './place.js';
15
19
  export { paintOrder } from './order.js';
16
20
  export type { PaintItem } from './order.js';
17
- export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
21
+ export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
22
+ export { createGl } from './gl.js';
18
23
  export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
19
24
  export { renderView, renderViews, renderSheet } from './svg.js';
20
25
  export type { RenderOptions } from './svg.js';
@@ -31,5 +36,8 @@ export interface Info {
31
36
  export declare const info: () => Info;
32
37
  export { initKernel } from './kernel.js';
33
38
  export type { Kernel } from './kernel.js';
34
- export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
39
+ export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet, reshape, outlineOf } from './wire.js';
40
+ export { triangulate } from './triangulate.js';
41
+ export { buildEdges, buildMarks, MARK_FLOATS, sceneEdges } from './gpu.js';
42
+ export type { Tri } from './triangulate.js';
35
43
  export type { Wire } from './wire.js';
package/dist/index.js CHANGED
@@ -4,16 +4,19 @@
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.4010';
7
+ export const version = '26.11.11010';
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
+ export { boolean, union, subtract, intersect, health, splitParts } from './csg.js';
11
+ export { saw, planeThroughLine, planeThroughTriangle, sectionSegments } from './saw.js';
11
12
  export { extrudeFace, moveVertices } from './edit.js';
12
13
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
13
14
  export { pick, crosshair } from './pick.js';
15
+ export { instantiate, lodLevel } from './instances.js';
14
16
  export { standOn } from './place.js';
15
17
  export { paintOrder } from './order.js';
16
- export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
18
+ export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
19
+ export { createGl } from './gl.js';
17
20
  export { renderView, renderViews, renderSheet } from './svg.js';
18
21
  export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
19
22
  export { displayVersion } from './version.js';
@@ -30,4 +33,6 @@ export const info = () => ({
30
33
  ],
31
34
  });
32
35
  export { initKernel } from './kernel.js';
33
- export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
36
+ export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet, reshape, outlineOf } from './wire.js';
37
+ export { triangulate } from './triangulate.js';
38
+ export { buildEdges, buildMarks, MARK_FLOATS, sceneEdges } from './gpu.js';
@@ -0,0 +1,19 @@
1
+ import type { Mesh, Vec3 } from './types.js';
2
+ export interface Prototype {
3
+ lods: Mesh[];
4
+ }
5
+ export interface Placement {
6
+ at: Vec3;
7
+ /** Size of the instance; a record scales each slot on its own ('*' is the default), e.g. a star core and its halo. */
8
+ scale: number | Record<string, number>;
9
+ /** Slot -> palette name. */
10
+ mats?: Record<string, string>;
11
+ /** Turn of the instance about its own centre: [tilt about x, turn about y] in radians (a galaxy disc seen from any side). */
12
+ rot?: [number, number];
13
+ /** Colour of every face (a tint on top of the material). */
14
+ color?: string;
15
+ }
16
+ /** A copy of the prototype mesh, scaled, moved and given its materials. Slots must not share vertices when scaled apart. */
17
+ export declare const instantiate: (proto: Mesh, p: Placement) => Mesh;
18
+ /** Which level of detail to draw for something that is `px` pixels across (its radius); `limits` are the pixel sizes where the next finer level starts. */
19
+ export declare const lodLevel: (px: number, levels: number, limits?: readonly number[]) => number;
@@ -0,0 +1,41 @@
1
+ // Prototypes and instances. A PROTOTYPE is a list of meshes of the same thing in decreasing detail (level of detail:
2
+ // lods[0] is the finest), all of unit size and centred on the origin, with material SLOTS in `Face.mat` ('core', 'halo'...).
3
+ // An INSTANCE is the prototype placed with a position, a size (one number, or one per slot) and its own slot -> palette
4
+ // map, so a scene file stores the shape once and one short record per star. The detail used on screen follows the size
5
+ // the thing has in pixels, so a far star costs a few faces and a near one a few dozen.
6
+ /** A copy of the prototype mesh, scaled, moved and given its materials. Slots must not share vertices when scaled apart. */
7
+ export const instantiate = (proto, p) => {
8
+ const slotOf = [];
9
+ for (const f of proto.faces)
10
+ for (const i of f.v)
11
+ slotOf[i] ??= f.mat ?? '';
12
+ const size = (slot) => (typeof p.scale === 'number' ? p.scale : (p.scale[slot] ?? p.scale['*'] ?? 1));
13
+ const positions = proto.positions.map((q, i) => {
14
+ const k = size(slotOf[i] ?? '');
15
+ let [x, y, z] = [q[0] * k, q[1] * k, q[2] * k];
16
+ if (p.rot) {
17
+ const [a, b] = p.rot;
18
+ [y, z] = [y * Math.cos(a) - z * Math.sin(a), y * Math.sin(a) + z * Math.cos(a)];
19
+ [x, z] = [x * Math.cos(b) + z * Math.sin(b), -x * Math.sin(b) + z * Math.cos(b)];
20
+ }
21
+ return [x + p.at[0], y + p.at[1], z + p.at[2]];
22
+ });
23
+ const faces = proto.faces.map((f) => {
24
+ const out = { v: f.v };
25
+ const mat = f.mat === undefined ? undefined : (p.mats?.[f.mat] ?? f.mat);
26
+ if (mat !== undefined)
27
+ out.mat = mat;
28
+ const color = p.color ?? f.color;
29
+ if (color !== undefined)
30
+ out.color = color;
31
+ return out;
32
+ });
33
+ return { positions, faces };
34
+ };
35
+ /** Which level of detail to draw for something that is `px` pixels across (its radius); `limits` are the pixel sizes where the next finer level starts. */
36
+ export const lodLevel = (px, levels, limits = [60, 22, 8, 3, 1]) => {
37
+ let level = 0;
38
+ while (level < levels - 1 && level < limits.length && px < limits[level])
39
+ level++;
40
+ return level;
41
+ };
package/dist/kernel.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // then every pick is one call that walks all triangles. Same answers as pick() in pick.ts.
4
4
  import { dot, norm, plus, times } from './vec.js';
5
5
  import { faceNormal } from './mesh.js';
6
+ import { triangulate } from './triangulate.js';
6
7
  import { ray } from './camera.js';
7
8
  import { KERNEL_BYTES } from './kernel-bytes.js';
8
9
  const POS = 64; // vertices start here, 24 bytes each (f64 x y z)
@@ -38,14 +39,14 @@ export const initKernel = async () => {
38
39
  f64[i * 3 + 1] = p[1];
39
40
  f64[i * 3 + 2] = p[2];
40
41
  }
41
- // faces are fanned into triangles (v0, vj, vj+1), each remembering its face number
42
+ // faces are cut into triangles (a fan, or ear clipping for a concave one), each remembering its face number
42
43
  const i32 = new Int32Array(memory.buffer, triBase, nt * 4);
43
44
  let at = 0;
44
45
  mesh.faces.forEach((f, face) => {
45
- for (let j = 1; j < f.v.length - 1; j++) {
46
- i32[at] = f.v[0];
47
- i32[at + 1] = f.v[j];
48
- i32[at + 2] = f.v[j + 1];
46
+ for (const [a, b, c] of triangulate(f.v.map((k) => mesh.positions[k]))) {
47
+ i32[at] = f.v[a];
48
+ i32[at + 1] = f.v[b];
49
+ i32[at + 2] = f.v[c];
49
50
  i32[at + 3] = face;
50
51
  at += 4;
51
52
  }
@@ -10,6 +10,8 @@ export interface Material {
10
10
  alpha?: number;
11
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
12
  emit?: number;
13
+ /** 0..1: soft, skin-like light. The light wraps round the form and a warm, scattered band shows where light turns to shade. */
14
+ soft?: number;
13
15
  }
14
16
  export type Palette = Record<string, Material>;
15
17
  /** What a face is made of, all numbers filled in. */
@@ -19,6 +21,7 @@ export interface Surface {
19
21
  rough: number;
20
22
  alpha: number;
21
23
  emit: number;
24
+ soft: number;
22
25
  }
23
26
  export declare const EMIT_MAX = 5;
24
27
  export declare const FALLBACK_COLOR = "#c8c8c8";
@@ -27,6 +30,7 @@ export declare const DEFAULTS: {
27
30
  readonly rough: 0.7;
28
31
  readonly alpha: 1;
29
32
  readonly emit: 0;
33
+ readonly soft: 0;
30
34
  };
31
35
  /** The surface of a face: its palette entry when it names one, else just its color with default properties. */
32
36
  export declare const surfaceOf: (face: Pick<Face, "color" | "mat">, palette?: Palette) => Surface;
package/dist/material.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const clamp = (n, fallback, max = 1) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(max, n)) : fallback);
5
5
  export const EMIT_MAX = 5;
6
6
  export const FALLBACK_COLOR = '#c8c8c8';
7
- export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0 };
7
+ export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0, soft: 0 };
8
8
  /** The surface of a face: its palette entry when it names one, else just its color with default properties. */
9
9
  export const surfaceOf = (face, palette) => {
10
10
  const m = face.mat !== undefined ? palette?.[face.mat] : undefined;
@@ -14,5 +14,6 @@ export const surfaceOf = (face, palette) => {
14
14
  rough: clamp(m?.rough, DEFAULTS.rough),
15
15
  alpha: clamp(m?.alpha, DEFAULTS.alpha),
16
16
  emit: clamp(m?.emit, DEFAULTS.emit, EMIT_MAX),
17
+ soft: clamp(m?.soft, DEFAULTS.soft),
17
18
  };
18
19
  };
package/dist/pick.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // the painter-sorted drawing is not.
4
4
  import { cross, dot, norm, plus, minus, times, scale } from './vec.js';
5
5
  import { faceNormal } from './mesh.js';
6
+ import { triangulate } from './triangulate.js';
6
7
  import { ray } from './camera.js';
7
8
  const EPS = 1e-9;
8
9
  // Moller-Trumbore ray/triangle test. Distance along the ray, or null.
@@ -41,8 +42,8 @@ export const pick = (mesh, cam, x, y, { cull = true } = {}) => {
41
42
  const front = dot(outward, dir) < 0;
42
43
  if (cull && !front)
43
44
  return;
44
- for (let j = 1; j < pts.length - 1; j++) {
45
- const t = hitTriangle(origin, dir, pts[0], pts[j], pts[j + 1]);
45
+ for (const [a, b, c] of triangulate(pts)) {
46
+ const t = hitTriangle(origin, dir, pts[a], pts[b], pts[c]);
46
47
  if (t !== null && (!best || t < best.distance)) {
47
48
  best = {
48
49
  face: i,
package/dist/saw.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Mesh, Vec3 } from './types.js';
2
+ /** A plane: a point on it and the unit normal. `front` is the side the normal points to. */
3
+ export interface CutPlane {
4
+ point: Vec3;
5
+ n: Vec3;
6
+ }
7
+ export interface Sawn {
8
+ /** Closed pieces on the normal's side of the plane. */
9
+ front: Mesh[];
10
+ /** Closed pieces on the other side. */
11
+ back: Mesh[];
12
+ }
13
+ /**
14
+ * The plane a drawn line gives: it contains the line and stands square on the face the line was drawn on
15
+ * (`faceNormal`), so it runs straight into the material and out beyond the ends of the line.
16
+ */
17
+ export declare const planeThroughLine: (a: Vec3, b: Vec3, faceNormal: Vec3) => CutPlane;
18
+ /** The plane through three points. Throws when they lie on one line. */
19
+ export declare const planeThroughTriangle: (a: Vec3, b: Vec3, c: Vec3) => CutPlane;
20
+ /**
21
+ * Saw `mesh` along `plane`. `mat` / `color` is the material of the two new faces (the cut surface).
22
+ * Throws when the mesh is not a closed solid, or when the plane does not go through it.
23
+ */
24
+ export declare const saw: (mesh: Mesh, plane: CutPlane, opts?: {
25
+ color?: string;
26
+ mat?: string;
27
+ }) => Sawn;
28
+ /** Where the plane meets the faces of the mesh: line segments for the preview of a cut. */
29
+ export declare const sectionSegments: (mesh: Mesh, plane: CutPlane) => [Vec3, Vec3][];
package/dist/saw.js ADDED
@@ -0,0 +1,121 @@
1
+ // The saw: one plane cuts a closed solid into closed solids, like a board on the bench.
2
+ // The cut is a half-space (a huge box with one face on the plane) run through the boolean, so both sides come out
3
+ // closed, every face stays planar, and the new face lies exactly in the saw plane (the "kerf" has no width).
4
+ // A solid that the plane splits into several pieces (a U shape cut across its arms) comes out as several meshes.
5
+ import { sub, cross, dot, len, norm, add, scale as mul } from './vec.js';
6
+ import { box, bounds } from './mesh.js';
7
+ import { subtract, intersect, splitParts } from './csg.js';
8
+ import { validate } from './check.js';
9
+ const MIN = 1e-9;
10
+ /**
11
+ * The plane a drawn line gives: it contains the line and stands square on the face the line was drawn on
12
+ * (`faceNormal`), so it runs straight into the material and out beyond the ends of the line.
13
+ */
14
+ export const planeThroughLine = (a, b, faceNormal) => {
15
+ const c = cross(sub(b, a), faceNormal);
16
+ if (len(c) < MIN * Math.max(1, len(sub(b, a))))
17
+ throw new Error('the line has no length, or it points along the face normal: it cannot give a plane');
18
+ return { point: a, n: norm(c) };
19
+ };
20
+ /** The plane through three points. Throws when they lie on one line. */
21
+ export const planeThroughTriangle = (a, b, c) => {
22
+ const x = cross(sub(b, a), sub(c, a));
23
+ const scale = Math.max(len(sub(b, a)), len(sub(c, a)), MIN);
24
+ if (len(x) < 1e-9 * scale * scale)
25
+ throw new Error('the three points lie on one line: they do not give a plane');
26
+ return { point: a, n: norm(x) };
27
+ };
28
+ const frame = (n) => {
29
+ const helper = Math.abs(n[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
30
+ const u = norm(cross(helper, n));
31
+ const v = cross(n, u);
32
+ return [u, v, n]; // right-handed: u x v = n
33
+ };
34
+ /**
35
+ * Saw `mesh` along `plane`. `mat` / `color` is the material of the two new faces (the cut surface).
36
+ * Throws when the mesh is not a closed solid, or when the plane does not go through it.
37
+ */
38
+ export const saw = (mesh, plane, opts = {}) => {
39
+ // A model of several shells (a cube with a drawn-and-pulled-out V standing on it, a loose open surface): every closed shell is cut by
40
+ // itself, a shell the plane misses, or one that is not closed, goes whole to the side its middle is on.
41
+ const parts = splitParts(mesh);
42
+ if (parts.length > 1) {
43
+ const n = norm(plane.n);
44
+ const front = [];
45
+ const back = [];
46
+ let solids = 0;
47
+ let cut = 0;
48
+ for (const part of parts) {
49
+ const pv = validate(part);
50
+ if (pv.closed && !pv.inside_out) {
51
+ solids++;
52
+ try {
53
+ const s = sawSolid(part, plane, opts);
54
+ front.push(...s.front);
55
+ back.push(...s.back);
56
+ cut++;
57
+ continue;
58
+ }
59
+ catch (err) {
60
+ if (!/does not go through/.test(String(err.message)))
61
+ throw err;
62
+ }
63
+ }
64
+ (dot(sub(bounds(part).center, plane.point), n) >= 0 ? front : back).push(part);
65
+ }
66
+ if (!solids)
67
+ throw new Error('saw: only a closed solid can be cut');
68
+ if (!cut)
69
+ throw new Error('the plane does not go through the object');
70
+ return { front, back };
71
+ }
72
+ return sawSolid(mesh, plane, opts);
73
+ };
74
+ const sawSolid = (mesh, plane, { color = '#c8c8c8', mat } = {}) => {
75
+ const v = validate(mesh);
76
+ if (!v.closed || v.inside_out)
77
+ throw new Error('saw: only a closed solid can be cut');
78
+ const n = norm(plane.n);
79
+ const { center, radius } = bounds(mesh);
80
+ const size = Math.max(radius, MIN) * 8;
81
+ const axes = frame(n);
82
+ // the half-space: a box whose bottom face lies in the plane and which reaches far past the solid on every side
83
+ const along = dot(sub(center, plane.point), n);
84
+ const origin = add(sub(center, mul(n, along)), mul(n, size / 2)); // centre of the box: over the foot of the solid's centre
85
+ const slab = box({ size: [size, size, size], at: origin, color, axes });
86
+ if (mat)
87
+ slab.faces.forEach((f) => (f.mat = mat));
88
+ const front = intersect(mesh, slab);
89
+ const back = subtract(mesh, slab);
90
+ if (!front.faces.length || !back.faces.length)
91
+ throw new Error('the plane does not go through the object');
92
+ return { front: splitParts(front), back: splitParts(back) };
93
+ };
94
+ /** Where the plane meets the faces of the mesh: line segments for the preview of a cut. */
95
+ export const sectionSegments = (mesh, plane) => {
96
+ const n = norm(plane.n);
97
+ const out = [];
98
+ const eps = 1e-9 * Math.max(bounds(mesh).radius, 1);
99
+ const d = mesh.positions.map((p) => dot(sub(p, plane.point), n));
100
+ for (const f of mesh.faces) {
101
+ const hits = [];
102
+ f.v.forEach((i, k) => {
103
+ const j = f.v[(k + 1) % f.v.length];
104
+ const a = d[i];
105
+ const b = d[j];
106
+ if (Math.abs(a) <= eps && Math.abs(b) <= eps)
107
+ return; // an edge lying in the plane is the cut's boundary: shown from the other face
108
+ if ((a < -eps && b > eps) || (a > eps && b < -eps)) {
109
+ const t = a / (a - b);
110
+ hits.push(add(mesh.positions[i], mul(sub(mesh.positions[j], mesh.positions[i]), t)));
111
+ }
112
+ else if (Math.abs(a) <= eps)
113
+ hits.push(mesh.positions[i]);
114
+ });
115
+ // a convex face meets a plane in at most two points
116
+ const uniq = hits.filter((p, k) => hits.findIndex((q) => len(sub(p, q)) <= eps * 10) === k);
117
+ if (uniq.length === 2)
118
+ out.push([uniq[0], uniq[1]]);
119
+ }
120
+ return out;
121
+ };
package/dist/svg.js CHANGED
@@ -208,7 +208,7 @@ const panel = (mesh, view, o, x = 0, y = 0) => {
208
208
  return [
209
209
  `<polyline data-mark="${esc(m.name ?? 'mark')}" points="${pts.map((p) => `${num(p.x)},${num(p.y)}`).join(' ')}" ` +
210
210
  `fill="none" stroke="${m.color ?? '#0b7285'}" stroke-width="${m.width ?? 2}" ` +
211
- `stroke-linecap="round" stroke-linejoin="round"/>`,
211
+ `stroke-linecap="round" stroke-linejoin="round"${m.opacity !== undefined && m.opacity < 1 ? ` stroke-opacity="${m.opacity}"` : ''}/>`,
212
212
  ];
213
213
  }));
214
214
  return [
@@ -0,0 +1,4 @@
1
+ import type { Vec3 } from './types.js';
2
+ export type Tri = [number, number, number];
3
+ /** Indices into `pts`, three per triangle, `pts.length - 2` triangles. */
4
+ export declare const triangulate: (pts: Vec3[]) => Tri[];
@@ -0,0 +1,72 @@
1
+ // Triangles of one polygon face. A convex polygon (and anything with 3 or 4 well-behaved corners) is a plain fan from its first
2
+ // corner; a concave one (an L, a V, a star: an outline that was drawn or filled by hand) is cut by ear clipping, so no triangle ever
3
+ // lies outside the outline. The triangles keep the winding of the polygon.
4
+ import { faceNormal } from './mesh.js';
5
+ const fan = (n) => Array.from({ length: Math.max(0, n - 2) }, (_, j) => [0, j + 1, j + 2]);
6
+ /** Indices into `pts`, three per triangle, `pts.length - 2` triangles. */
7
+ export const triangulate = (pts) => {
8
+ const n = pts.length;
9
+ if (n <= 3)
10
+ return fan(n);
11
+ const nrm = faceNormal(pts);
12
+ const big = Math.max(Math.abs(nrm[0]), Math.abs(nrm[1]), Math.abs(nrm[2]));
13
+ if (big < 1e-12)
14
+ return fan(n); // no plane to speak of: nothing better than the fan
15
+ // the plane is dropped to 2D along the normal's biggest axis; `sign` keeps the polygon counter-clockwise there
16
+ const axis = big === Math.abs(nrm[0]) ? 0 : big === Math.abs(nrm[1]) ? 1 : 2;
17
+ const [iu, iv] = axis === 0 ? [1, 2] : axis === 1 ? [2, 0] : [0, 1];
18
+ const sign = nrm[axis] >= 0 ? 1 : -1;
19
+ const P = pts.map((p) => [p[iu] * sign, p[iv]]);
20
+ const turn = (a, b, c) => (P[b][0] - P[a][0]) * (P[c][1] - P[a][1]) - (P[b][1] - P[a][1]) * (P[c][0] - P[a][0]);
21
+ let scale = 0;
22
+ for (const p of P)
23
+ scale = Math.max(scale, Math.abs(p[0]), Math.abs(p[1]));
24
+ const eps = 1e-12 * scale * scale;
25
+ let convex = true;
26
+ for (let i = 0; i < n && convex; i++)
27
+ if (turn(i, (i + 1) % n, (i + 2) % n) < -eps)
28
+ convex = false;
29
+ if (convex)
30
+ return fan(n);
31
+ const inside = (p, a, b, c) => {
32
+ // on or in the triangle (a, b, c), which is counter-clockwise; corners at the same place as a, b or c do not count
33
+ for (const q of [a, b, c])
34
+ if (Math.abs(P[p][0] - P[q][0]) <= 1e-12 * scale && Math.abs(P[p][1] - P[q][1]) <= 1e-12 * scale)
35
+ return false;
36
+ return turn(a, b, p) >= -eps && turn(b, c, p) >= -eps && turn(c, a, p) >= -eps;
37
+ };
38
+ const rest = pts.map((_, i) => i);
39
+ const out = [];
40
+ let guard = 0;
41
+ while (rest.length > 3 && guard++ < n * n) {
42
+ let clipped = false;
43
+ for (let k = 0; k < rest.length; k++) {
44
+ const a = rest[(k + rest.length - 1) % rest.length];
45
+ const b = rest[k];
46
+ const c = rest[(k + 1) % rest.length];
47
+ if (turn(a, b, c) <= eps)
48
+ continue; // a reflex (or flat) corner is not an ear
49
+ if (rest.some((p) => p !== a && p !== b && p !== c && inside(p, a, b, c)))
50
+ continue;
51
+ out.push([a, b, c]);
52
+ rest.splice(k, 1);
53
+ clipped = true;
54
+ break;
55
+ }
56
+ if (!clipped) {
57
+ // a self-touching or degenerate outline: take the flattest corner off so the loop always ends
58
+ let k = 0;
59
+ let best = -Infinity;
60
+ for (let i = 0; i < rest.length; i++) {
61
+ const t = turn(rest[(i + rest.length - 1) % rest.length], rest[i], rest[(i + 1) % rest.length]);
62
+ if (t > best)
63
+ ((best = t), (k = i));
64
+ }
65
+ out.push([rest[(k + rest.length - 1) % rest.length], rest[k], rest[(k + 1) % rest.length]]);
66
+ rest.splice(k, 1);
67
+ }
68
+ }
69
+ if (rest.length === 3)
70
+ out.push([rest[0], rest[1], rest[2]]);
71
+ return out;
72
+ };
package/dist/types.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface Fit {
25
25
  radius: number;
26
26
  /** Perspective only: distance from the eye to the center. A first-person eye sits almost on the center. */
27
27
  dist?: number;
28
+ /** Perspective only: the field of view in radians (default 35°). A first-person eye inside a room wants a wider one. */
29
+ fov?: number;
28
30
  }
29
31
  export interface Camera {
30
32
  name: string;
@@ -54,4 +56,6 @@ export interface Mark {
54
56
  lines: Vec3[][];
55
57
  color?: string;
56
58
  width?: number;
59
+ /** 0..1, default 1. */
60
+ opacity?: number;
57
61
  }
package/dist/wire.d.ts CHANGED
@@ -55,3 +55,16 @@ export declare const wireFromSegments: (mesh: Mesh, wire: Wire, segments: [Vec3,
55
55
  * so every face stays planar.
56
56
  */
57
57
  export declare const moveVertexSet: (mesh: Mesh, moves: [number, Vec3][]) => Mesh;
58
+ /**
59
+ * Moves vertices like `moveVertexSet`, but a polygon stays one polygon whenever the move leaves it flat (and facing the
60
+ * same way): a whole face dragged, scaled or stretched, the side of an extrusion. Only a polygon that the move would bend
61
+ * (or flip, or collapse) becomes triangles. This is what the grab / scale / extrude of the editor use.
62
+ */
63
+ export declare const reshape: (mesh: Mesh, moves: [number, Vec3][]) => Mesh;
64
+ /**
65
+ * The closed outline that loose lines make, as an ordered list of vertex ids, or null when they make none. `ids` are the points taken
66
+ * part in, `lines` the vertex pairs between them. The points are not ordered by angle round their middle (that only works for a convex
67
+ * shape, an L or a V comes out knotted): the lines are followed. The walk starts at the outermost point and at a junction (more than
68
+ * one way on) continues in the direction that turns least, the way Rustroke's fill follows the tangent. Winding is not decided here.
69
+ */
70
+ export declare const outlineOf: (positions: Vec3[], lines: [number, number][], ids: number[]) => number[] | null;
package/dist/wire.js CHANGED
@@ -3,8 +3,9 @@
3
3
  // its ends are ordinary mesh vertices, so moving a vertex moves every line (and every face) that uses it.
4
4
  // Faces stay convex and planar: a vertex placed inside a face splits it into triangles, a cut splits a convex
5
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';
6
+ import { sub, add, scale, dot, len, cross, norm } from './vec.js';
7
7
  import { faceNormal, bounds } from './mesh.js';
8
+ import { triangulate } from './triangulate.js';
8
9
  const sizeOf = (mesh) => bounds(mesh).radius || 1;
9
10
  /** Distance from `p` to the segment a-b, and where along it (0..1). */
10
11
  const toSegment = (p, a, b) => {
@@ -87,8 +88,8 @@ export const moveVertex = (mesh, i, to) => {
87
88
  if (f.v.length <= 3 || !f.v.includes(i))
88
89
  faces.push(f);
89
90
  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]] });
91
+ for (const [a, b, c] of triangulate(f.v.map((v) => mesh.positions[v])))
92
+ faces.push({ ...f, v: [f.v[a], f.v[b], f.v[c]] });
92
93
  }
93
94
  return withFaces(mesh, faces, positions);
94
95
  };
@@ -103,8 +104,8 @@ export const fillLoop = (mesh, loop, face = {}, awayFrom = bounds(mesh).center)
103
104
  const centre = scale(pts.reduce((s, p) => add(s, p), [0, 0, 0]), 1 / pts.length);
104
105
  const ordered = dot(faceNormal(pts), sub(centre, awayFrom)) >= 0 ? loop : [...loop].reverse();
105
106
  const out = [];
106
- for (let j = 1; j < ordered.length - 1; j++)
107
- out.push({ ...face, v: [ordered[0], ordered[j], ordered[j + 1]] });
107
+ for (const [a, b, c] of triangulate(ordered.map((v) => mesh.positions[v])))
108
+ out.push({ ...face, v: [ordered[a], ordered[b], ordered[c]] });
108
109
  return withFaces(mesh, [...mesh.faces, ...out]);
109
110
  };
110
111
  /** Adds the line a-b (not twice, not a point). */
@@ -177,8 +178,8 @@ export const nearestSurface = (mesh, p, tolerance) => {
177
178
  let best = null;
178
179
  mesh.faces.forEach((f, face) => {
179
180
  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]);
181
+ for (const [a, b, c] of triangulate(pts)) {
182
+ const q = closestOnTriangle(p, pts[a], pts[b], pts[c]);
182
183
  const d = len(sub(q, p));
183
184
  if (d <= tolerance && (!best || d < best.d))
184
185
  best = { face, point: q, d };
@@ -237,8 +238,109 @@ export const moveVertexSet = (mesh, moves) => {
237
238
  if (rigid)
238
239
  faces.push(f);
239
240
  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]] });
241
+ for (const [a, b, c] of triangulate(f.v.map((v) => mesh.positions[v])))
242
+ faces.push({ ...f, v: [f.v[a], f.v[b], f.v[c]] });
242
243
  }
243
244
  return withFaces(mesh, faces, positions);
244
245
  };
246
+ /**
247
+ * Moves vertices like `moveVertexSet`, but a polygon stays one polygon whenever the move leaves it flat (and facing the
248
+ * same way): a whole face dragged, scaled or stretched, the side of an extrusion. Only a polygon that the move would bend
249
+ * (or flip, or collapse) becomes triangles. This is what the grab / scale / extrude of the editor use.
250
+ */
251
+ export const reshape = (mesh, moves) => {
252
+ const to = new Map(moves);
253
+ const positions = mesh.positions.map((p, k) => to.get(k) ?? p);
254
+ const tol = 1e-6 * sizeOf(mesh);
255
+ const faces = [];
256
+ for (const f of mesh.faces) {
257
+ if (f.v.length <= 3 || !f.v.some((v) => to.has(v))) {
258
+ faces.push(f);
259
+ continue;
260
+ }
261
+ const before = faceNormal(f.v.map((v) => mesh.positions[v]));
262
+ const pts = f.v.map((v) => positions[v]);
263
+ const after = faceNormal(pts);
264
+ const l = len(after);
265
+ const c = pts.reduce((t, p) => add(t, scale(p, 1 / pts.length)), [0, 0, 0]);
266
+ const flat = l > 1e-12 && dot(after, before) > 0 && pts.every((p) => Math.abs(dot(sub(p, c), after)) / l <= tol);
267
+ if (flat)
268
+ faces.push(f);
269
+ else
270
+ for (const [a, b, c] of triangulate(f.v.map((v) => mesh.positions[v])))
271
+ faces.push({ ...f, v: [f.v[a], f.v[b], f.v[c]] });
272
+ }
273
+ return withFaces(mesh, faces, positions);
274
+ };
275
+ /**
276
+ * The closed outline that loose lines make, as an ordered list of vertex ids, or null when they make none. `ids` are the points taken
277
+ * part in, `lines` the vertex pairs between them. The points are not ordered by angle round their middle (that only works for a convex
278
+ * shape, an L or a V comes out knotted): the lines are followed. The walk starts at the outermost point and at a junction (more than
279
+ * one way on) continues in the direction that turns least, the way Rustroke's fill follows the tangent. Winding is not decided here.
280
+ */
281
+ export const outlineOf = (positions, lines, ids) => {
282
+ if (ids.length < 3 || !lines.length)
283
+ return null;
284
+ const P = positions;
285
+ // the plane: the two farthest points, and the point farthest from the line between them
286
+ let [a, b] = [ids[0], ids[1]];
287
+ let far = -1;
288
+ for (const i of ids)
289
+ for (const j of ids)
290
+ if (i < j) {
291
+ const d = len(sub(P[i], P[j]));
292
+ if (d > far)
293
+ ((far = d), (a = i), (b = j));
294
+ }
295
+ const u = norm(sub(P[b], P[a]));
296
+ let n = null;
297
+ let best = 1e-12;
298
+ for (const i of ids) {
299
+ const m = cross(u, sub(P[i], P[a]));
300
+ if (len(m) > best)
301
+ ((best = len(m)), (n = norm(m)));
302
+ }
303
+ if (!n)
304
+ return null;
305
+ const w = cross(n, u);
306
+ const flat = (i) => [dot(sub(P[i], P[a]), u), dot(sub(P[i], P[a]), w)];
307
+ const adj = new Map();
308
+ const link = (p, q) => (adj.get(p) ?? adj.set(p, []).get(p)).push(q);
309
+ const seen = new Set();
310
+ for (const [p, q] of lines) {
311
+ const k = p < q ? `${p},${q}` : `${q},${p}`;
312
+ if (p === q || seen.has(k))
313
+ continue;
314
+ seen.add(k);
315
+ link(p, q);
316
+ link(q, p);
317
+ }
318
+ const starts = [...adj.keys()].filter((i) => adj.get(i).length >= 2);
319
+ if (starts.length < 3)
320
+ return null;
321
+ const first = starts.reduce((m, i) => (flat(i)[0] < flat(m)[0] ? i : m));
322
+ const loop = [first];
323
+ let prev = null;
324
+ let at = first;
325
+ for (let guard = 0; guard < ids.length * 4 + 8; guard++) {
326
+ const options = adj.get(at).filter((q) => q !== prev);
327
+ if (!options.length)
328
+ return null;
329
+ const here = flat(at);
330
+ const came = prev === null ? [0, -1] : [here[0] - flat(prev)[0], here[1] - flat(prev)[1]];
331
+ const turn = (q) => {
332
+ const t = flat(q);
333
+ const d = [t[0] - here[0], t[1] - here[1]];
334
+ return Math.abs(Math.atan2(came[0] * d[1] - came[1] * d[0], came[0] * d[0] + came[1] * d[1]));
335
+ };
336
+ const next = options.reduce((m, q) => (turn(q) < turn(m) ? q : m));
337
+ if (next === first)
338
+ return loop.length >= 3 ? loop : null;
339
+ if (loop.includes(next))
340
+ return null;
341
+ loop.push(next);
342
+ prev = at;
343
+ at = next;
344
+ }
345
+ return null;
346
+ };