@huaqiu/dsh-tool-pcb-viewer 0.4.1
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/LICENSE +21 -0
- package/README.md +67 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +58877 -0
- package/lib/index.d.mts +26 -0
- package/lib/index.mjs +616 -0
- package/lib/standalone.js +58326 -0
- package/package.json +66 -0
- package/src/adapter.ts +237 -0
- package/src/assets/demo.d.ts +2 -0
- package/src/assets/demo.js +2 -0
- package/src/client/index.ts +6 -0
- package/src/client/panel.tsx +496 -0
- package/src/client/standalone.ts +35 -0
- package/src/index.ts +302 -0
- package/src/model.ts +91 -0
- package/src/parse.ts +24 -0
- package/src/pcb/outline.ts +115 -0
- package/src/pcb/parseKicad.ts +286 -0
- package/src/scene/buildBoard.ts +333 -0
- package/src/scene/components.ts +480 -0
- package/src/scene/materials.ts +23 -0
- package/src/scene/textures.ts +506 -0
- package/src/scene/view2d.ts +301 -0
- package/src/viewer.ts +556 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// textures.ts — procedural board-surface texture generation for the PCB
|
|
3
|
+
// renderer. Driven entirely by parsed board data (BoardModel); nothing is
|
|
4
|
+
// hardcoded to a specific board.
|
|
5
|
+
//
|
|
6
|
+
// const t = makeBoardTextures(board)
|
|
7
|
+
// → { maskTop, maskBot, copperTop, copperBot, inner: Map<layerName, texture>, W, H, outlinePath, S }
|
|
8
|
+
//
|
|
9
|
+
// Coordinate mapping (verified — do not change):
|
|
10
|
+
// px(x) = (x - bbox.x0) * S canvas x (mm → px)
|
|
11
|
+
// py(y) = (y - bbox.y0) * S canvas y (input data is pre-mirrored in Y)
|
|
12
|
+
// Every layer canvas is the same size and uses this mapping, so all layers
|
|
13
|
+
// align pixel-for-pixel. The board outline is clipped on every canvas so no
|
|
14
|
+
// artwork bleeds past the edge.
|
|
15
|
+
//
|
|
16
|
+
// Pure three.js + canvas 2D. Runs in the browser; in Node (no DOM) a tiny
|
|
17
|
+
// canvas shim lets module import and smoke tests succeed without rendering.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
// Path2D stub for Node (no DOM).
|
|
21
|
+
if (typeof (globalThis as { Path2D?: unknown }).Path2D === 'undefined') {
|
|
22
|
+
;(globalThis as Record<string, unknown>).Path2D = class {
|
|
23
|
+
moveTo() {} lineTo() {} arc() {} ellipse() {} rect() {} closePath() {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import * as THREE from 'three'
|
|
28
|
+
import { outlineParts } from '../pcb/outline.js'
|
|
29
|
+
import type { BoardModel, BoardPad } from '../model.js'
|
|
30
|
+
|
|
31
|
+
const MAX_CANVAS = 4096 // hard cap on canvas dimension (px)
|
|
32
|
+
|
|
33
|
+
type Pt = [number, number]
|
|
34
|
+
type Ctx = CanvasRenderingContext2D
|
|
35
|
+
|
|
36
|
+
// A minimal canvas stand-in with the 2D API surface this module uses.
|
|
37
|
+
interface ShimCanvas { width: number; height: number; getContext(id: string): Ctx }
|
|
38
|
+
|
|
39
|
+
// ---------- canvas factory (browser native, Node shim fallback) ----------
|
|
40
|
+
function makeCanvas(w: number, h: number): HTMLCanvasElement {
|
|
41
|
+
w = Math.max(1, Math.round(w))
|
|
42
|
+
h = Math.max(1, Math.round(h))
|
|
43
|
+
if (typeof document !== 'undefined' && document.createElement) {
|
|
44
|
+
const c = document.createElement('canvas')
|
|
45
|
+
c.width = w
|
|
46
|
+
c.height = h
|
|
47
|
+
return c
|
|
48
|
+
}
|
|
49
|
+
// Headless fallback: drawing calls no-op; geometry/size are still correct so
|
|
50
|
+
// tests can assert on them.
|
|
51
|
+
const noop = () => {}
|
|
52
|
+
const grad = { addColorStop: noop }
|
|
53
|
+
const ctx = {
|
|
54
|
+
save: noop, restore: noop, beginPath: noop, closePath: noop, moveTo: noop, lineTo: noop,
|
|
55
|
+
arc: noop, ellipse: noop, rect: noop, fillRect: noop, strokeRect: noop, clearRect: noop,
|
|
56
|
+
fill: noop, stroke: noop, clip: noop, translate: noop, rotate: noop, scale: noop,
|
|
57
|
+
fillText: noop, strokeText: noop, setLineDash: noop, putImageData: noop,
|
|
58
|
+
createLinearGradient: () => grad,
|
|
59
|
+
createRadialGradient: () => grad,
|
|
60
|
+
createPattern: () => ({}),
|
|
61
|
+
createImageData: (ww: number, hh: number) => ({ data: new Uint8ClampedArray(Math.max(1, ww * hh * 4)), width: ww, height: hh }),
|
|
62
|
+
getImageData: (_x: number, _y: number, ww: number, hh: number) => ({ data: new Uint8ClampedArray(Math.max(1, ww * hh * 4)), width: ww, height: hh }),
|
|
63
|
+
fillStyle: '', strokeStyle: '', lineWidth: 1, lineCap: 'butt', lineJoin: 'miter',
|
|
64
|
+
textAlign: 'center', textBaseline: 'middle', font: '', globalAlpha: 1, globalCompositeOperation: 'source-over',
|
|
65
|
+
}
|
|
66
|
+
const shim: ShimCanvas = { width: w, height: h, getContext: () => ctx as unknown as Ctx }
|
|
67
|
+
return shim as unknown as HTMLCanvasElement
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------- deterministic pseudo-random (stable textures across reloads) ----------
|
|
71
|
+
function mulberry32(seed: number): () => number {
|
|
72
|
+
let a = seed >>> 0
|
|
73
|
+
return function () {
|
|
74
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0
|
|
75
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
76
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
77
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- geometry helpers ----------
|
|
82
|
+
const dist = (p: Pt, q: Pt) => Math.hypot(p[0] - q[0], p[1] - q[1])
|
|
83
|
+
|
|
84
|
+
// Circle through three points (start, mid, end). Returns {cx, cy, r} or null if
|
|
85
|
+
// the points are collinear / degenerate.
|
|
86
|
+
function circleThrough3(a: Pt, m: Pt, b: Pt): { cx: number; cy: number; r: number } | null {
|
|
87
|
+
const ax = a[0], ay = a[1], mx = m[0], my = m[1], bx = b[0], by = b[1]
|
|
88
|
+
const d = 2 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my))
|
|
89
|
+
if (!isFinite(d) || Math.abs(d) < 1e-9) return null
|
|
90
|
+
const a2 = ax * ax + ay * ay, m2 = mx * mx + my * my, b2 = bx * bx + by * by
|
|
91
|
+
const ux = (a2 * (my - by) + m2 * (by - ay) + b2 * (ay - my)) / d
|
|
92
|
+
const uy = (a2 * (bx - mx) + m2 * (ax - bx) + b2 * (mx - ax)) / d
|
|
93
|
+
return { cx: ux, cy: uy, r: Math.hypot(ax - ux, ay - uy) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Append arc a→m→b (all in canvas px coords) to path using the 3-point circle.
|
|
97
|
+
// Works with any object exposing lineTo(x, y): Path2D or a 2D context mid-path.
|
|
98
|
+
function appendArcCanvas(path: { lineTo(x: number, y: number): void }, a: Pt, m: Pt, b: Pt, n = 12): void {
|
|
99
|
+
const c = circleThrough3(a, m, b)
|
|
100
|
+
if (!c) { path.lineTo(b[0], b[1]); return } // degenerate (collinear): straight to end
|
|
101
|
+
const ang = (p: Pt) => Math.atan2(p[1] - c.cy, p[0] - c.cx)
|
|
102
|
+
const a0 = ang(a), am0 = ang(m)
|
|
103
|
+
let am = am0
|
|
104
|
+
let a1 = ang(b)
|
|
105
|
+
while (am < a0 - Math.PI) am += 2 * Math.PI
|
|
106
|
+
while (am > a0 + Math.PI) am -= 2 * Math.PI
|
|
107
|
+
let delta = a1 - a0
|
|
108
|
+
while (delta <= -Math.PI) delta += 2 * Math.PI
|
|
109
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
110
|
+
if (Math.abs(am - a0) > Math.abs(am - (a0 + delta))) delta += delta < 0 ? 2 * Math.PI : -2 * Math.PI
|
|
111
|
+
for (let i = 1; i <= n; i++) {
|
|
112
|
+
const t = a0 + (delta * i) / n
|
|
113
|
+
path.lineTo(c.cx + c.r * Math.cos(t), c.cy + c.r * Math.sin(t))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- per-layer drawing (each wrapped so one bad path can't kill the build) ----------
|
|
118
|
+
function safe(label: string, fn: () => void): void {
|
|
119
|
+
try { fn() } catch (e) { console.warn(`[textures] ${label}:`, e instanceof Error ? e.message : e) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function padDiagonal(p: BoardPad): number { return Math.hypot(p.w || 0, p.l || 0) }
|
|
123
|
+
|
|
124
|
+
type Side = 'top' | 'bot'
|
|
125
|
+
|
|
126
|
+
// Pads on one side. `color` = fill; rim is a thin darker stroke.
|
|
127
|
+
function drawPads(ctx: Ctx, board: BoardModel, side: Side, color: string, growMm: number, S: number): void {
|
|
128
|
+
const px = (x: number) => (x - board.bbox.x0) * S
|
|
129
|
+
const py = (y: number) => (y - board.bbox.y0) * S
|
|
130
|
+
const grow = growMm * S
|
|
131
|
+
for (const c of board.comps || []) {
|
|
132
|
+
for (const p of c.pads || []) {
|
|
133
|
+
// top pads on maskTop, bottom on maskBot; through-hole pads on both
|
|
134
|
+
if (side === 'top' && !(p.top || p.th)) continue
|
|
135
|
+
if (side === 'bot' && !(p.bottom || p.th)) continue
|
|
136
|
+
const x = px(p.x), y = py(p.y)
|
|
137
|
+
const w = Math.max((p.w || 1) * S, 2) + grow * 2
|
|
138
|
+
const l = Math.max((p.l || 1) * S, 2) + grow * 2
|
|
139
|
+
ctx.fillStyle = color
|
|
140
|
+
ctx.strokeStyle = 'rgba(60,40,10,0.55)'
|
|
141
|
+
ctx.lineWidth = 1
|
|
142
|
+
if (p.shape === 'circle') {
|
|
143
|
+
const r = Math.max(w, l) / 2
|
|
144
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke()
|
|
145
|
+
} else if (p.shape === 'oval') {
|
|
146
|
+
ctx.beginPath(); ctx.ellipse(x, y, w / 2, l / 2, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke()
|
|
147
|
+
} else {
|
|
148
|
+
ctx.fillRect(x - w / 2, y - l / 2, w, l)
|
|
149
|
+
ctx.strokeRect(x - w / 2, y - l / 2, w, l)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Soft radial halos under each pad = the shiny solder fillet.
|
|
156
|
+
function drawSolderFillets(ctx: Ctx, board: BoardModel, side: Side, S: number): void {
|
|
157
|
+
const px = (x: number) => (x - board.bbox.x0) * S
|
|
158
|
+
const py = (y: number) => (y - board.bbox.y0) * S
|
|
159
|
+
for (const c of board.comps || []) {
|
|
160
|
+
for (const p of c.pads || []) {
|
|
161
|
+
if (side === 'top' && !(p.top || p.th)) continue
|
|
162
|
+
if (side === 'bot' && !(p.bottom || p.th)) continue
|
|
163
|
+
const x = px(p.x), y = py(p.y)
|
|
164
|
+
// tight, subtle sheen ring — big fuzzy halos make the board look dirty
|
|
165
|
+
const r = Math.max(padDiagonal(p) * 0.55, 0.5) * S
|
|
166
|
+
const g = ctx.createRadialGradient(x, y, r * 0.45, x, y, r)
|
|
167
|
+
g.addColorStop(0, 'rgba(214,224,232,0.0)')
|
|
168
|
+
g.addColorStop(0.72, 'rgba(214,224,232,0.26)')
|
|
169
|
+
g.addColorStop(1, 'rgba(214,224,232,0)')
|
|
170
|
+
ctx.fillStyle = g
|
|
171
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Silkscreen: board texts + per-component reference designators.
|
|
177
|
+
function drawSilk(ctx: Ctx, board: BoardModel, side: Side, S: number): void {
|
|
178
|
+
const px = (x: number) => (x - board.bbox.x0) * S
|
|
179
|
+
const py = (y: number) => (y - board.bbox.y0) * S
|
|
180
|
+
const layerName = side === 'top' ? 'F.SilkS' : 'B.SilkS'
|
|
181
|
+
ctx.fillStyle = 'rgba(240,245,248,0.92)'
|
|
182
|
+
ctx.textAlign = 'center'
|
|
183
|
+
ctx.textBaseline = 'middle'
|
|
184
|
+
|
|
185
|
+
safe(`silkscreen texts (${side})`, () => {
|
|
186
|
+
for (const t of board.texts || []) {
|
|
187
|
+
if ((t.layer || '') !== layerName) continue
|
|
188
|
+
const sizePx = Math.max((t.size || 1) * S, 4)
|
|
189
|
+
ctx.save()
|
|
190
|
+
ctx.translate(px(t.x), py(t.y))
|
|
191
|
+
// The canvas' vertical axis maps to the board flipped, so glyphs must be
|
|
192
|
+
// pre-flipped on the canvas (scale(1,-1)) to read upright on the board.
|
|
193
|
+
ctx.scale(1, -1)
|
|
194
|
+
ctx.rotate(-((t.rot || 0) * Math.PI) / 180)
|
|
195
|
+
ctx.font = `600 ${Math.round(sizePx)}px ui-monospace, Menlo, monospace`
|
|
196
|
+
ctx.fillText(t.text || '', 0, 0)
|
|
197
|
+
ctx.restore()
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
safe(`silkscreen refs (${side})`, () => {
|
|
202
|
+
const refSize = Math.max(1 * S, 5) // ~1 mm font
|
|
203
|
+
for (const c of board.comps || []) {
|
|
204
|
+
if (!c.ref) continue
|
|
205
|
+
const isTopComp = (c.layer || 'F.Cu') !== 'B.Cu'
|
|
206
|
+
if (side === 'top' && !isTopComp) continue
|
|
207
|
+
if (side === 'bot' && isTopComp) continue
|
|
208
|
+
if (/^M[23]/.test(c.fp || '')) continue // skip mounting-hardware refs
|
|
209
|
+
ctx.save()
|
|
210
|
+
ctx.translate(px(c.x), py(c.y))
|
|
211
|
+
ctx.scale(1, -1) // see note above — pre-flip glyphs
|
|
212
|
+
ctx.rotate(-((c.rot || 0) * Math.PI) / 180)
|
|
213
|
+
ctx.font = `600 ${Math.round(refSize)}px ui-monospace, Menlo, monospace`
|
|
214
|
+
ctx.fillText(c.ref, 0, -Math.max(2.5, refSize * 0.9))
|
|
215
|
+
ctx.restore()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Traces on one copper layer: polylines + arcs, round caps/joins.
|
|
221
|
+
function drawTraces(ctx: Ctx, board: BoardModel, layer: string, color: string, S: number): void {
|
|
222
|
+
const px = (x: number) => (x - board.bbox.x0) * S
|
|
223
|
+
const py = (y: number) => (y - board.bbox.y0) * S
|
|
224
|
+
ctx.strokeStyle = color
|
|
225
|
+
ctx.lineCap = 'round'
|
|
226
|
+
ctx.lineJoin = 'round'
|
|
227
|
+
for (const t of board.traces || []) {
|
|
228
|
+
if ((t.layer || '') !== layer) continue
|
|
229
|
+
safe(`trace (${layer})`, () => {
|
|
230
|
+
ctx.lineWidth = Math.max((t.w || 0.25) * S, 1.5)
|
|
231
|
+
ctx.beginPath()
|
|
232
|
+
if (t.arc && t.pts && t.pts.length === 3) {
|
|
233
|
+
const a: Pt = [px(t.pts[0]![0]), py(t.pts[0]![1])]
|
|
234
|
+
const m: Pt = [px(t.pts[1]![0]), py(t.pts[1]![1])]
|
|
235
|
+
const b: Pt = [px(t.pts[2]![0]), py(t.pts[2]![1])]
|
|
236
|
+
ctx.moveTo(a[0], a[1])
|
|
237
|
+
appendArcCanvas(ctx, a, m, b, 8) // ctx works as a path sink (lineTo)
|
|
238
|
+
} else {
|
|
239
|
+
const pts = t.pts || []
|
|
240
|
+
if (!pts.length) return
|
|
241
|
+
ctx.moveTo(px(pts[0]![0]), py(pts[0]![1]))
|
|
242
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(px(pts[i]![0]), py(pts[i]![1]))
|
|
243
|
+
}
|
|
244
|
+
ctx.stroke()
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Copper zones (pours) on one layer.
|
|
250
|
+
function drawZones(ctx: Ctx, board: BoardModel, layer: string, S: number): void {
|
|
251
|
+
const px = (x: number) => (x - board.bbox.x0) * S
|
|
252
|
+
const py = (y: number) => (y - board.bbox.y0) * S
|
|
253
|
+
ctx.fillStyle = 'rgba(190,120,50,0.55)'
|
|
254
|
+
for (const z of board.zones || []) {
|
|
255
|
+
if ((z.layer || '') !== layer) continue
|
|
256
|
+
safe(`zone (${layer})`, () => {
|
|
257
|
+
const pts = z.pts || []
|
|
258
|
+
if (pts.length < 3) return
|
|
259
|
+
ctx.beginPath()
|
|
260
|
+
ctx.moveTo(px(pts[0]![0]), py(pts[0]![1]))
|
|
261
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(px(pts[i]![0]), py(pts[i]![1]))
|
|
262
|
+
ctx.closePath()
|
|
263
|
+
ctx.fill()
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Vias: ring + drill hole. Drawn on both copper faces.
|
|
269
|
+
function drawVias(ctx: Ctx, board: BoardModel, S: number): void {
|
|
270
|
+
const px = (x: number) => (x - board.bbox.x0) * S
|
|
271
|
+
const py = (y: number) => (y - board.bbox.y0) * S
|
|
272
|
+
for (const v of board.vias || []) {
|
|
273
|
+
safe('via', () => {
|
|
274
|
+
const x = px(v.x), y = py(v.y)
|
|
275
|
+
ctx.fillStyle = '#b87333'
|
|
276
|
+
ctx.beginPath(); ctx.arc(x, y, Math.max((v.size || 0.8) / 2 * S, 1.5), 0, Math.PI * 2); ctx.fill()
|
|
277
|
+
ctx.fillStyle = '#2a1a0e'
|
|
278
|
+
ctx.beginPath(); ctx.arc(x, y, Math.max((v.drill || v.size * 0.5) / 2 * S, 0.75), 0, Math.PI * 2); ctx.fill()
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Faint copper mottling so the base doesn't read as flat color.
|
|
284
|
+
function drawCopperNoise(ctx: Ctx, w: number, h: number, rng: () => number, alpha = 0.08): void {
|
|
285
|
+
const img = ctx.getImageData(0, 0, w, h)
|
|
286
|
+
const d = img.data
|
|
287
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
288
|
+
const n = (rng() - 0.5) * 2 * alpha * 255
|
|
289
|
+
d[i] = Math.max(0, Math.min(255, (d[i] ?? 0) + n))
|
|
290
|
+
d[i + 1] = Math.max(0, Math.min(255, (d[i + 1] ?? 0) + n * 0.85))
|
|
291
|
+
d[i + 2] = Math.max(0, Math.min(255, (d[i + 2] ?? 0) + n * 0.6))
|
|
292
|
+
}
|
|
293
|
+
ctx.putImageData(img, 0, 0)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- texture assembly ----------
|
|
297
|
+
function toTexture(canvas: HTMLCanvasElement): THREE.CanvasTexture {
|
|
298
|
+
const t = new THREE.CanvasTexture(canvas)
|
|
299
|
+
t.colorSpace = THREE.SRGBColorSpace
|
|
300
|
+
t.anisotropy = 8
|
|
301
|
+
return t
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function makeMaskCanvas(board: BoardModel, side: Side, W: number, H: number, S: number, outlinePath: Path2D | null): HTMLCanvasElement {
|
|
305
|
+
const canvas = makeCanvas(W, H)
|
|
306
|
+
const ctx = canvas.getContext('2d')!
|
|
307
|
+
ctx.save()
|
|
308
|
+
if (outlinePath) { ctx.beginPath(); ctx.clip(outlinePath, 'evenodd') }
|
|
309
|
+
|
|
310
|
+
// deep green base with a vertical gradient for subtle depth
|
|
311
|
+
const g = ctx.createLinearGradient(0, 0, 0, H)
|
|
312
|
+
g.addColorStop(0, '#0f5e30')
|
|
313
|
+
g.addColorStop(1, '#0b512a')
|
|
314
|
+
ctx.fillStyle = g
|
|
315
|
+
ctx.fillRect(0, 0, W, H)
|
|
316
|
+
|
|
317
|
+
// real solder mask is slightly translucent — traces ghost through as faint shadows
|
|
318
|
+
safe(`mask trace ghost (${side})`, () => drawTraces(ctx, board, side === 'top' ? 'F.Cu' : 'B.Cu', 'rgba(6,26,14,0.35)', S))
|
|
319
|
+
|
|
320
|
+
safe(`mask fillets (${side})`, () => drawSolderFillets(ctx, board, side, S))
|
|
321
|
+
safe(`mask pads (${side})`, () => drawPads(ctx, board, side, '#cda94e', 0.08, S))
|
|
322
|
+
safe(`mask silk (${side})`, () => drawSilk(ctx, board, side, S))
|
|
323
|
+
|
|
324
|
+
ctx.restore()
|
|
325
|
+
return canvas
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function makeCopperCanvas(board: BoardModel, layer: string, W: number, H: number, S: number, outlinePath: Path2D | null): HTMLCanvasElement {
|
|
329
|
+
const canvas = makeCanvas(W, H)
|
|
330
|
+
const ctx = canvas.getContext('2d')!
|
|
331
|
+
ctx.save()
|
|
332
|
+
if (outlinePath) { ctx.beginPath(); ctx.clip(outlinePath, 'evenodd') }
|
|
333
|
+
|
|
334
|
+
ctx.fillStyle = '#7a4a1e'
|
|
335
|
+
ctx.fillRect(0, 0, W, H)
|
|
336
|
+
safe(`copper noise (${layer})`, () => drawCopperNoise(ctx, canvas.width, canvas.height, mulberry32(layer === 'F.Cu' ? 1234 : 5678)))
|
|
337
|
+
|
|
338
|
+
safe(`copper zones (${layer})`, () => drawZones(ctx, board, layer, S))
|
|
339
|
+
safe(`copper traces (${layer})`, () => drawTraces(ctx, board, layer, '#c98a3a', S))
|
|
340
|
+
const side: Side = layer === 'F.Cu' ? 'top' : 'bot'
|
|
341
|
+
safe(`copper pads (${layer})`, () => drawPads(ctx, board, side, '#e0b84e', 0, S))
|
|
342
|
+
safe(`copper vias (${layer})`, () => drawVias(ctx, board, S))
|
|
343
|
+
|
|
344
|
+
ctx.restore()
|
|
345
|
+
return canvas
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function makeInnerCanvas(layerName: string, W: number, H: number, S: number, outlinePath: Path2D | null, idx: number): HTMLCanvasElement {
|
|
349
|
+
const canvas = makeCanvas(W, H)
|
|
350
|
+
const ctx = canvas.getContext('2d')!
|
|
351
|
+
ctx.save()
|
|
352
|
+
if (outlinePath) { ctx.beginPath(); ctx.clip(outlinePath, 'evenodd') }
|
|
353
|
+
|
|
354
|
+
ctx.fillStyle = '#8a5a28'
|
|
355
|
+
ctx.fillRect(0, 0, W, H)
|
|
356
|
+
|
|
357
|
+
// faint darker traces — deterministic pseudo-random routing per inner layer
|
|
358
|
+
safe(`inner traces (${layerName})`, () => {
|
|
359
|
+
const rng = mulberry32(97 + idx * 131)
|
|
360
|
+
ctx.strokeStyle = 'rgba(40,22,8,0.25)'
|
|
361
|
+
ctx.lineCap = 'round'
|
|
362
|
+
ctx.lineJoin = 'round'
|
|
363
|
+
const n = 60 + Math.floor(rng() * 40)
|
|
364
|
+
for (let i = 0; i < n; i++) {
|
|
365
|
+
let x = rng() * W, y = rng() * H
|
|
366
|
+
ctx.lineWidth = 1.5 + rng() * 3
|
|
367
|
+
ctx.beginPath()
|
|
368
|
+
ctx.moveTo(x, y)
|
|
369
|
+
const steps = 2 + Math.floor(rng() * 4)
|
|
370
|
+
for (let s = 0; s < steps; s++) {
|
|
371
|
+
// mostly axis-aligned with occasional 45° jog — reads as inner-layer routing
|
|
372
|
+
if (rng() < 0.72) x += (rng() - 0.5) * W * 0.35
|
|
373
|
+
else y += (rng() - 0.5) * H * 0.35
|
|
374
|
+
ctx.lineTo(x, y)
|
|
375
|
+
}
|
|
376
|
+
ctx.stroke()
|
|
377
|
+
}
|
|
378
|
+
// a few pour islands
|
|
379
|
+
ctx.fillStyle = 'rgba(40,22,8,0.18)'
|
|
380
|
+
for (let i = 0; i < 14; i++) {
|
|
381
|
+
const x = rng() * W, y = rng() * H
|
|
382
|
+
const w = 8 + rng() * 50, h = 8 + rng() * 40
|
|
383
|
+
ctx.fillRect(x, y, w, h)
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
ctx.restore()
|
|
388
|
+
return canvas
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Public API
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
export interface BoardTextures {
|
|
396
|
+
maskTop: THREE.Texture
|
|
397
|
+
maskBot: THREE.Texture
|
|
398
|
+
copperTop: THREE.Texture
|
|
399
|
+
copperBot: THREE.Texture
|
|
400
|
+
inner: Map<string, THREE.Texture | null>
|
|
401
|
+
W: number
|
|
402
|
+
H: number
|
|
403
|
+
outlinePath: Path2D
|
|
404
|
+
S: number
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Subtle micro-grain for the solder mask: fine speckle + soft blotches.
|
|
408
|
+
// STANDALONE — no board data; used as a repeat-wrapped roughnessMap.
|
|
409
|
+
let _grainCache: THREE.CanvasTexture | null = null
|
|
410
|
+
export function makeMaskGrainTexture(): THREE.CanvasTexture {
|
|
411
|
+
if (_grainCache) return _grainCache
|
|
412
|
+
const size = 256
|
|
413
|
+
const c = makeCanvas(size, size)
|
|
414
|
+
const ctx = c.getContext('2d')!
|
|
415
|
+
const rng = mulberry32(424242)
|
|
416
|
+
safe('grain speckle', () => {
|
|
417
|
+
const img = ctx.createImageData(size, size)
|
|
418
|
+
for (let i = 0; i < img.data.length; i += 4) {
|
|
419
|
+
const n = 235 + Math.floor(rng() * 20)
|
|
420
|
+
img.data[i] = img.data[i + 1] = img.data[i + 2] = n
|
|
421
|
+
img.data[i + 3] = 255
|
|
422
|
+
}
|
|
423
|
+
ctx.putImageData(img, 0, 0)
|
|
424
|
+
})
|
|
425
|
+
safe('grain blotches', () => {
|
|
426
|
+
for (let i = 0; i < 60; i++) {
|
|
427
|
+
const x = rng() * size, y = rng() * size
|
|
428
|
+
const r = 8 + rng() * 30
|
|
429
|
+
const g = ctx.createRadialGradient(x, y, 0, x, y, r)
|
|
430
|
+
const v = rng() > 0.5 ? 255 : 210
|
|
431
|
+
g.addColorStop(0, `rgba(${v},${v},${v},0.06)`)
|
|
432
|
+
g.addColorStop(1, 'rgba(255,255,255,0)')
|
|
433
|
+
ctx.fillStyle = g
|
|
434
|
+
ctx.fillRect(x - r, y - r, r * 2, r * 2)
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
const t = new THREE.CanvasTexture(c)
|
|
438
|
+
t.wrapS = t.wrapT = THREE.RepeatWrapping
|
|
439
|
+
t.repeat.set(9, 5)
|
|
440
|
+
_grainCache = t
|
|
441
|
+
return t
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Build every board-surface texture from parsed KiCad data.
|
|
445
|
+
export function makeBoardTextures(board: BoardModel): BoardTextures {
|
|
446
|
+
const bb = board.bbox || { x0: 0, y0: 0, x1: 20, y1: 20 }
|
|
447
|
+
let S = 16 // px per mm
|
|
448
|
+
// clamp canvas so width*S ≤ MAX_CANVAS (boards ~20mm → ~350mm all fit)
|
|
449
|
+
const mmW = Math.max(bb.x1 - bb.x0, 1e-6)
|
|
450
|
+
const mmH = Math.max(bb.y1 - bb.y0, 1e-6)
|
|
451
|
+
S = Math.min(S, MAX_CANVAS / mmW, MAX_CANVAS / mmH)
|
|
452
|
+
S = Math.max(S, 0.5)
|
|
453
|
+
|
|
454
|
+
const W = Math.round(mmW * S)
|
|
455
|
+
const H = Math.round(mmH * S)
|
|
456
|
+
const px = (x: number) => (x - bb.x0) * S
|
|
457
|
+
const py = (y: number) => (y - bb.y0) * S
|
|
458
|
+
|
|
459
|
+
// outline Path2D in canvas px space; fall back to the bbox rect.
|
|
460
|
+
// Uses the validated shared stitcher (src/pcb/outline.ts). Holes (Edge.Cuts
|
|
461
|
+
// circles) are appended as extra subpaths; every consumer clips with the
|
|
462
|
+
// 'evenodd' rule so a hole punches through regardless of its winding.
|
|
463
|
+
let outlinePath: Path2D
|
|
464
|
+
try {
|
|
465
|
+
const parts = outlineParts(board)
|
|
466
|
+
const path = new Path2D()
|
|
467
|
+
const sub = (pts: [number, number][]) => {
|
|
468
|
+
pts.forEach((p, i) => { const X = px(p[0]), Y = py(p[1]); if (i === 0) path.moveTo(X, Y); else path.lineTo(X, Y) })
|
|
469
|
+
path.closePath()
|
|
470
|
+
}
|
|
471
|
+
sub(parts.ring)
|
|
472
|
+
for (const h of parts.holes) sub(h)
|
|
473
|
+
outlinePath = path
|
|
474
|
+
} catch (e) {
|
|
475
|
+
console.warn('[textures] outline stitch failed:', e instanceof Error ? e.message : e)
|
|
476
|
+
outlinePath = new Path2D()
|
|
477
|
+
outlinePath.rect(0, 0, W, H)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const cuLayers = Array.isArray(board.cuLayers) && board.cuLayers.length >= 2 ? board.cuLayers : ['F.Cu', 'B.Cu']
|
|
481
|
+
const innerLayers = cuLayers.filter((l) => l !== 'F.Cu' && l !== 'B.Cu')
|
|
482
|
+
|
|
483
|
+
const maskTop = toTexture(makeMaskCanvas(board, 'top', W, H, S, outlinePath))
|
|
484
|
+
const maskBot = toTexture(makeMaskCanvas(board, 'bot', W, H, S, outlinePath))
|
|
485
|
+
const copperTop = toTexture(makeCopperCanvas(board, 'F.Cu', W, H, S, outlinePath))
|
|
486
|
+
const copperBot = toTexture(makeCopperCanvas(board, 'B.Cu', W, H, S, outlinePath))
|
|
487
|
+
|
|
488
|
+
const inner = new Map<string, THREE.Texture | null>()
|
|
489
|
+
// Inner layers sit INSIDE the stack — barely visible — so they render at half
|
|
490
|
+
// resolution (a quarter of the pixels). On a 10-layer board that removes 8
|
|
491
|
+
// full-size canvases from the critical path. UVs are unchanged.
|
|
492
|
+
const Sinner = S * 0.5
|
|
493
|
+
const Winner = Math.max(1, Math.round(mmW * Sinner))
|
|
494
|
+
const Hinner = Math.max(1, Math.round(mmH * Sinner))
|
|
495
|
+
innerLayers.forEach((layerName, i) => {
|
|
496
|
+
let tex: THREE.Texture | null = null
|
|
497
|
+
try {
|
|
498
|
+
tex = toTexture(makeInnerCanvas(layerName, Winner, Hinner, Sinner, outlinePath, i))
|
|
499
|
+
} catch (e) {
|
|
500
|
+
console.warn(`[textures] inner layer ${layerName}:`, e instanceof Error ? e.message : e)
|
|
501
|
+
}
|
|
502
|
+
inner.set(layerName, tex)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
return { maskTop, maskBot, copperTop, copperBot, inner, W, H, outlinePath, S }
|
|
506
|
+
}
|