pcb-scene3d-viewer 1.1.21 → 1.1.31
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/README.md +3 -2
- package/docs/api.md +27 -1
- package/docs/circuitjson.md +63 -18
- package/docs/model-format.md +13 -2
- package/package.json +1 -1
- package/spec/library-scope.md +2 -2
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
- package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
- package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
- package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
- package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
- package/src/PcbAssemblyGltfValidator.mjs +460 -0
- package/src/PcbAssemblyGltfWriter.mjs +801 -0
- package/src/PcbAssemblyMeshUtils.mjs +117 -0
- package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
- package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
- package/src/PcbAssemblyStepWriter.mjs +24 -2
- package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
- package/src/PcbModelArchiveExporter.mjs +521 -7
- package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
- package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
- package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
- package/src/PcbScene3dComponentVisibility.mjs +100 -5
- package/src/PcbScene3dController.mjs +25 -55
- package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
- package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
- package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
- package/src/PcbScene3dCopperFactory.mjs +99 -85
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
- package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
- package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
- package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
- package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
- package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
- package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
- package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
- package/src/PcbScene3dExternalModels.mjs +16 -7
- package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
- package/src/PcbScene3dFallbackVisibility.mjs +58 -1
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
- package/src/PcbScene3dPadFactory.mjs +35 -2
- package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
- package/src/PcbScene3dRuntime.mjs +94 -100
- package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
- package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
- package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
- package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
- package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
- package/src/PcbScene3dSelectionStyler.mjs +52 -2
- package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
- package/src/PcbScene3dText.mjs +1 -0
- package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
- package/src/PcbScene3dViaFactory.mjs +27 -7
- package/src/scene3d.mjs +3 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import earcut from 'earcut'
|
|
2
|
+
|
|
1
3
|
const FULL_CIRCLE_DEGREES = 360
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -88,6 +90,100 @@ export class PcbAssemblyMeshUtils {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Builds an extruded polygon mesh with inner cutout loops.
|
|
95
|
+
* @param {string} name Mesh name.
|
|
96
|
+
* @param {number[][]} outerPoints Outer polygon points in mils.
|
|
97
|
+
* @param {number[][][]} holeLoops Inner cutout loops in mils.
|
|
98
|
+
* @param {number} z Center Z in mils.
|
|
99
|
+
* @param {number} thickness Extrusion thickness in mils.
|
|
100
|
+
* @param {number[] | undefined} color Optional RGB color.
|
|
101
|
+
* @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] } | null}
|
|
102
|
+
*/
|
|
103
|
+
static prismWithHoles(
|
|
104
|
+
name,
|
|
105
|
+
outerPoints,
|
|
106
|
+
holeLoops,
|
|
107
|
+
z,
|
|
108
|
+
thickness,
|
|
109
|
+
color = undefined
|
|
110
|
+
) {
|
|
111
|
+
const outer = PcbAssemblyMeshUtils.cleanLoop(outerPoints)
|
|
112
|
+
const holes = (Array.isArray(holeLoops) ? holeLoops : [])
|
|
113
|
+
.map((loop) => PcbAssemblyMeshUtils.cleanLoop(loop))
|
|
114
|
+
.filter((loop) => loop.length >= 3)
|
|
115
|
+
if (outer.length < 3) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
if (!holes.length) {
|
|
119
|
+
return PcbAssemblyMeshUtils.prism(name, outer, z, thickness, color)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const loops = [outer, ...holes]
|
|
123
|
+
const holeIndexes = []
|
|
124
|
+
const points = []
|
|
125
|
+
const flat = []
|
|
126
|
+
for (const loop of loops) {
|
|
127
|
+
if (points.length) {
|
|
128
|
+
holeIndexes.push(points.length)
|
|
129
|
+
}
|
|
130
|
+
for (const point of loop) {
|
|
131
|
+
points.push(point)
|
|
132
|
+
flat.push(point[0], point[1])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const triangles = earcut(flat, holeIndexes, 2)
|
|
137
|
+
if (!triangles.length) {
|
|
138
|
+
return PcbAssemblyMeshUtils.prism(name, outer, z, thickness, color)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const halfThickness = Math.max(Number(thickness || 0), 0.001) / 2
|
|
142
|
+
const bottomZ = Number(z || 0) - halfThickness
|
|
143
|
+
const topZ = Number(z || 0) + halfThickness
|
|
144
|
+
const topOffset = points.length
|
|
145
|
+
const vertices = [
|
|
146
|
+
...points.map((point) => [point[0], point[1], bottomZ]),
|
|
147
|
+
...points.map((point) => [point[0], point[1], topZ])
|
|
148
|
+
]
|
|
149
|
+
const faces = []
|
|
150
|
+
|
|
151
|
+
for (let index = 0; index + 2 < triangles.length; index += 3) {
|
|
152
|
+
const a = triangles[index]
|
|
153
|
+
const b = triangles[index + 1]
|
|
154
|
+
const c = triangles[index + 2]
|
|
155
|
+
faces.push([c, b, a])
|
|
156
|
+
faces.push([a + topOffset, b + topOffset, c + topOffset])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let loopStart = 0
|
|
160
|
+
PcbAssemblyMeshUtils.#appendLoopWalls(
|
|
161
|
+
faces,
|
|
162
|
+
loopStart,
|
|
163
|
+
outer.length,
|
|
164
|
+
topOffset,
|
|
165
|
+
false
|
|
166
|
+
)
|
|
167
|
+
loopStart += outer.length
|
|
168
|
+
for (const hole of holes) {
|
|
169
|
+
PcbAssemblyMeshUtils.#appendLoopWalls(
|
|
170
|
+
faces,
|
|
171
|
+
loopStart,
|
|
172
|
+
hole.length,
|
|
173
|
+
topOffset,
|
|
174
|
+
true
|
|
175
|
+
)
|
|
176
|
+
loopStart += hole.length
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
name,
|
|
181
|
+
vertices,
|
|
182
|
+
faces,
|
|
183
|
+
...(Array.isArray(color) ? { color } : {})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
91
187
|
/**
|
|
92
188
|
* Builds a cylinder mesh from polygonal rings.
|
|
93
189
|
* @param {string} name Mesh name.
|
|
@@ -538,4 +634,25 @@ export class PcbAssemblyMeshUtils {
|
|
|
538
634
|
.replace(/^-+|-+$/gu, '')
|
|
539
635
|
.slice(0, 80)
|
|
540
636
|
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Appends side-wall faces for one loop.
|
|
640
|
+
* @param {number[][]} faces Mutable face list.
|
|
641
|
+
* @param {number} start Loop start index.
|
|
642
|
+
* @param {number} length Loop length.
|
|
643
|
+
* @param {number} topOffset Top vertex offset.
|
|
644
|
+
* @param {boolean} reverse Whether to reverse wall orientation.
|
|
645
|
+
* @returns {void}
|
|
646
|
+
*/
|
|
647
|
+
static #appendLoopWalls(faces, start, length, topOffset, reverse) {
|
|
648
|
+
for (let index = 0; index < length; index += 1) {
|
|
649
|
+
const current = start + index
|
|
650
|
+
const next = start + ((index + 1) % length)
|
|
651
|
+
faces.push(
|
|
652
|
+
reverse
|
|
653
|
+
? [next, current, current + topOffset, next + topOffset]
|
|
654
|
+
: [current, next, next + topOffset, current + topOffset]
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
541
658
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PcbAssemblyGltfModelMeshParser } from './PcbAssemblyGltfModelMeshParser.mjs'
|
|
2
|
+
import { PcbAssemblyTextModelMeshParser } from './PcbAssemblyTextModelMeshParser.mjs'
|
|
1
3
|
import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -31,6 +33,22 @@ export class PcbAssemblyModelMeshLoader {
|
|
|
31
33
|
return this.#loadWrlMeshes(model)
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
if (format === 'stl') {
|
|
37
|
+
return await PcbAssemblyTextModelMeshParser.parseStlModel(model)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (format === 'obj') {
|
|
41
|
+
return await PcbAssemblyTextModelMeshParser.parseObjModel(model)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (format === 'gltf') {
|
|
45
|
+
return await PcbAssemblyGltfModelMeshParser.parseGltfModel(model)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (format === 'glb') {
|
|
49
|
+
return await PcbAssemblyGltfModelMeshParser.parseGlbModel(model)
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
throw new Error('Unsupported model format: ' + (format || 'unknown'))
|
|
35
53
|
}
|
|
36
54
|
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
|
|
2
|
+
|
|
3
|
+
const GEOMETRY_EPSILON = 0.001
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds PCB assembly copper pad meshes.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbAssemblyPadMeshBuilder {
|
|
9
|
+
/**
|
|
10
|
+
* Builds one pad mesh for an assembly export.
|
|
11
|
+
* @param {string} name Mesh name.
|
|
12
|
+
* @param {object} pad Pad primitive.
|
|
13
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
14
|
+
* @param {number} z Center Z.
|
|
15
|
+
* @param {number} thickness Extrusion thickness.
|
|
16
|
+
* @param {number[]} color Mesh color.
|
|
17
|
+
* @returns {object | null}
|
|
18
|
+
*/
|
|
19
|
+
static build(name, pad, side, z, thickness, color) {
|
|
20
|
+
const size = PcbAssemblyPadMeshBuilder.#padSize(pad, side)
|
|
21
|
+
if (!size) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const offset = PcbAssemblyPadMeshBuilder.#padOffset(pad, side)
|
|
26
|
+
const x = Number(pad?.x || 0) + offset.x
|
|
27
|
+
const y = Number(pad?.y || 0) + offset.y
|
|
28
|
+
const shape = PcbAssemblyPadMeshBuilder.#padShape(pad, side)
|
|
29
|
+
const isCircle =
|
|
30
|
+
Math.abs(size.width - size.height) < GEOMETRY_EPSILON && shape !== 2
|
|
31
|
+
const hole = PcbAssemblyPadMeshBuilder.#holeLoop(pad, x, y)
|
|
32
|
+
const mesh = hole
|
|
33
|
+
? PcbAssemblyPadMeshBuilder.#drilledMesh(
|
|
34
|
+
name,
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
size,
|
|
38
|
+
isCircle,
|
|
39
|
+
hole,
|
|
40
|
+
z,
|
|
41
|
+
thickness,
|
|
42
|
+
color
|
|
43
|
+
)
|
|
44
|
+
: PcbAssemblyPadMeshBuilder.#solidMesh(
|
|
45
|
+
name,
|
|
46
|
+
x,
|
|
47
|
+
y,
|
|
48
|
+
size,
|
|
49
|
+
isCircle,
|
|
50
|
+
z,
|
|
51
|
+
thickness,
|
|
52
|
+
color
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return PcbAssemblyPadMeshBuilder.#rotateMeshAroundZ(
|
|
56
|
+
mesh,
|
|
57
|
+
Number(pad?.rotation || 0),
|
|
58
|
+
x,
|
|
59
|
+
y
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds one solid pad mesh.
|
|
65
|
+
* @param {string} name Mesh name.
|
|
66
|
+
* @param {number} x Pad center X.
|
|
67
|
+
* @param {number} y Pad center Y.
|
|
68
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
69
|
+
* @param {boolean} isCircle Whether the pad is circular.
|
|
70
|
+
* @param {number} z Center Z.
|
|
71
|
+
* @param {number} thickness Extrusion thickness.
|
|
72
|
+
* @param {number[]} color Mesh color.
|
|
73
|
+
* @returns {object}
|
|
74
|
+
*/
|
|
75
|
+
static #solidMesh(name, x, y, size, isCircle, z, thickness, color) {
|
|
76
|
+
return isCircle
|
|
77
|
+
? PcbAssemblyMeshUtils.cylinder(name, {
|
|
78
|
+
x,
|
|
79
|
+
y,
|
|
80
|
+
z,
|
|
81
|
+
radius: size.width / 2,
|
|
82
|
+
height: thickness,
|
|
83
|
+
color
|
|
84
|
+
})
|
|
85
|
+
: PcbAssemblyMeshUtils.box(name, {
|
|
86
|
+
x,
|
|
87
|
+
y,
|
|
88
|
+
z,
|
|
89
|
+
width: size.width,
|
|
90
|
+
depth: size.height,
|
|
91
|
+
height: thickness,
|
|
92
|
+
color
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds one drilled pad mesh.
|
|
98
|
+
* @param {string} name Mesh name.
|
|
99
|
+
* @param {number} x Pad center X.
|
|
100
|
+
* @param {number} y Pad center Y.
|
|
101
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
102
|
+
* @param {boolean} isCircle Whether the pad is circular.
|
|
103
|
+
* @param {number[][]} hole Hole loop.
|
|
104
|
+
* @param {number} z Center Z.
|
|
105
|
+
* @param {number} thickness Extrusion thickness.
|
|
106
|
+
* @param {number[]} color Mesh color.
|
|
107
|
+
* @returns {object | null}
|
|
108
|
+
*/
|
|
109
|
+
static #drilledMesh(name, x, y, size, isCircle, hole, z, thickness, color) {
|
|
110
|
+
if (
|
|
111
|
+
PcbAssemblyPadMeshBuilder.#holeConsumesPad(
|
|
112
|
+
x,
|
|
113
|
+
y,
|
|
114
|
+
size,
|
|
115
|
+
isCircle,
|
|
116
|
+
hole
|
|
117
|
+
)
|
|
118
|
+
) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const outer = isCircle
|
|
123
|
+
? PcbAssemblyMeshUtils.circlePoints(x, y, size.width / 2, 32)
|
|
124
|
+
: PcbAssemblyPadMeshBuilder.#rectanglePoints(x, y, size)
|
|
125
|
+
|
|
126
|
+
return PcbAssemblyMeshUtils.prismWithHoles(
|
|
127
|
+
name,
|
|
128
|
+
outer,
|
|
129
|
+
[hole],
|
|
130
|
+
z,
|
|
131
|
+
thickness,
|
|
132
|
+
color
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns true when a drill leaves no meaningful copper surface.
|
|
138
|
+
* @param {number} x Pad center X.
|
|
139
|
+
* @param {number} y Pad center Y.
|
|
140
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
141
|
+
* @param {boolean} isCircle Whether the pad is circular.
|
|
142
|
+
* @param {number[][]} hole Hole loop.
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
static #holeConsumesPad(x, y, size, isCircle, hole) {
|
|
146
|
+
if (isCircle) {
|
|
147
|
+
const outerRadius = Math.min(size.width, size.height) / 2
|
|
148
|
+
const innerRadius = hole.reduce(
|
|
149
|
+
(minRadius, point) =>
|
|
150
|
+
Math.min(minRadius, Math.hypot(point[0] - x, point[1] - y)),
|
|
151
|
+
Infinity
|
|
152
|
+
)
|
|
153
|
+
return innerRadius >= outerRadius - GEOMETRY_EPSILON
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const outerArea = Math.max(size.width, 0) * Math.max(size.height, 0)
|
|
157
|
+
const holeArea = Math.abs(PcbAssemblyPadMeshBuilder.#polygonArea(hole))
|
|
158
|
+
return holeArea >= outerArea - GEOMETRY_EPSILON
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Builds a drill loop when a pad has a usable hole.
|
|
163
|
+
* @param {object} pad Pad primitive.
|
|
164
|
+
* @param {number} x Pad center X.
|
|
165
|
+
* @param {number} y Pad center Y.
|
|
166
|
+
* @returns {number[][] | null}
|
|
167
|
+
*/
|
|
168
|
+
static #holeLoop(pad, x, y) {
|
|
169
|
+
const diameter = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
170
|
+
pad?.holeDiameter,
|
|
171
|
+
pad?.drillDiameter,
|
|
172
|
+
pad?.holeSize,
|
|
173
|
+
pad?.drill,
|
|
174
|
+
pad?.holeGeometry?.diameter,
|
|
175
|
+
pad?.holeGeometry?.width
|
|
176
|
+
])
|
|
177
|
+
if (!diameter) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const slotLength = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
182
|
+
pad?.holeSlotLength,
|
|
183
|
+
pad?.slotLength,
|
|
184
|
+
pad?.holeGeometry?.slotLength,
|
|
185
|
+
pad?.holeGeometry?.length
|
|
186
|
+
])
|
|
187
|
+
if (slotLength > diameter + GEOMETRY_EPSILON) {
|
|
188
|
+
return PcbAssemblyPadMeshBuilder.#rotatedPoints(
|
|
189
|
+
PcbAssemblyMeshUtils.capsulePoints(
|
|
190
|
+
x - (slotLength - diameter) / 2,
|
|
191
|
+
y,
|
|
192
|
+
x + (slotLength - diameter) / 2,
|
|
193
|
+
y,
|
|
194
|
+
diameter / 2
|
|
195
|
+
),
|
|
196
|
+
x,
|
|
197
|
+
y,
|
|
198
|
+
Number(pad?.holeRotation ?? pad?.rotation ?? 0) -
|
|
199
|
+
Number(pad?.rotation || 0)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return PcbAssemblyMeshUtils.circlePoints(x, y, diameter / 2, 24)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolves side-specific pad dimensions.
|
|
208
|
+
* @param {object} pad Pad primitive.
|
|
209
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
210
|
+
* @returns {{ width: number, height: number } | null}
|
|
211
|
+
*/
|
|
212
|
+
static #padSize(pad, side) {
|
|
213
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
214
|
+
let width = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
215
|
+
pad?.['size' + prefix + 'X']
|
|
216
|
+
])
|
|
217
|
+
let height = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
218
|
+
pad?.['size' + prefix + 'Y']
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
if (side === 'top' && !width) {
|
|
222
|
+
width = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
223
|
+
pad?.width,
|
|
224
|
+
pad?.sizeX,
|
|
225
|
+
pad?.diameter,
|
|
226
|
+
pad?.sizeMidX,
|
|
227
|
+
pad?.sizeBottomX
|
|
228
|
+
])
|
|
229
|
+
}
|
|
230
|
+
if (side === 'top' && !height) {
|
|
231
|
+
height = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
232
|
+
pad?.height,
|
|
233
|
+
pad?.sizeY,
|
|
234
|
+
pad?.diameter,
|
|
235
|
+
pad?.sizeMidY,
|
|
236
|
+
pad?.sizeBottomY,
|
|
237
|
+
width
|
|
238
|
+
])
|
|
239
|
+
}
|
|
240
|
+
if (width && !height) {
|
|
241
|
+
height = width
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return width && height
|
|
245
|
+
? {
|
|
246
|
+
width: Math.max(width, 1),
|
|
247
|
+
height: Math.max(height, 1)
|
|
248
|
+
}
|
|
249
|
+
: null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Resolves side-specific pad center offset.
|
|
254
|
+
* @param {object} pad Pad primitive.
|
|
255
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
256
|
+
* @returns {{ x: number, y: number }}
|
|
257
|
+
*/
|
|
258
|
+
static #padOffset(pad, side) {
|
|
259
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
x: Number(pad?.['offset' + prefix + 'X'] || 0),
|
|
263
|
+
y: Number(pad?.['offset' + prefix + 'Y'] || 0)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolves the renderer pad shape code for one side.
|
|
269
|
+
* @param {object} pad Pad primitive.
|
|
270
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
271
|
+
* @returns {number}
|
|
272
|
+
*/
|
|
273
|
+
static #padShape(pad, side) {
|
|
274
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
275
|
+
const rounded = pad?.['roundedRectShape' + prefix]
|
|
276
|
+
if (Number.isInteger(Number(rounded))) {
|
|
277
|
+
return Number(rounded)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return Number(
|
|
281
|
+
pad?.['shape' + prefix] ||
|
|
282
|
+
pad?.shapeMid ||
|
|
283
|
+
(side === 'top' ? pad?.shapeBottom : pad?.shapeTop) ||
|
|
284
|
+
0
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Builds rectangle points for an axis-aligned pad.
|
|
290
|
+
* @param {number} x Center X.
|
|
291
|
+
* @param {number} y Center Y.
|
|
292
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
293
|
+
* @returns {number[][]}
|
|
294
|
+
*/
|
|
295
|
+
static #rectanglePoints(x, y, size) {
|
|
296
|
+
const halfWidth = size.width / 2
|
|
297
|
+
const halfHeight = size.height / 2
|
|
298
|
+
return [
|
|
299
|
+
[x - halfWidth, y - halfHeight],
|
|
300
|
+
[x + halfWidth, y - halfHeight],
|
|
301
|
+
[x + halfWidth, y + halfHeight],
|
|
302
|
+
[x - halfWidth, y + halfHeight]
|
|
303
|
+
]
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Rotates points around a center.
|
|
308
|
+
* @param {number[][]} points Source points.
|
|
309
|
+
* @param {number} centerX Center X.
|
|
310
|
+
* @param {number} centerY Center Y.
|
|
311
|
+
* @param {number} rotationDeg Rotation in degrees.
|
|
312
|
+
* @returns {number[][]}
|
|
313
|
+
*/
|
|
314
|
+
static #rotatedPoints(points, centerX, centerY, rotationDeg) {
|
|
315
|
+
if (Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
|
|
316
|
+
return points
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const angle = (Number(rotationDeg || 0) * Math.PI) / 180
|
|
320
|
+
const cos = Math.cos(angle)
|
|
321
|
+
const sin = Math.sin(angle)
|
|
322
|
+
return points.map((point) => {
|
|
323
|
+
const dx = point[0] - centerX
|
|
324
|
+
const dy = point[1] - centerY
|
|
325
|
+
return [
|
|
326
|
+
centerX + dx * cos - dy * sin,
|
|
327
|
+
centerY + dx * sin + dy * cos
|
|
328
|
+
]
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Calculates polygon area using the shoelace formula.
|
|
334
|
+
* @param {number[][]} points Polygon points.
|
|
335
|
+
* @returns {number}
|
|
336
|
+
*/
|
|
337
|
+
static #polygonArea(points) {
|
|
338
|
+
let area = 0
|
|
339
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
340
|
+
const point = points[index]
|
|
341
|
+
const next = points[(index + 1) % points.length]
|
|
342
|
+
area += point[0] * next[1] - next[0] * point[1]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return area / 2
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Rotates one mesh around a Z-axis origin.
|
|
350
|
+
* @param {object | null} mesh Mesh to rotate.
|
|
351
|
+
* @param {number} rotationDeg Rotation angle.
|
|
352
|
+
* @param {number} originX Origin X.
|
|
353
|
+
* @param {number} originY Origin Y.
|
|
354
|
+
* @returns {object | null}
|
|
355
|
+
*/
|
|
356
|
+
static #rotateMeshAroundZ(mesh, rotationDeg, originX, originY) {
|
|
357
|
+
if (!mesh || Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
|
|
358
|
+
return mesh
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const rotationRad = (Number(rotationDeg || 0) * Math.PI) / 180
|
|
362
|
+
const cos = Math.cos(rotationRad)
|
|
363
|
+
const sin = Math.sin(rotationRad)
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...mesh,
|
|
367
|
+
vertices: mesh.vertices.map((vertex) => {
|
|
368
|
+
const dx = Number(vertex[0] || 0) - originX
|
|
369
|
+
const dy = Number(vertex[1] || 0) - originY
|
|
370
|
+
return [
|
|
371
|
+
originX + dx * cos - dy * sin,
|
|
372
|
+
originY + dx * sin + dy * cos,
|
|
373
|
+
Number(vertex[2] || 0)
|
|
374
|
+
]
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Returns the first positive finite number.
|
|
381
|
+
* @param {unknown[]} values Candidate values.
|
|
382
|
+
* @returns {number}
|
|
383
|
+
*/
|
|
384
|
+
static #firstPositive(values) {
|
|
385
|
+
for (const value of values) {
|
|
386
|
+
const number = Number(value)
|
|
387
|
+
if (Number.isFinite(number) && number > 0) {
|
|
388
|
+
return number
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return 0
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -739,17 +739,39 @@ export class PcbAssemblyStepWriter {
|
|
|
739
739
|
}
|
|
740
740
|
|
|
741
741
|
/**
|
|
742
|
-
* Returns
|
|
742
|
+
* Returns display-space RGB components for one mesh color.
|
|
743
743
|
* @param {number[] | undefined} color Mesh RGB color.
|
|
744
744
|
* @returns {number[]}
|
|
745
745
|
*/
|
|
746
746
|
static #colorComponents(color) {
|
|
747
747
|
const normalized = Array.isArray(color) ? color : [0.55, 0.56, 0.58]
|
|
748
748
|
return [0, 1, 2].map((index) =>
|
|
749
|
-
|
|
749
|
+
PcbAssemblyStepWriter.#linearToSrgb(
|
|
750
|
+
PcbAssemblyStepWriter.#clampColorChannel(normalized[index])
|
|
751
|
+
)
|
|
750
752
|
)
|
|
751
753
|
}
|
|
752
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Clamps one color channel to the STEP RGB range.
|
|
757
|
+
* @param {unknown} value Candidate color channel.
|
|
758
|
+
* @returns {number}
|
|
759
|
+
*/
|
|
760
|
+
static #clampColorChannel(value) {
|
|
761
|
+
return Math.max(Math.min(Number(value || 0), 1), 0)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Converts one linear RGB channel to display-space sRGB.
|
|
766
|
+
* @param {number} value Linear RGB channel.
|
|
767
|
+
* @returns {number}
|
|
768
|
+
*/
|
|
769
|
+
static #linearToSrgb(value) {
|
|
770
|
+
return value <= 0.0031308
|
|
771
|
+
? value * 12.92
|
|
772
|
+
: 1.055 * Math.pow(value, 1 / 2.4) - 0.055
|
|
773
|
+
}
|
|
774
|
+
|
|
753
775
|
/**
|
|
754
776
|
* Tests whether a vertex has finite coordinates.
|
|
755
777
|
* @param {unknown} vertex Candidate vertex.
|