partforge 0.136.0 → 0.137.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/docs/AUTHORING-PARTS.md +16 -8
- package/package.json +4 -2
- package/src/framework/contact-shadow-plane.js +30 -0
- package/src/framework/contact-shadow.js +83 -0
- package/src/framework/materials/physical.js +1 -4
- package/src/framework/materials/resolve.js +6 -0
- package/src/framework/mount.js +2 -1
- package/src/framework/renderStyles.js +146 -0
- package/src/framework/softrender/camera.js +38 -0
- package/src/framework/softrender/dfgLut.js +65 -0
- package/src/framework/softrender/edges.js +39 -0
- package/src/framework/softrender/index.js +136 -0
- package/src/framework/softrender/raster.js +78 -0
- package/src/framework/softrender/shade.js +96 -0
- package/src/framework/softrender/triangles.js +9 -0
- package/src/framework/style-camera.js +174 -0
- package/src/framework/viewer-lighting.js +11 -45
- package/src/framework/viewer.js +107 -76
- package/src/testing/render.js +40 -176
- package/src/testing.js +2 -1
- package/types/index.d.ts +2 -0
- package/types/testing.d.ts +27 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2396,7 +2396,7 @@ The framework resolves these — fetch/read bytes, detect the format (filename e
|
|
|
2396
2396
|
- **STL/3MF on Manifold** — native: parsed, repaired (vertex merge + winding/orientation fix), and handed to `Manifold.ofMesh`. A mesh still non-manifold after repair throws loudly with the open-edge count — see [ERROR-PATTERNS.md#import-mesh-not-solid](ERROR-PATTERNS.md#import-mesh-not-solid).
|
|
2397
2397
|
- **STL/3MF on OCCT** — never attempted: mesh-to-B-rep conversion isn't in scope for v1. Declaring a mesh import on a part (or sub-part, under per-sub-part routing) that routes to OCCT is an error — see [ERROR-PATTERNS.md#import-mesh-on-occt](ERROR-PATTERNS.md#import-mesh-on-occt).
|
|
2398
2398
|
|
|
2399
|
-
`import` is not in `OCCT_ONLY_OPS` — a STEP import does not by itself force OCCT routing (the crossover exists precisely so it doesn't have to); backend selection is still driven by `
|
|
2399
|
+
`import` is not in `OCCT_ONLY_OPS` — a STEP import does not by itself force OCCT routing (the crossover exists precisely so it doesn't have to); backend selection is still driven by `shell` on a `Solid` or `meta.backend` (`fillet`/`chamfer` run on Manifold and send a sub-part to OCCT only when an edge they select is one the mesh fillet cannot blend).
|
|
2400
2400
|
|
|
2401
2401
|
**Units:** everything normalizes to millimetres at parse time — STEP units are honored by the OCCT importer, a 3MF file's `unit` attribute is converted, and **STL is assumed to already be in millimetres** (the format carries no unit metadata).
|
|
2402
2402
|
|
|
@@ -3009,14 +3009,16 @@ instead of (or in addition to) the built-in `#part` bar:
|
|
|
3009
3009
|
`viewName` rendered offscreen (falling back to the resolved default view — see
|
|
3010
3010
|
`resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
|
|
3011
3011
|
part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
|
|
3012
|
-
scene; `opts` forwards to the underlying render (size, quality, angle, background).
|
|
3012
|
+
scene; `opts` forwards to the underlying render (size, quality, angle, background, style).
|
|
3013
3013
|
Resolves `null` on failure rather than throwing (a build error, a part with no sub-parts
|
|
3014
3014
|
in that view, a disposed runtime). The render happens in a throwaway scene, so it takes
|
|
3015
|
-
no colour from the viewer's light/dark theme
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3015
|
+
no colour from the viewer's light/dark theme. The default `style` is `"thumbnail"`, the
|
|
3016
|
+
product shot: a fixed light background, soft lights, lighter edges, a contact shadow and
|
|
3017
|
+
fit framing — a thumbnail is captured once and then displayed under host chrome
|
|
3018
|
+
partforge cannot see. `style: "cad"` gives the agent-render look instead. `style.view` is
|
|
3019
|
+
only a default angle; an explicit `angle` wins. Pass `background` (any
|
|
3020
|
+
`THREE.Color`-compatible value) to override the style's, or `background: null` for no
|
|
3021
|
+
background at all — which clears to opaque black unless the embedder has set a clear colour.
|
|
3020
3022
|
|
|
3021
3023
|
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
3022
3024
|
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
|
@@ -3143,7 +3145,8 @@ pane's pixel size:
|
|
|
3143
3145
|
or when measurement dimensions are pinned (their labels sit beside the part and
|
|
3144
3146
|
could otherwise be cut off).
|
|
3145
3147
|
- `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
|
|
3146
|
-
counterpart (fixed
|
|
3148
|
+
counterpart (fixed view directions, each fitted so the visible assembly's projected
|
|
3149
|
+
geometry fills 90% of the frame and sits centred in it; 1024², grid hidden). Sized
|
|
3147
3150
|
for feeding a vision model, not for display; use `captureCurrent` for showcase images.
|
|
3148
3151
|
|
|
3149
3152
|
### `runtime.projection`
|
|
@@ -3469,6 +3472,11 @@ the part's first declared view. Treat renders as complementary evidence, not a r
|
|
|
3469
3472
|
use several views for complex parts and the interactive viewer's cutaway for hidden
|
|
3470
3473
|
interfaces, but rely on `measure` / `verify` for dimensions, contact, and clearance.
|
|
3471
3474
|
|
|
3475
|
+
Headlessly, `renderViewImages(kernel, part, view, opts)` from `partforge/testing` returns
|
|
3476
|
+
the same stills in memory as `[{ angle, png }]` (PNG buffers; `renderViews` writes them to
|
|
3477
|
+
disk through it). Its `style` option is `"cad"` (default) or `"thumbnail"`; `style.view`
|
|
3478
|
+
is only a default, so explicit `views` always win.
|
|
3479
|
+
|
|
3472
3480
|
The `measure` function is also exported for vitest (boot a Manifold kernel as in
|
|
3473
3481
|
"Testing a part", then `measure(kernel, part, "<view>")`):
|
|
3474
3482
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.137.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -106,7 +106,9 @@
|
|
|
106
106
|
"lint": "eslint .",
|
|
107
107
|
"lint:dead": "knip",
|
|
108
108
|
"check": "node scripts/check-app.mjs",
|
|
109
|
-
"offset-rates": "node scripts/offset-rates.mjs"
|
|
109
|
+
"offset-rates": "node scripts/offset-rates.mjs",
|
|
110
|
+
"bench:softrender": "node scripts/bench-softrender.mjs",
|
|
111
|
+
"check:softrender": "node scripts/check-softrender-parity.mjs"
|
|
110
112
|
},
|
|
111
113
|
"peerDependencies": {
|
|
112
114
|
"happy-dom": ">=20"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/framework/contact-shadow-plane.js
|
|
2
|
+
// The viewer's half of the contact shadow (contact-shadow.js holds the mask):
|
|
3
|
+
// a black, unlit, alpha-mapped plane a hair under the floor. MeshBasicMaterial
|
|
4
|
+
// so no light touches it; depthWrite off so the part's bottom face never
|
|
5
|
+
// z-fights it. The CPU renderer samples the same mask per pixel instead.
|
|
6
|
+
import * as THREE from "three";
|
|
7
|
+
|
|
8
|
+
export function buildContactShadowPlane(mask, shadow) {
|
|
9
|
+
const { width: W, height: H, data, rect, y } = mask;
|
|
10
|
+
// alphaMap reads the GREEN channel. PlaneGeometry's v runs along local +y,
|
|
11
|
+
// which rotation.x = -PI/2 turns into world -z: texture row 0 (v = 0) is
|
|
12
|
+
// world z1, so mask row j (world z0 + ...) goes to texture row H-1-j.
|
|
13
|
+
const px = new Uint8Array(W * H * 4);
|
|
14
|
+
for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) {
|
|
15
|
+
const v = Math.round(Math.min(1, Math.max(0, data[j * W + i])) * 255);
|
|
16
|
+
const o = ((H - 1 - j) * W + i) * 4;
|
|
17
|
+
px[o] = px[o + 1] = px[o + 2] = v; px[o + 3] = 255;
|
|
18
|
+
}
|
|
19
|
+
const tex = new THREE.DataTexture(px, W, H, THREE.RGBAFormat);
|
|
20
|
+
tex.magFilter = THREE.LinearFilter; tex.minFilter = THREE.LinearFilter;
|
|
21
|
+
tex.needsUpdate = true;
|
|
22
|
+
const sx = rect.x1 - rect.x0, sz = rect.z1 - rect.z0;
|
|
23
|
+
const mat = new THREE.MeshBasicMaterial({
|
|
24
|
+
color: 0x000000, transparent: true, opacity: shadow.opacity, alphaMap: tex, depthWrite: false,
|
|
25
|
+
});
|
|
26
|
+
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(sx, sz), mat);
|
|
27
|
+
mesh.rotation.x = -Math.PI / 2;
|
|
28
|
+
mesh.position.set((rect.x0 + rect.x1) / 2, y - 1e-3 * Math.max(sx, sz), (rect.z0 + rect.z1) / 2);
|
|
29
|
+
return mesh;
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The product shot's soft contact shadow, as a top-down mask over the floor
|
|
2
|
+
// (the part's lowest world Y). Each cell is darkest where geometry sits ON the
|
|
3
|
+
// floor and fades to nothing `falloff·diag` above it, then the mask is blurred.
|
|
4
|
+
// Pure, so the viewer (as an alpha texture on a ground plane) and the CPU
|
|
5
|
+
// renderer (sampled per background pixel) draw the SAME shadow.
|
|
6
|
+
import { triangleCount, triangleOffsets } from "./softrender/triangles.js";
|
|
7
|
+
|
|
8
|
+
export function contactShadowMask(meshes, box, shadow) {
|
|
9
|
+
const size = [0, 1, 2].map((k) => box.max[k] - box.min[k]);
|
|
10
|
+
const diag = Math.hypot(...size);
|
|
11
|
+
if (!(diag > 0)) return null;
|
|
12
|
+
const falloff = shadow.falloff * diag;
|
|
13
|
+
const blurW = shadow.blur * diag;
|
|
14
|
+
const x0 = box.min[0] - 2 * blurW, x1 = box.max[0] + 2 * blurW;
|
|
15
|
+
const z0 = box.min[2] - 2 * blurW, z1 = box.max[2] + 2 * blurW;
|
|
16
|
+
const spanX = Math.max(x1 - x0, 1e-9), spanZ = Math.max(z1 - z0, 1e-9);
|
|
17
|
+
const span = Math.max(spanX, spanZ);
|
|
18
|
+
const W = Math.max(1, Math.round((shadow.resolution * spanX) / span));
|
|
19
|
+
const H = Math.max(1, Math.round((shadow.resolution * spanZ) / span));
|
|
20
|
+
const floor = box.min[1];
|
|
21
|
+
const minH = new Float32Array(W * H).fill(Infinity);
|
|
22
|
+
|
|
23
|
+
const gx = (x) => ((x - x0) / spanX) * W, gz = (z) => ((z - z0) / spanZ) * H;
|
|
24
|
+
for (const m of meshes) {
|
|
25
|
+
const P = m.positions;
|
|
26
|
+
const n = triangleCount(m);
|
|
27
|
+
for (let t = 0; t < n; t++) {
|
|
28
|
+
const o = triangleOffsets(m, t);
|
|
29
|
+
const ax = gx(P[o[0]]), az = gz(P[o[0] + 2]), ah = P[o[0] + 1] - floor;
|
|
30
|
+
const bx = gx(P[o[1]]), bz = gz(P[o[1] + 2]), bh = P[o[1] + 1] - floor;
|
|
31
|
+
const cx = gx(P[o[2]]), cz = gz(P[o[2] + 2]), ch = P[o[2] + 1] - floor;
|
|
32
|
+
const area = (bx - ax) * (cz - az) - (bz - az) * (cx - ax);
|
|
33
|
+
if (Math.abs(area) < 1e-12) continue; // a wall seen from above covers no cells
|
|
34
|
+
const i0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))), i1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx)));
|
|
35
|
+
const j0 = Math.max(0, Math.floor(Math.min(az, bz, cz))), j1 = Math.min(H - 1, Math.ceil(Math.max(az, bz, cz)));
|
|
36
|
+
for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {
|
|
37
|
+
const px = i + 0.5, pz = j + 0.5;
|
|
38
|
+
const w0 = ((cx - bx) * (pz - bz) - (cz - bz) * (px - bx)) / area;
|
|
39
|
+
const w1 = ((ax - cx) * (pz - cz) - (az - cz) * (px - cx)) / area;
|
|
40
|
+
const w2 = 1 - w0 - w1;
|
|
41
|
+
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
|
|
42
|
+
const h = w0 * ah + w1 * bh + w2 * ch;
|
|
43
|
+
const k = j * W + i;
|
|
44
|
+
if (h < minH[k]) minH[k] = h;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let data = new Float32Array(W * H);
|
|
50
|
+
for (let k = 0; k < data.length; k++) {
|
|
51
|
+
const h = minH[k];
|
|
52
|
+
data[k] = Number.isFinite(h) ? Math.min(1, Math.max(0, 1 - Math.max(0, h) / falloff)) : 0;
|
|
53
|
+
}
|
|
54
|
+
const r = Math.max(1, Math.round((blurW / spanX) * W));
|
|
55
|
+
for (let pass = 0; pass < 3; pass++) data = boxBlur(boxBlur(data, W, H, r, true), W, H, r, false);
|
|
56
|
+
return { width: W, height: H, data, rect: { x0, x1, z0, z1 }, y: floor };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// One separable box-blur pass, edges clamped.
|
|
60
|
+
function boxBlur(src, W, H, r, horizontal) {
|
|
61
|
+
const out = new Float32Array(src.length);
|
|
62
|
+
const n = horizontal ? W : H, lines = horizontal ? H : W;
|
|
63
|
+
for (let l = 0; l < lines; l++) {
|
|
64
|
+
const at = (i) => (horizontal ? l * W + i : i * W + l);
|
|
65
|
+
let sum = 0;
|
|
66
|
+
for (let i = -r; i <= r; i++) sum += src[at(Math.min(n - 1, Math.max(0, i)))];
|
|
67
|
+
for (let i = 0; i < n; i++) {
|
|
68
|
+
out[at(i)] = sum / (2 * r + 1);
|
|
69
|
+
sum += src[at(Math.min(n - 1, i + r + 1))] - src[at(Math.max(0, i - r))];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sampleShadowMask(mask, x, z) {
|
|
76
|
+
const { width: W, height: H, data, rect } = mask;
|
|
77
|
+
if (x < rect.x0 || x > rect.x1 || z < rect.z0 || z > rect.z1) return 0;
|
|
78
|
+
const u = ((x - rect.x0) / (rect.x1 - rect.x0)) * W - 0.5;
|
|
79
|
+
const v = ((z - rect.z0) / (rect.z1 - rect.z0)) * H - 0.5;
|
|
80
|
+
const i = Math.floor(u), j = Math.floor(v), fu = u - i, fv = v - j;
|
|
81
|
+
const at = (ii, jj) => data[Math.min(H - 1, Math.max(0, jj)) * W + Math.min(W - 1, Math.max(0, ii))];
|
|
82
|
+
return (at(i, j) * (1 - fu) + at(i + 1, j) * fu) * (1 - fv) + (at(i, j + 1) * (1 - fu) + at(i + 1, j + 1) * fu) * fv;
|
|
83
|
+
}
|
|
@@ -4,13 +4,10 @@
|
|
|
4
4
|
// builds MeshPhysicalMaterial. Both read resolve.js, so the two modes can never
|
|
5
5
|
// disagree about which preset a sub-part is.
|
|
6
6
|
import * as THREE from "three";
|
|
7
|
-
import { cadAppearance, resolveMaterial } from "./resolve.js";
|
|
7
|
+
import { cadAppearance, resolveMaterial, hasAppearance } from "./resolve.js";
|
|
8
8
|
import { applyBrushFrame, applyBurn, applyPattern } from "./patterns.js";
|
|
9
9
|
import { PATTERN_TEXTURES } from "./assets.js";
|
|
10
10
|
|
|
11
|
-
const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
|
|
12
|
-
const hasAppearance = (display) => !!display && APPEARANCE_KEYS.some((k) => display[k] != null);
|
|
13
|
-
|
|
14
11
|
export function buildCadMaterial(display, base) {
|
|
15
12
|
if (!hasAppearance(display)) return base;
|
|
16
13
|
const a = cadAppearance(display);
|
|
@@ -141,3 +141,9 @@ export function resolveEnvironmentId(id) {
|
|
|
141
141
|
if (typeof id === "string" && Object.hasOwn(ENVIRONMENTS, id)) return { id, known: true };
|
|
142
142
|
return { id: DEFAULT_ENVIRONMENT_ID, known: false };
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
// Whether a sub-part's `display` declares any appearance at all. Without one
|
|
146
|
+
// the CAD view uses the shared default material untouched (physical.js
|
|
147
|
+
// buildCadMaterial; softrender/index.js mirrors that rule).
|
|
148
|
+
const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
|
|
149
|
+
export const hasAppearance = (display) => !!display && APPEARANCE_KEYS.some((k) => display[k] != null);
|
package/src/framework/mount.js
CHANGED
|
@@ -1390,7 +1390,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1390
1390
|
// touches the active tab, getView(), or the live scene — best-effort: any
|
|
1391
1391
|
// failure, including a resolved-null from a worker build failure (4A
|
|
1392
1392
|
// settles rather than throwing), returns null. `opts` is spread last, so
|
|
1393
|
-
// renderMeshPayloads' own options (including `background`
|
|
1393
|
+
// renderMeshPayloads' own options (including `background` and
|
|
1394
|
+
// `style`: "thumbnail" by default, "cad" for the agent look) pass straight
|
|
1394
1395
|
// through from the caller.
|
|
1395
1396
|
const captureView = async (viewName, opts = {}) => {
|
|
1396
1397
|
try {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/framework/renderStyles.js
|
|
2
|
+
// The two offscreen capture looks, as plain data: `cad` (the agent's renders)
|
|
3
|
+
// and `thumbnail` (the card/product shot). Read by BOTH the three.js viewer
|
|
4
|
+
// (viewer.js, viewer-lighting.js) and the CPU renderer (softrender/), so a
|
|
5
|
+
// look is written down once and the two renderers cannot drift apart. No
|
|
6
|
+
// three.js here: this module runs in the Vercel Sandbox under plain Node.
|
|
7
|
+
//
|
|
8
|
+
// Colours are sRGB hex, exactly as three.js receives them; consumers convert
|
|
9
|
+
// to linear (srgbHexToLinear) the way three's ColorManagement does.
|
|
10
|
+
|
|
11
|
+
export const CAD_LIGHT_THEME = Object.freeze({ bg: 0xe9edf2, line: 0x33414f });
|
|
12
|
+
export const CAD_DARK_THEME = Object.freeze({ bg: 0x15181d, line: 0x1c232d });
|
|
13
|
+
|
|
14
|
+
const CAD_LIGHTS = {
|
|
15
|
+
hemisphere: { sky: 0xdce9ff, ground: 0x687586, intensity: 1.35 },
|
|
16
|
+
// Offsets are camera-space multiples of the camera-to-target distance:
|
|
17
|
+
// key over the viewer's shoulder, fill opposing it at eye level
|
|
18
|
+
// (style-camera.js captureLightPoses places them).
|
|
19
|
+
key: { color: 0xffffff, intensity: 1.45, offset: { right: 0.45, up: 0.75 } },
|
|
20
|
+
fill: { color: 0xe5efff, intensity: 0.65, offset: { right: -0.7, up: 0.15 } },
|
|
21
|
+
};
|
|
22
|
+
const CAD_MATERIAL = { color: 0x9fb4cc, metalness: 0.25, roughness: 0.55 };
|
|
23
|
+
|
|
24
|
+
// The product shot, tuned 2026-10-02 on demo / hinged-box / propeller captures
|
|
25
|
+
// judged at card size (~300px from the 640px thumbnail): fill 0.88 (room for
|
|
26
|
+
// the shadow and the card's corners), hemisphere 1.65, key
|
|
27
|
+
// 1.25, fill light 1.05 (the shaded side stays off the background), 1.5px edges
|
|
28
|
+
// at 0.7 (visible at card size, lighter than the editor's), and a contact shadow
|
|
29
|
+
// of 0.5 opacity / 0.25 falloff / 0.07 blur (faint, but it reads — most of it
|
|
30
|
+
// sits under the part at iso). Retune with scripts/check-softrender-parity.mjs
|
|
31
|
+
// --out, on captures, never by eye on the hex; both renderers move together.
|
|
32
|
+
// Both looks frame by "fit" (style-camera.js fitPoseToPoints): the canonical
|
|
33
|
+
// direction, at the distance that brings the projected geometry to `fill` of
|
|
34
|
+
// the frame, centred on it. cad's 0.9 leaves a margin so a silhouette never
|
|
35
|
+
// touches the frame edge. "canonical" (cameraPoseForView's fixed distance)
|
|
36
|
+
// stays valid for a caller-supplied style.
|
|
37
|
+
const STYLES = {
|
|
38
|
+
cad: {
|
|
39
|
+
camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.9 },
|
|
40
|
+
lights: CAD_LIGHTS,
|
|
41
|
+
material: CAD_MATERIAL,
|
|
42
|
+
edges: { color: CAD_LIGHT_THEME.line, widthPx: 1, opacity: 1 },
|
|
43
|
+
background: CAD_LIGHT_THEME.bg,
|
|
44
|
+
shadow: null,
|
|
45
|
+
view: null,
|
|
46
|
+
},
|
|
47
|
+
thumbnail: {
|
|
48
|
+
camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.88 },
|
|
49
|
+
lights: {
|
|
50
|
+
hemisphere: { ...CAD_LIGHTS.hemisphere, intensity: 1.65 },
|
|
51
|
+
key: { ...CAD_LIGHTS.key, intensity: 1.25 },
|
|
52
|
+
fill: { ...CAD_LIGHTS.fill, intensity: 1.05 },
|
|
53
|
+
},
|
|
54
|
+
material: CAD_MATERIAL,
|
|
55
|
+
edges: { color: CAD_LIGHT_THEME.line, widthPx: 1.5, opacity: 0.7 },
|
|
56
|
+
background: 0xeef1f5,
|
|
57
|
+
// falloff/blur are fractions of the part's bounding-box diagonal;
|
|
58
|
+
// resolution is the mask's long side in cells.
|
|
59
|
+
shadow: { opacity: 0.5, falloff: 0.25, blur: 0.07, resolution: 128 },
|
|
60
|
+
view: "iso",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isHex = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
|
|
65
|
+
const inRange = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
|
66
|
+
function check(ok, field) { if (!ok) throw new Error(`render style: ${field} is invalid`); }
|
|
67
|
+
|
|
68
|
+
export function validateRenderStyle(s) {
|
|
69
|
+
check(s && typeof s === "object", "style");
|
|
70
|
+
check(s.camera?.projection === "perspective", "camera.projection");
|
|
71
|
+
check(inRange(s.camera.fov, 1, 120), "camera.fov");
|
|
72
|
+
check(s.camera.framing === "canonical" || s.camera.framing === "fit", "camera.framing");
|
|
73
|
+
if (s.camera.framing === "fit") check(inRange(s.camera.fill, 0.05, 1), "camera.fill");
|
|
74
|
+
const h = s.lights?.hemisphere;
|
|
75
|
+
check(isHex(h?.sky) && isHex(h?.ground) && inRange(h?.intensity, 0, 20), "lights.hemisphere");
|
|
76
|
+
for (const k of ["key", "fill"]) {
|
|
77
|
+
const l = s.lights[k];
|
|
78
|
+
check(isHex(l?.color) && inRange(l?.intensity, 0, 20), `lights.${k}`);
|
|
79
|
+
check(inRange(l.offset?.right, -10, 10) && inRange(l.offset?.up, -10, 10), `lights.${k}.offset`);
|
|
80
|
+
}
|
|
81
|
+
check(isHex(s.material?.color), "material.color");
|
|
82
|
+
check(inRange(s.material.metalness, 0, 1), "material.metalness");
|
|
83
|
+
check(inRange(s.material.roughness, 0, 1), "material.roughness");
|
|
84
|
+
check(isHex(s.edges?.color) && inRange(s.edges?.widthPx, 0.25, 8) && inRange(s.edges?.opacity, 0, 1), "edges");
|
|
85
|
+
check(isHex(s.background), "background");
|
|
86
|
+
if (s.shadow !== null) {
|
|
87
|
+
const sh = s.shadow;
|
|
88
|
+
check(inRange(sh?.opacity, 0, 1) && inRange(sh?.falloff, 0.001, 10) && inRange(sh?.blur, 0, 1)
|
|
89
|
+
&& Number.isInteger(sh?.resolution) && sh.resolution >= 8 && sh.resolution <= 1024, "shadow");
|
|
90
|
+
}
|
|
91
|
+
check(s.view === null || typeof s.view === "string", "view");
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function deepFreeze(o) {
|
|
96
|
+
for (const v of Object.values(o)) if (v && typeof v === "object") deepFreeze(v);
|
|
97
|
+
return Object.freeze(o);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validated once, at load: a typo in the data above fails every test that
|
|
101
|
+
// imports this module rather than producing odd images.
|
|
102
|
+
for (const s of Object.values(STYLES)) validateRenderStyle(s);
|
|
103
|
+
export const RENDER_STYLES = deepFreeze(structuredClone(STYLES));
|
|
104
|
+
|
|
105
|
+
export function getRenderStyle(nameOrStyle) {
|
|
106
|
+
if (typeof nameOrStyle === "string") {
|
|
107
|
+
if (!Object.hasOwn(RENDER_STYLES, nameOrStyle)) throw new Error(`unknown render style "${nameOrStyle}"`);
|
|
108
|
+
return RENDER_STYLES[nameOrStyle];
|
|
109
|
+
}
|
|
110
|
+
return validateRenderStyle(nameOrStyle);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// sRGB hex → linear [r, g, b], the same transfer three's ColorManagement
|
|
114
|
+
// applies to a hex colour handed to a material or a light.
|
|
115
|
+
export function srgbHexToLinear(hex) {
|
|
116
|
+
return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((b) => {
|
|
117
|
+
const c = b / 255;
|
|
118
|
+
return c < 0.04045 ? c * 0.0773993808 : ((c * 0.9478672986) + 0.0521327014) ** 2.4;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// three renders into a render target in the LINEAR working colour space: as of r184
|
|
123
|
+
// WebGLRenderer only applies `outputColorSpace` on the canvas path (WebGLPrograms
|
|
124
|
+
// substitutes workingColorSpace whenever a render target is bound), so readback pixels
|
|
125
|
+
// are linear no matter what the target texture's colorSpace says. Writing them straight
|
|
126
|
+
// into a JPEG is what made captured views come back muddy and dark compared to the live
|
|
127
|
+
// canvas. Encode the transfer function ourselves. The 8-bit LUT loses precision only in
|
|
128
|
+
// the deepest shadows, which a quality-0.9 JPEG would not have preserved anyway.
|
|
129
|
+
export const SRGB8 = (() => {
|
|
130
|
+
const table = new Uint8Array(256);
|
|
131
|
+
for (let i = 0; i < 256; i++) {
|
|
132
|
+
const l = i / 255;
|
|
133
|
+
table[i] = Math.round(255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055));
|
|
134
|
+
}
|
|
135
|
+
return table;
|
|
136
|
+
})();
|
|
137
|
+
|
|
138
|
+
// Linear RGBA bytes → sRGB, in place. Alpha is a coverage value, not a colour: untouched.
|
|
139
|
+
export function srgbEncodeInPlace(data) {
|
|
140
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
141
|
+
data[i] = SRGB8[data[i]];
|
|
142
|
+
data[i + 1] = SRGB8[data[i + 1]];
|
|
143
|
+
data[i + 2] = SRGB8[data[i + 2]];
|
|
144
|
+
}
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A perspective camera with three.js's conventions (PerspectiveCamera +
|
|
2
|
+
// lookAt: vertical fov, right = forward × up, screen y down in pixel space),
|
|
3
|
+
// so a pose from style-camera.js lands every vertex where the viewer would.
|
|
4
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
5
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
6
|
+
const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; };
|
|
7
|
+
|
|
8
|
+
export function makeCamera(pose, { fov, width, height }) {
|
|
9
|
+
const f = norm(sub(pose.target, pose.position));
|
|
10
|
+
let r = cross(f, pose.up);
|
|
11
|
+
if (Math.hypot(...r) < 1e-9) r = cross(f, [0, 0, 1]);
|
|
12
|
+
r = norm(r);
|
|
13
|
+
const u = cross(r, f);
|
|
14
|
+
const tanH = Math.tan((fov * Math.PI) / 360);
|
|
15
|
+
const aspect = width / height;
|
|
16
|
+
const [px, py, pz] = pose.position;
|
|
17
|
+
return {
|
|
18
|
+
position: pose.position, forward: f, right: r, up: u, tanH, aspect, width, height,
|
|
19
|
+
// Both write into `out` when given: the rasterizer and shading loops call
|
|
20
|
+
// them per vertex / per sample and pass one scratch array, so the hot
|
|
21
|
+
// path allocates nothing.
|
|
22
|
+
project(x, y, z, out = [0, 0, 0]) {
|
|
23
|
+
const dx = x - px, dy = y - py, dz = z - pz;
|
|
24
|
+
const vz = dx * f[0] + dy * f[1] + dz * f[2];
|
|
25
|
+
const vx = dx * r[0] + dy * r[1] + dz * r[2];
|
|
26
|
+
const vy = dx * u[0] + dy * u[1] + dz * u[2];
|
|
27
|
+
out[0] = (vx / (vz * tanH * aspect) * 0.5 + 0.5) * width;
|
|
28
|
+
out[1] = (0.5 - (vy / (vz * tanH)) * 0.5) * height;
|
|
29
|
+
out[2] = vz;
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
ray(sx, sy, out = [0, 0, 0]) {
|
|
33
|
+
const a = ((sx / width) * 2 - 1) * tanH * aspect, b = (1 - (sy / height) * 2) * tanH;
|
|
34
|
+
for (let i = 0; i < 3; i++) out[i] = f[i] + a * r[i] + b * u[i];
|
|
35
|
+
return out;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// three r184's DFG look-up table (renderers/shaders/DFGLUTData.js), copied
|
|
2
|
+
// verbatim: 16x16 texels of (scale, bias) as RG16F half floats, 4096 samples
|
|
3
|
+
// each. Row = dot(N, V), column = roughness, as the shader samples it
|
|
4
|
+
// (texture2D(dfgLUT, vec2(roughness, dotNV))). three feeds it to the standard
|
|
5
|
+
// material's multiscattering terms even with no environment map, so shade.js
|
|
6
|
+
// needs it. Pure data + a bilinear sampler; no three.js import.
|
|
7
|
+
const DATA = new Uint16Array([
|
|
8
|
+
0x30b5, 0x3ad1, 0x314c, 0x3a4d, 0x33d2, 0x391c, 0x35ef, 0x3828, 0x37f3, 0x36a6, 0x38d1, 0x3539, 0x3979, 0x3410, 0x39f8, 0x3252,
|
|
9
|
+
0x3a53, 0x30f0, 0x3a94, 0x2fc9, 0x3abf, 0x2e35, 0x3ada, 0x2d05, 0x3ae8, 0x2c1f, 0x3aed, 0x2ae0, 0x3aea, 0x29d1, 0x3ae1, 0x28ff,
|
|
10
|
+
0x3638, 0x38e4, 0x364a, 0x38ce, 0x3699, 0x385e, 0x374e, 0x372c, 0x3839, 0x35a4, 0x38dc, 0x3462, 0x396e, 0x32c4, 0x39de, 0x3134,
|
|
11
|
+
0x3a2b, 0x3003, 0x3a59, 0x2e3a, 0x3a6d, 0x2ce1, 0x3a6e, 0x2bba, 0x3a5f, 0x2a33, 0x3a49, 0x290a, 0x3a2d, 0x2826, 0x3a0a, 0x26e8,
|
|
12
|
+
0x3894, 0x36d7, 0x3897, 0x36c9, 0x38a3, 0x3675, 0x38bc, 0x35ac, 0x38ee, 0x349c, 0x393e, 0x3332, 0x3997, 0x3186, 0x39e2, 0x3038,
|
|
13
|
+
0x3a13, 0x2e75, 0x3a29, 0x2cf5, 0x3a2d, 0x2bac, 0x3a21, 0x29ff, 0x3a04, 0x28bc, 0x39dc, 0x2790, 0x39ad, 0x261a, 0x3978, 0x24fa,
|
|
14
|
+
0x39ac, 0x34a8, 0x39ac, 0x34a3, 0x39ae, 0x3480, 0x39ae, 0x3423, 0x39b1, 0x330e, 0x39c2, 0x31a9, 0x39e0, 0x3063, 0x39fc, 0x2eb5,
|
|
15
|
+
0x3a0c, 0x2d1d, 0x3a14, 0x2bcf, 0x3a07, 0x29ff, 0x39e9, 0x28a3, 0x39be, 0x273c, 0x3989, 0x25b3, 0x394a, 0x2488, 0x3907, 0x2345,
|
|
16
|
+
0x3a77, 0x3223, 0x3a76, 0x321f, 0x3a73, 0x3204, 0x3a6a, 0x31b3, 0x3a58, 0x3114, 0x3a45, 0x303b, 0x3a34, 0x2eb6, 0x3a26, 0x2d31,
|
|
17
|
+
0x3a1e, 0x2bef, 0x3a0b, 0x2a0d, 0x39ec, 0x28a1, 0x39c0, 0x271b, 0x3987, 0x2580, 0x3944, 0x2449, 0x38fa, 0x22bd, 0x38ac, 0x2155,
|
|
18
|
+
0x3b07, 0x2fca, 0x3b06, 0x2fca, 0x3b00, 0x2fb8, 0x3af4, 0x2f7c, 0x3adb, 0x2eea, 0x3ab4, 0x2e00, 0x3a85, 0x2cec, 0x3a5e, 0x2bc5,
|
|
19
|
+
0x3a36, 0x2a00, 0x3a0d, 0x2899, 0x39dc, 0x2707, 0x39a0, 0x2562, 0x395a, 0x2424, 0x390b, 0x2268, 0x38b7, 0x20fd, 0x385f, 0x1fd1,
|
|
20
|
+
0x3b69, 0x2cb9, 0x3b68, 0x2cbb, 0x3b62, 0x2cbb, 0x3b56, 0x2cae, 0x3b3b, 0x2c78, 0x3b0d, 0x2c0a, 0x3acf, 0x2ae3, 0x3a92, 0x2998,
|
|
21
|
+
0x3a54, 0x2867, 0x3a17, 0x26d0, 0x39d3, 0x253c, 0x3989, 0x2402, 0x3935, 0x2226, 0x38dc, 0x20bd, 0x387d, 0x1f54, 0x381d, 0x1db3,
|
|
22
|
+
0x3ba9, 0x296b, 0x3ba8, 0x296f, 0x3ba3, 0x297b, 0x3b98, 0x2987, 0x3b7f, 0x2976, 0x3b4e, 0x2927, 0x3b0e, 0x2895, 0x3ac2, 0x27b7,
|
|
23
|
+
0x3a73, 0x263b, 0x3a23, 0x24e7, 0x39d0, 0x239b, 0x3976, 0x21d9, 0x3917, 0x207e, 0x38b2, 0x1ee7, 0x384b, 0x1d53, 0x37c7, 0x1c1e,
|
|
24
|
+
0x3bd2, 0x25cb, 0x3bd1, 0x25d3, 0x3bcd, 0x25f0, 0x3bc2, 0x261f, 0x3bad, 0x2645, 0x3b7d, 0x262d, 0x3b3e, 0x25c4, 0x3aec, 0x250f,
|
|
25
|
+
0x3a93, 0x243a, 0x3a32, 0x22ce, 0x39d0, 0x215b, 0x3969, 0x202a, 0x38fe, 0x1e6e, 0x388f, 0x1cf1, 0x381f, 0x1b9b, 0x3762, 0x19dd,
|
|
26
|
+
0x3be9, 0x21ab, 0x3be9, 0x21b7, 0x3be5, 0x21e5, 0x3bdd, 0x2241, 0x3bc9, 0x22a7, 0x3ba0, 0x22ec, 0x3b62, 0x22cd, 0x3b0f, 0x2247,
|
|
27
|
+
0x3aae, 0x2175, 0x3a44, 0x2088, 0x39d4, 0x1f49, 0x3960, 0x1dbe, 0x38e9, 0x1c77, 0x3870, 0x1ae8, 0x37f1, 0x1953, 0x3708, 0x181b,
|
|
28
|
+
0x3bf6, 0x1cea, 0x3bf6, 0x1cfb, 0x3bf3, 0x1d38, 0x3bec, 0x1dbd, 0x3bda, 0x1e7c, 0x3bb7, 0x1f25, 0x3b7d, 0x1f79, 0x3b2c, 0x1f4c,
|
|
29
|
+
0x3ac6, 0x1ea6, 0x3a55, 0x1dbb, 0x39da, 0x1cbd, 0x395a, 0x1b9d, 0x38d8, 0x1a00, 0x3855, 0x18ac, 0x37ab, 0x173c, 0x36b7, 0x1598,
|
|
30
|
+
0x3bfc, 0x1736, 0x3bfc, 0x1759, 0x3bf9, 0x17e7, 0x3bf4, 0x1896, 0x3be4, 0x1997, 0x3bc6, 0x1aa8, 0x3b91, 0x1b84, 0x3b43, 0x1bd2,
|
|
31
|
+
0x3ade, 0x1b8a, 0x3a65, 0x1acd, 0x39e2, 0x19d3, 0x3957, 0x18cd, 0x38ca, 0x17b3, 0x383e, 0x1613, 0x376d, 0x14bf, 0x366f, 0x135e,
|
|
32
|
+
0x3bff, 0x101b, 0x3bff, 0x1039, 0x3bfc, 0x10c8, 0x3bf9, 0x1226, 0x3bea, 0x1428, 0x3bcf, 0x1584, 0x3b9f, 0x16c5, 0x3b54, 0x179a,
|
|
33
|
+
0x3af0, 0x17ce, 0x3a76, 0x1771, 0x39ea, 0x16a4, 0x3956, 0x15a7, 0x38bf, 0x14a7, 0x3829, 0x1379, 0x3735, 0x11ea, 0x362d, 0x10a1,
|
|
34
|
+
0x3c00, 0x061b, 0x3c00, 0x066a, 0x3bfe, 0x081c, 0x3bfa, 0x0a4c, 0x3bed, 0x0d16, 0x3bd5, 0x0fb3, 0x3ba9, 0x114d, 0x3b63, 0x127c,
|
|
35
|
+
0x3b01, 0x132f, 0x3a85, 0x1344, 0x39f4, 0x12d2, 0x3957, 0x120d, 0x38b5, 0x1122, 0x3817, 0x103c, 0x3703, 0x0ed3, 0x35f0, 0x0d6d,
|
|
36
|
+
0x3c00, 0x007a, 0x3c00, 0x0089, 0x3bfe, 0x011d, 0x3bfb, 0x027c, 0x3bf0, 0x04fa, 0x3bda, 0x0881, 0x3bb1, 0x0acd, 0x3b6f, 0x0c97,
|
|
37
|
+
0x3b10, 0x0d7b, 0x3a93, 0x0df1, 0x39fe, 0x0def, 0x3959, 0x0d8a, 0x38af, 0x0ce9, 0x3808, 0x0c31, 0x36d5, 0x0af0, 0x35b9, 0x09a3,
|
|
38
|
+
0x3c00, 0x0000, 0x3c00, 0x0001, 0x3bff, 0x0015, 0x3bfb, 0x0059, 0x3bf2, 0x00fd, 0x3bdd, 0x01df, 0x3bb7, 0x031c, 0x3b79, 0x047c,
|
|
39
|
+
0x3b1d, 0x05d4, 0x3aa0, 0x06d5, 0x3a08, 0x075a, 0x395d, 0x075e, 0x38aa, 0x06f7, 0x37f4, 0x0648, 0x36ac, 0x0576, 0x3586, 0x049f,
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const SIZE = 16;
|
|
43
|
+
|
|
44
|
+
function half(h) {
|
|
45
|
+
const e = (h >> 10) & 0x1f, f = h & 0x3ff, sign = h & 0x8000 ? -1 : 1;
|
|
46
|
+
if (e === 0) return sign * 2 ** -14 * (f / 1024);
|
|
47
|
+
return sign * 2 ** (e - 15) * (1 + f / 1024);
|
|
48
|
+
}
|
|
49
|
+
const LUT = Float32Array.from(DATA, half);
|
|
50
|
+
|
|
51
|
+
// LinearFilter + ClampToEdge sample at (u, v) = (roughness, dotNV) -> [scale, bias].
|
|
52
|
+
export function dfgLut(roughness, dotNV) {
|
|
53
|
+
const x = Math.min(Math.max(roughness * SIZE - 0.5, 0), SIZE - 1);
|
|
54
|
+
const y = Math.min(Math.max(dotNV * SIZE - 0.5, 0), SIZE - 1);
|
|
55
|
+
const x0 = Math.floor(x), y0 = Math.floor(y);
|
|
56
|
+
const x1 = Math.min(x0 + 1, SIZE - 1), y1 = Math.min(y0 + 1, SIZE - 1);
|
|
57
|
+
const fx = x - x0, fy = y - y0;
|
|
58
|
+
const at = (xi, yi, c) => LUT[(yi * SIZE + xi) * 2 + c];
|
|
59
|
+
const mix = (c) => {
|
|
60
|
+
const a = at(x0, y0, c) * (1 - fx) + at(x1, y0, c) * fx;
|
|
61
|
+
const b = at(x0, y1, c) * (1 - fx) + at(x1, y1, c) * fx;
|
|
62
|
+
return a * (1 - fy) + b * fy;
|
|
63
|
+
};
|
|
64
|
+
return [mix(0), mix(1)];
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/framework/softrender/edges.js
|
|
2
|
+
// Feature edges as lines of the style's width, stamped at supersample
|
|
3
|
+
// resolution (so they come out antialiased after the downsample) and depth-
|
|
4
|
+
// tested against the G-buffer with a scene-relative bias. Coverage is marked
|
|
5
|
+
// first and blended ONCE per sample: overlapping stamps of a translucent line
|
|
6
|
+
// must not darken where they overlap.
|
|
7
|
+
import { NEAR } from "./raster.js";
|
|
8
|
+
|
|
9
|
+
export function drawEdges(color, gb, cam, segments, rgb, opacity, widthPx, ss, bias) {
|
|
10
|
+
if (!segments?.length || opacity <= 0) return;
|
|
11
|
+
const { width: W, height: H, depth } = gb;
|
|
12
|
+
const mark = new Uint8Array(W * H);
|
|
13
|
+
const half = Math.max(0.5, (widthPx * ss) / 2);
|
|
14
|
+
const p0 = [0, 0, 0], p1 = [0, 0, 0];
|
|
15
|
+
for (let i = 0; i + 5 < segments.length; i += 6) {
|
|
16
|
+
cam.project(segments[i], segments[i + 1], segments[i + 2], p0);
|
|
17
|
+
cam.project(segments[i + 3], segments[i + 4], segments[i + 5], p1);
|
|
18
|
+
if (p0[2] < NEAR || p1[2] < NEAR) continue;
|
|
19
|
+
const steps = Math.max(1, Math.ceil(Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) * 2));
|
|
20
|
+
for (let s = 0; s <= steps; s++) {
|
|
21
|
+
const t = s / steps;
|
|
22
|
+
const x = p0[0] + (p1[0] - p0[0]) * t, y = p0[1] + (p1[1] - p0[1]) * t;
|
|
23
|
+
const z = 1 / ((1 - t) / p0[2] + t / p1[2]); // depth is linear in 1/z on screen
|
|
24
|
+
const xmin = Math.max(0, Math.floor(x - half + 0.5)), xmax = Math.min(W - 1, Math.max(xmin, Math.floor(x + half - 0.5)));
|
|
25
|
+
const ymin = Math.max(0, Math.floor(y - half + 0.5)), ymax = Math.min(H - 1, Math.max(ymin, Math.floor(y + half - 0.5)));
|
|
26
|
+
for (let yy = ymin; yy <= ymax; yy++) for (let xx = xmin; xx <= xmax; xx++) {
|
|
27
|
+
const k = yy * W + xx;
|
|
28
|
+
if (z - bias <= depth[k]) mark[k] = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (let k = 0; k < mark.length; k++) {
|
|
33
|
+
if (!mark[k]) continue;
|
|
34
|
+
const o = k * 3;
|
|
35
|
+
color[o] += (rgb[0] - color[o]) * opacity;
|
|
36
|
+
color[o + 1] += (rgb[1] - color[o + 1]) * opacity;
|
|
37
|
+
color[o + 2] += (rgb[2] - color[o + 2]) * opacity;
|
|
38
|
+
}
|
|
39
|
+
}
|