altium-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,212 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { createReadStream } from 'node:fs'
6
+ import { stat } from 'node:fs/promises'
7
+ import { createServer } from 'node:http'
8
+ import { extname, join, normalize, relative, resolve, sep } from 'node:path'
9
+ import { fileURLToPath, pathToFileURL } from 'node:url'
10
+
11
+ const DEFAULT_EXAMPLE_PATH = '/examples/arduino-uno/'
12
+ const DEFAULT_HOST = '127.0.0.1'
13
+ const DEFAULT_PORT = 4173
14
+ const CONTENT_TYPES = new Map([
15
+ ['.css', 'text/css; charset=utf-8'],
16
+ ['.html', 'text/html; charset=utf-8'],
17
+ ['.js', 'text/javascript; charset=utf-8'],
18
+ ['.json', 'application/json; charset=utf-8'],
19
+ ['.mjs', 'text/javascript; charset=utf-8'],
20
+ ['.svg', 'image/svg+xml; charset=utf-8'],
21
+ ['.txt', 'text/plain; charset=utf-8']
22
+ ])
23
+
24
+ /**
25
+ * Serves the browser examples with Node's built-in HTTP server.
26
+ */
27
+ export class ExampleServer {
28
+ /**
29
+ * Reads default server options from the environment.
30
+ * @param {NodeJS.ProcessEnv} env
31
+ * @returns {{ host: string, port: number, rootDirectory: string }}
32
+ */
33
+ static defaultOptions(env = process.env) {
34
+ return {
35
+ host: env.HOST || DEFAULT_HOST,
36
+ port: ExampleServer.#parsePort(env.PORT),
37
+ rootDirectory: resolve(
38
+ fileURLToPath(new URL('..', import.meta.url))
39
+ )
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Creates an HTTP request handler rooted at the repository directory.
45
+ * @param {string} rootDirectory
46
+ * @returns {import('node:http').RequestListener}
47
+ */
48
+ static createHandler(rootDirectory) {
49
+ const root = resolve(rootDirectory)
50
+
51
+ return async (request, response) => {
52
+ const filePath = await ExampleServer.resolveRequestPath(
53
+ root,
54
+ request.url || '/'
55
+ )
56
+
57
+ if (!filePath) {
58
+ ExampleServer.#writeResponse(response, 403, 'Forbidden')
59
+ return
60
+ }
61
+
62
+ const fileStat = await stat(filePath).catch(() => null)
63
+ if (!fileStat?.isFile()) {
64
+ ExampleServer.#writeResponse(response, 404, 'Not found')
65
+ return
66
+ }
67
+
68
+ response.writeHead(200, {
69
+ 'content-type': ExampleServer.getContentType(filePath)
70
+ })
71
+ createReadStream(filePath).pipe(response)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolves a request URL to a file inside the served root.
77
+ * @param {string} rootDirectory
78
+ * @param {string} requestUrl
79
+ * @returns {Promise<string | null>}
80
+ */
81
+ static async resolveRequestPath(rootDirectory, requestUrl) {
82
+ const parsedUrl = new URL(requestUrl, 'http://localhost')
83
+ const pathname =
84
+ parsedUrl.pathname === '/'
85
+ ? DEFAULT_EXAMPLE_PATH
86
+ : parsedUrl.pathname
87
+ const requestedPath = ExampleServer.#resolveSafePath(
88
+ rootDirectory,
89
+ pathname
90
+ )
91
+
92
+ if (!requestedPath) return null
93
+
94
+ const fileStat = await stat(requestedPath).catch(() => null)
95
+ if (fileStat?.isDirectory()) {
96
+ return join(requestedPath, 'index.html')
97
+ }
98
+
99
+ return requestedPath
100
+ }
101
+
102
+ /**
103
+ * Returns a content type for a file path.
104
+ * @param {string} filePath
105
+ * @returns {string}
106
+ */
107
+ static getContentType(filePath) {
108
+ return (
109
+ CONTENT_TYPES.get(extname(filePath).toLowerCase()) ||
110
+ 'application/octet-stream'
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Starts the local example server.
116
+ * @param {{ host?: string, port?: number, rootDirectory?: string, logger?: Pick<Console, 'log'> }} options
117
+ * @returns {Promise<{ server: import('node:http').Server, url: string, host: string, port: number, rootDirectory: string }>}
118
+ */
119
+ static async start(options = {}) {
120
+ const defaults = ExampleServer.defaultOptions()
121
+ const host = options.host || defaults.host
122
+ const port = options.port ?? defaults.port
123
+ const rootDirectory = options.rootDirectory || defaults.rootDirectory
124
+ const logger = options.logger || console
125
+ const server = createServer(ExampleServer.createHandler(rootDirectory))
126
+
127
+ await new Promise((resolveStart, rejectStart) => {
128
+ server.once('error', rejectStart)
129
+ server.listen(port, host, () => {
130
+ server.off('error', rejectStart)
131
+ resolveStart()
132
+ })
133
+ })
134
+
135
+ const address = server.address()
136
+ const resolvedPort =
137
+ typeof address === 'object' && address ? address.port : port
138
+ const url = 'http://' + host + ':' + resolvedPort + DEFAULT_EXAMPLE_PATH
139
+
140
+ logger.log('Serving Arduino Uno example at ' + url)
141
+
142
+ return {
143
+ server,
144
+ url,
145
+ host,
146
+ port: resolvedPort,
147
+ rootDirectory
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Converts an environment port value into a listen port.
153
+ * @param {string | undefined} value
154
+ * @returns {number}
155
+ */
156
+ static #parsePort(value) {
157
+ const port = Number(value || DEFAULT_PORT)
158
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
159
+ throw new Error('PORT must be an integer from 0 through 65535.')
160
+ }
161
+
162
+ return port
163
+ }
164
+
165
+ /**
166
+ * Resolves a request path without allowing directory traversal.
167
+ * @param {string} rootDirectory
168
+ * @param {string} pathname
169
+ * @returns {string | null}
170
+ */
171
+ static #resolveSafePath(rootDirectory, pathname) {
172
+ const root = resolve(rootDirectory)
173
+ const decodedPath = decodeURIComponent(pathname)
174
+ const normalizedPath = normalize('/' + decodedPath).replace(/^\/+/, '')
175
+ const filePath = resolve(root, normalizedPath)
176
+ const relativePath = relative(root, filePath)
177
+
178
+ if (
179
+ relativePath === '..' ||
180
+ relativePath.startsWith('..' + sep) ||
181
+ relativePath.startsWith('/')
182
+ ) {
183
+ return null
184
+ }
185
+
186
+ return filePath
187
+ }
188
+
189
+ /**
190
+ * Writes a plain-text status response.
191
+ * @param {import('node:http').ServerResponse} response
192
+ * @param {number} status
193
+ * @param {string} body
194
+ * @returns {void}
195
+ */
196
+ static #writeResponse(response, status, body) {
197
+ response.writeHead(status, {
198
+ 'content-type': 'text/plain; charset=utf-8'
199
+ })
200
+ response.end(body)
201
+ }
202
+ }
203
+
204
+ if (
205
+ process.argv[1] &&
206
+ import.meta.url === pathToFileURL(process.argv[1]).href
207
+ ) {
208
+ ExampleServer.start().catch((error) => {
209
+ console.error(error instanceof Error ? error.message : String(error))
210
+ process.exitCode = 1
211
+ })
212
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "altium-toolkit",
3
+ "version": "0.1.0",
4
+ "description": "Altium document parsing and non-interactive rendering utilities",
5
+ "license": "GPL-3.0-or-later",
6
+ "type": "module",
7
+ "main": "./src/index.mjs",
8
+ "exports": {
9
+ ".": "./src/index.mjs",
10
+ "./parser": "./src/parser.mjs",
11
+ "./renderers": "./src/renderers.mjs",
12
+ "./scene3d": "./src/scene3d.mjs",
13
+ "./workers/altium-parser.worker.mjs": "./src/workers/altium-parser.worker.mjs",
14
+ "./styles/altium-renderers.css": "./src/styles/altium-renderers.css"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "docs",
19
+ "examples",
20
+ "spec",
21
+ "LICENSE",
22
+ "LICENSES",
23
+ "COMMERCIAL-LICENSE.md",
24
+ "CONTRIBUTING.md",
25
+ "NOTICE.md",
26
+ "README.md",
27
+ "AGENTS.md"
28
+ ],
29
+ "scripts": {
30
+ "start": "node examples/server.mjs",
31
+ "test": "node --test tests/*.test.mjs tests/**/*.test.mjs",
32
+ "format": "prettier --write .",
33
+ "check:format": "prettier --check ."
34
+ },
35
+ "dependencies": {
36
+ "fflate": "^0.8.2",
37
+ "three": "^0.183.2"
38
+ },
39
+ "devDependencies": {
40
+ "prettier": "^3.4.2"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/SunboX/altium-toolkit.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/SunboX/altium-toolkit/issues"
51
+ },
52
+ "homepage": "https://github.com/SunboX/altium-toolkit#readme"
53
+ }
@@ -0,0 +1,32 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 André Fiedler
3
+
4
+ SPDX-License-Identifier: CC-BY-SA-4.0
5
+ -->
6
+
7
+ # Library Scope
8
+
9
+ Altium Toolkit provides reusable native Altium parsing and non-interactive
10
+ rendering primitives.
11
+
12
+ ## In Scope
13
+
14
+ - `.SchDoc` and `.PcbDoc` parsing from `ArrayBuffer`
15
+ - OLE and binary stream helpers needed by parser recovery
16
+ - Schematic SVG rendering
17
+ - PCB SVG rendering
18
+ - BOM HTML rendering
19
+ - PCB 3D scene-description data
20
+ - Static 3D summary HTML
21
+ - Parser worker entrypoint for host applications
22
+ - Optional renderer CSS
23
+
24
+ ## Out Of Scope
25
+
26
+ - Application state management
27
+ - File picker, drag/drop, or session orchestration
28
+ - Schematic/PCB pan and zoom event controllers
29
+ - Three.js runtime, OrbitControls, canvas mounting, and picking
30
+ - STEP mesh loading and browser script injection
31
+ - Model ZIP export UI and download orchestration
32
+ - Server, deployment, and app metadata endpoints
@@ -0,0 +1,127 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Reads little-endian primitive values from an ArrayBuffer with bounds checks.
7
+ */
8
+ export class BinaryReader {
9
+ #arrayBuffer
10
+
11
+ #byteLength
12
+
13
+ #dataView
14
+
15
+ /**
16
+ * @param {ArrayBuffer} arrayBuffer
17
+ */
18
+ constructor(arrayBuffer) {
19
+ this.#arrayBuffer = arrayBuffer
20
+ this.#dataView = new DataView(arrayBuffer)
21
+ this.#byteLength = arrayBuffer.byteLength
22
+ }
23
+
24
+ /**
25
+ * Returns the underlying byte length.
26
+ * @returns {number}
27
+ */
28
+ get byteLength() {
29
+ return this.#byteLength
30
+ }
31
+
32
+ /**
33
+ * Reads one unsigned byte.
34
+ * @param {number} offset
35
+ * @returns {number}
36
+ */
37
+ readUint8(offset) {
38
+ this.#assertReadable(offset, 1)
39
+ return this.#dataView.getUint8(offset)
40
+ }
41
+
42
+ /**
43
+ * Reads one unsigned 16-bit integer.
44
+ * @param {number} offset
45
+ * @returns {number}
46
+ */
47
+ readUint16(offset) {
48
+ this.#assertReadable(offset, 2)
49
+ return this.#dataView.getUint16(offset, true)
50
+ }
51
+
52
+ /**
53
+ * Reads one unsigned 32-bit integer.
54
+ * @param {number} offset
55
+ * @returns {number}
56
+ */
57
+ readUint32(offset) {
58
+ this.#assertReadable(offset, 4)
59
+ return this.#dataView.getUint32(offset, true)
60
+ }
61
+
62
+ /**
63
+ * Reads one signed 32-bit integer.
64
+ * @param {number} offset
65
+ * @returns {number}
66
+ */
67
+ readInt32(offset) {
68
+ this.#assertReadable(offset, 4)
69
+ return this.#dataView.getInt32(offset, true)
70
+ }
71
+
72
+ /**
73
+ * Reads one unsigned 64-bit integer as a JavaScript number.
74
+ * @param {number} offset
75
+ * @returns {number}
76
+ */
77
+ readUint64(offset) {
78
+ this.#assertReadable(offset, 8)
79
+
80
+ const low = this.#dataView.getUint32(offset, true)
81
+ const high = this.#dataView.getUint32(offset + 4, true)
82
+ const value = high * 0x100000000 + low
83
+
84
+ if (!Number.isSafeInteger(value)) {
85
+ throw new RangeError(
86
+ 'BinaryReader cannot represent an unsafe 64-bit integer.'
87
+ )
88
+ }
89
+
90
+ return value
91
+ }
92
+
93
+ /**
94
+ * Reads one byte slice.
95
+ * @param {number} offset
96
+ * @param {number} length
97
+ * @returns {Uint8Array}
98
+ */
99
+ readBytes(offset, length) {
100
+ this.#assertReadable(offset, length)
101
+ return new Uint8Array(this.#arrayBuffer.slice(offset, offset + length))
102
+ }
103
+
104
+ /**
105
+ * Ensures one read stays inside the buffer.
106
+ * @param {number} offset
107
+ * @param {number} size
108
+ */
109
+ #assertReadable(offset, size) {
110
+ const normalizedOffset = Number(offset)
111
+ const normalizedSize = Number(size)
112
+
113
+ if (
114
+ !Number.isInteger(normalizedOffset) ||
115
+ normalizedOffset < 0 ||
116
+ normalizedOffset + normalizedSize > this.#byteLength
117
+ ) {
118
+ throw new RangeError(
119
+ 'BinaryReader read is out of bounds at offset ' +
120
+ normalizedOffset +
121
+ ' for ' +
122
+ normalizedSize +
123
+ ' byte(s).'
124
+ )
125
+ }
126
+ }
127
+ }