@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.
@@ -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
+ }