altium-toolkit 0.1.1 → 0.1.16
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 +24 -6
- package/docs/api.md +42 -4
- package/docs/model-format.md +95 -5
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
- package/docs/testing.md +7 -2
- package/package.json +6 -2
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumParser.mjs +22 -325
- package/src/core/altium/NormalizedModelSchema.mjs +28 -0
- package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
- package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
- package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
- package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
- package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
- package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
- package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
- package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
- package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
- package/src/core/altium/PcbLibModelParser.mjs +202 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
- package/src/core/altium/PcbModelParser.mjs +618 -66
- package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
- package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
- package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
- package/src/core/altium/PcbPadStackParser.mjs +903 -0
- package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
- package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
- package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
- package/src/core/altium/PcbRuleParser.mjs +587 -0
- package/src/core/altium/PcbStreamExtractor.mjs +127 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
- package/src/core/altium/PcbViaStackParser.mjs +548 -0
- package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
- package/src/core/altium/PrjPcbModelParser.mjs +797 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
- package/src/parser.mjs +13 -0
- package/src/renderers.mjs +5 -0
- package/src/styles/altium-renderers.css +11 -6
- package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
- package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
- package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
- package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
- package/src/ui/PcbSvgRenderer.mjs +101 -109
- package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
- package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders recovered embedded PCB fonts as self-contained SVG font faces.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbEmbeddedFontFaceRenderer {
|
|
9
|
+
/**
|
|
10
|
+
* Builds self-contained SVG @font-face rules for recovered embedded fonts.
|
|
11
|
+
* @param {{ name?: string, style?: string, format?: string, mimeType?: string, payloadBase64?: string, metrics?: { weightClass?: number } }[]} embeddedFonts
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
static buildMarkup(embeddedFonts) {
|
|
15
|
+
const rules = (embeddedFonts || [])
|
|
16
|
+
.filter((font) => font?.name && font?.payloadBase64)
|
|
17
|
+
.map((font) => PcbEmbeddedFontFaceRenderer.#buildRule(font))
|
|
18
|
+
|
|
19
|
+
return rules.length ? '<style>' + rules.join('') + '</style>' : ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds one CSS @font-face rule.
|
|
24
|
+
* @param {{ name?: string, style?: string, format?: string, mimeType?: string, payloadBase64?: string, metrics?: { weightClass?: number } }} font
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
static #buildRule(font) {
|
|
28
|
+
const family = PcbEmbeddedFontFaceRenderer.#escapeCssString(font.name)
|
|
29
|
+
const base64 = PcbEmbeddedFontFaceRenderer.#sanitizeBase64(
|
|
30
|
+
font.payloadBase64
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
"@font-face{font-family: '" +
|
|
35
|
+
family +
|
|
36
|
+
"'; font-style: " +
|
|
37
|
+
PcbEmbeddedFontFaceRenderer.#fontStyleForFont(font) +
|
|
38
|
+
'; font-weight: ' +
|
|
39
|
+
PcbEmbeddedFontFaceRenderer.#fontWeightForFont(font) +
|
|
40
|
+
"; src: url('data:" +
|
|
41
|
+
PcbEmbeddedFontFaceRenderer.#fontMimeType(font) +
|
|
42
|
+
';base64,' +
|
|
43
|
+
base64 +
|
|
44
|
+
"') format('" +
|
|
45
|
+
PcbEmbeddedFontFaceRenderer.#fontFormat(font) +
|
|
46
|
+
"');}"
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a CSS font-style value from embedded font metadata.
|
|
52
|
+
* @param {{ style?: string }} font
|
|
53
|
+
* @returns {'normal' | 'italic'}
|
|
54
|
+
*/
|
|
55
|
+
static #fontStyleForFont(font) {
|
|
56
|
+
return /italic|oblique/iu.test(String(font.style || ''))
|
|
57
|
+
? 'italic'
|
|
58
|
+
: 'normal'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a CSS font-weight value from embedded font metadata.
|
|
63
|
+
* @param {{ style?: string, metrics?: { weightClass?: number } }} font
|
|
64
|
+
* @returns {number}
|
|
65
|
+
*/
|
|
66
|
+
static #fontWeightForFont(font) {
|
|
67
|
+
if (Number(font.metrics?.weightClass) >= 100) {
|
|
68
|
+
return Number(font.metrics.weightClass)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return /bold/iu.test(String(font.style || '')) ? 700 : 400
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves a CSS font source MIME type.
|
|
76
|
+
* @param {{ mimeType?: string, format?: string }} font
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
static #fontMimeType(font) {
|
|
80
|
+
if (font.mimeType) {
|
|
81
|
+
return PcbEmbeddedFontFaceRenderer.#escapeCssUrlToken(font.mimeType)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return font.format === 'opentype' ? 'font/otf' : 'font/ttf'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves a CSS font source format label.
|
|
89
|
+
* @param {{ format?: string }} font
|
|
90
|
+
* @returns {'opentype' | 'truetype'}
|
|
91
|
+
*/
|
|
92
|
+
static #fontFormat(font) {
|
|
93
|
+
return font.format === 'opentype' ? 'opentype' : 'truetype'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Escapes a string for use inside a single-quoted CSS string.
|
|
98
|
+
* @param {string | undefined} value
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
static #escapeCssString(value) {
|
|
102
|
+
return String(value || '')
|
|
103
|
+
.replace(/\\/gu, '\\\\')
|
|
104
|
+
.replace(/'/gu, "\\'")
|
|
105
|
+
.replace(/\r?\n/gu, ' ')
|
|
106
|
+
.replace(/</gu, '\\3C ')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Keeps a base64 font payload constrained to data-URI-safe characters.
|
|
111
|
+
* @param {string | undefined} value
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
static #sanitizeBase64(value) {
|
|
115
|
+
return String(value || '').replace(/[^A-Za-z0-9+/=]/gu, '')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Escapes a short CSS URL token.
|
|
120
|
+
* @param {string | undefined} value
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
static #escapeCssUrlToken(value) {
|
|
124
|
+
return String(value || '').replace(/[^A-Za-z0-9./+-]/gu, '')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -13,12 +13,23 @@ export class PcbFootprintPrimitiveSelector {
|
|
|
13
13
|
* @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
|
|
14
14
|
* @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
|
|
15
15
|
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
|
|
16
|
+
* @param {{ points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] | 'top' | 'bottom'} [regionsOrSide]
|
|
16
17
|
* @param {'top' | 'bottom'} [side]
|
|
17
|
-
* @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
|
|
18
|
+
* @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }}
|
|
18
19
|
*/
|
|
19
|
-
static select(
|
|
20
|
+
static select(
|
|
21
|
+
primitiveLayers,
|
|
22
|
+
fills,
|
|
23
|
+
tracks,
|
|
24
|
+
arcs,
|
|
25
|
+
regionsOrSide = [],
|
|
26
|
+
side = 'top'
|
|
27
|
+
) {
|
|
28
|
+
const requestedSide =
|
|
29
|
+
typeof regionsOrSide === 'string' ? regionsOrSide : side
|
|
30
|
+
const regions = Array.isArray(regionsOrSide) ? regionsOrSide : []
|
|
20
31
|
const prioritizedLayerMatchers =
|
|
21
|
-
PcbFootprintPrimitiveSelector.#resolveLayerMatchers(
|
|
32
|
+
PcbFootprintPrimitiveSelector.#resolveLayerMatchers(requestedSide)
|
|
22
33
|
|
|
23
34
|
for (const matchesLayerName of prioritizedLayerMatchers) {
|
|
24
35
|
const layerIds = new Set(
|
|
@@ -41,12 +52,21 @@ export class PcbFootprintPrimitiveSelector {
|
|
|
41
52
|
const layerArcs = (arcs || []).filter((arc) =>
|
|
42
53
|
layerIds.has(arc.layerId)
|
|
43
54
|
)
|
|
55
|
+
const layerRegions = regions.filter((region) =>
|
|
56
|
+
layerIds.has(region.layerId)
|
|
57
|
+
)
|
|
44
58
|
|
|
45
|
-
if (
|
|
59
|
+
if (
|
|
60
|
+
layerFills.length ||
|
|
61
|
+
layerTracks.length ||
|
|
62
|
+
layerArcs.length ||
|
|
63
|
+
layerRegions.length
|
|
64
|
+
) {
|
|
46
65
|
return {
|
|
47
66
|
fills: layerFills,
|
|
48
67
|
tracks: layerTracks,
|
|
49
|
-
arcs: layerArcs
|
|
68
|
+
arcs: layerArcs,
|
|
69
|
+
regions: layerRegions
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
72
|
}
|
|
@@ -54,7 +74,8 @@ export class PcbFootprintPrimitiveSelector {
|
|
|
54
74
|
return {
|
|
55
75
|
fills: [],
|
|
56
76
|
tracks: [],
|
|
57
|
-
arcs: []
|
|
77
|
+
arcs: [],
|
|
78
|
+
regions: []
|
|
58
79
|
}
|
|
59
80
|
}
|
|
60
81
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders filled PCB region contours into SVG path markup.
|
|
9
|
+
*/
|
|
10
|
+
export class PcbRegionPrimitiveRenderer {
|
|
11
|
+
/**
|
|
12
|
+
* Builds SVG path markup for filled PCB regions.
|
|
13
|
+
* @param {{ points?: object[], holes?: object[][] }[]} regions
|
|
14
|
+
* @param {string} className
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
static buildMarkup(regions, className) {
|
|
18
|
+
return (regions || [])
|
|
19
|
+
.map((region) =>
|
|
20
|
+
PcbRegionPrimitiveRenderer.#renderRegion(region, className)
|
|
21
|
+
)
|
|
22
|
+
.join('')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns true when one region intersects a bounds object.
|
|
27
|
+
* @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} region
|
|
28
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
static intersectsBounds(region, bounds) {
|
|
32
|
+
const regionBounds = PcbRegionPrimitiveRenderer.bounds(region)
|
|
33
|
+
if (!regionBounds) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return !(
|
|
38
|
+
regionBounds.maxX < bounds.minX ||
|
|
39
|
+
regionBounds.minX > bounds.maxX ||
|
|
40
|
+
regionBounds.maxY < bounds.minY ||
|
|
41
|
+
regionBounds.minY > bounds.maxY
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Computes a bounding box for one filled region.
|
|
47
|
+
* @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} region
|
|
48
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
49
|
+
*/
|
|
50
|
+
static bounds(region) {
|
|
51
|
+
const points = [
|
|
52
|
+
...(region?.points || []),
|
|
53
|
+
...(region?.holes || []).flat()
|
|
54
|
+
].filter(
|
|
55
|
+
(point) => Number.isFinite(point?.x) && Number.isFinite(point?.y)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (!points.length) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
minX: Math.min(...points.map((point) => Number(point.x))),
|
|
64
|
+
minY: Math.min(...points.map((point) => Number(point.y))),
|
|
65
|
+
maxX: Math.max(...points.map((point) => Number(point.x))),
|
|
66
|
+
maxY: Math.max(...points.map((point) => Number(point.y)))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pushes one region's extent into viewBox coordinate arrays.
|
|
72
|
+
* @param {number[]} xs
|
|
73
|
+
* @param {number[]} ys
|
|
74
|
+
* @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} region
|
|
75
|
+
*/
|
|
76
|
+
static pushExtents(xs, ys, region) {
|
|
77
|
+
const bounds = PcbRegionPrimitiveRenderer.bounds(region)
|
|
78
|
+
if (!bounds) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
xs.push(bounds.minX, bounds.maxX)
|
|
83
|
+
ys.push(bounds.minY, bounds.maxY)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Renders one filled region path.
|
|
88
|
+
* @param {{ points?: object[], holes?: object[][] }} region
|
|
89
|
+
* @param {string} className
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
static #renderRegion(region, className) {
|
|
93
|
+
const path = PcbRegionPrimitiveRenderer.#buildRegionPath(region)
|
|
94
|
+
if (!path) {
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
'<path class="' +
|
|
100
|
+
SchematicSvgUtils.escapeHtml(className) +
|
|
101
|
+
'" d="' +
|
|
102
|
+
SchematicSvgUtils.escapeHtml(path) +
|
|
103
|
+
'" fill-rule="evenodd" />'
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Builds one SVG path containing the outline and holes.
|
|
109
|
+
* @param {{ points?: object[], holes?: object[][] }} region
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
static #buildRegionPath(region) {
|
|
113
|
+
const paths = [
|
|
114
|
+
PcbRegionPrimitiveRenderer.#buildPointPath(region?.points || [])
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
for (const hole of region?.holes || []) {
|
|
118
|
+
paths.push(PcbRegionPrimitiveRenderer.#buildPointPath(hole))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return paths.filter(Boolean).join(' ')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Builds one closed contour path from region points.
|
|
126
|
+
* @param {object[]} points
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
static #buildPointPath(points) {
|
|
130
|
+
const contour =
|
|
131
|
+
PcbRegionPrimitiveRenderer.#withoutClosingDuplicate(points)
|
|
132
|
+
if (contour.length < 3) {
|
|
133
|
+
return ''
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const [first] = contour
|
|
137
|
+
const commands = [
|
|
138
|
+
'M ' +
|
|
139
|
+
SchematicSvgUtils.formatNumber(first.x) +
|
|
140
|
+
' ' +
|
|
141
|
+
SchematicSvgUtils.formatNumber(first.y)
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
for (let index = 0; index < contour.length - 1; index += 1) {
|
|
145
|
+
const current = contour[index]
|
|
146
|
+
const next = contour[index + 1]
|
|
147
|
+
commands.push(
|
|
148
|
+
PcbRegionPrimitiveRenderer.#segmentCommand(current, next)
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const last = contour[contour.length - 1]
|
|
153
|
+
if (PcbRegionPrimitiveRenderer.#isArcPoint(last)) {
|
|
154
|
+
commands.push(
|
|
155
|
+
PcbRegionPrimitiveRenderer.#segmentCommand(last, first)
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
commands.push('Z')
|
|
160
|
+
return commands.join(' ')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Builds one line or arc segment command.
|
|
165
|
+
* @param {object} current
|
|
166
|
+
* @param {object} next
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
static #segmentCommand(current, next) {
|
|
170
|
+
if (PcbRegionPrimitiveRenderer.#isArcPoint(current)) {
|
|
171
|
+
const delta =
|
|
172
|
+
PcbRegionPrimitiveRenderer.#normalizeAngle(
|
|
173
|
+
Number(current.endAngle || 0) -
|
|
174
|
+
Number(current.startAngle || 0)
|
|
175
|
+
) || 360
|
|
176
|
+
return (
|
|
177
|
+
'A ' +
|
|
178
|
+
SchematicSvgUtils.formatNumber(current.radius) +
|
|
179
|
+
' ' +
|
|
180
|
+
SchematicSvgUtils.formatNumber(current.radius) +
|
|
181
|
+
' 0 ' +
|
|
182
|
+
(delta > 180 ? '1' : '0') +
|
|
183
|
+
' ' +
|
|
184
|
+
(Number(current.endAngle || 0) >=
|
|
185
|
+
Number(current.startAngle || 0)
|
|
186
|
+
? '1'
|
|
187
|
+
: '0') +
|
|
188
|
+
' ' +
|
|
189
|
+
SchematicSvgUtils.formatNumber(next.x) +
|
|
190
|
+
' ' +
|
|
191
|
+
SchematicSvgUtils.formatNumber(next.y)
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
'L ' +
|
|
197
|
+
SchematicSvgUtils.formatNumber(next.x) +
|
|
198
|
+
' ' +
|
|
199
|
+
SchematicSvgUtils.formatNumber(next.y)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Removes an explicit duplicate closing vertex when present.
|
|
205
|
+
* @param {object[]} points
|
|
206
|
+
* @returns {object[]}
|
|
207
|
+
*/
|
|
208
|
+
static #withoutClosingDuplicate(points) {
|
|
209
|
+
if ((points || []).length < 2) {
|
|
210
|
+
return points || []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const first = points[0]
|
|
214
|
+
const last = points[points.length - 1]
|
|
215
|
+
if (
|
|
216
|
+
Math.abs(Number(first.x) - Number(last.x)) < 1e-6 &&
|
|
217
|
+
Math.abs(Number(first.y) - Number(last.y)) < 1e-6
|
|
218
|
+
) {
|
|
219
|
+
return points.slice(0, -1)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return points
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Checks whether one region point represents an arc segment.
|
|
227
|
+
* @param {object | undefined} point
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
static #isArcPoint(point) {
|
|
231
|
+
return Boolean(point?.isArc && Number(point.radius || 0) > 0)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalizes one angle delta into [0, 360).
|
|
236
|
+
* @param {number} angle
|
|
237
|
+
* @returns {number}
|
|
238
|
+
*/
|
|
239
|
+
static #normalizeAngle(angle) {
|
|
240
|
+
const normalized = Number(angle || 0) % 360
|
|
241
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
242
|
+
}
|
|
243
|
+
}
|