circuitjson-toolkit 1.0.16 → 1.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 (155) hide show
  1. package/AGENTS.md +15 -0
  2. package/NOTICE.md +31 -0
  3. package/README.md +267 -107
  4. package/docs/api.md +501 -96
  5. package/docs/capabilities.md +70 -0
  6. package/docs/migration/behaviors.md +45 -0
  7. package/docs/migration/parser.md +60 -0
  8. package/docs/migration/renderers.md +515 -0
  9. package/docs/migration/root.md +740 -0
  10. package/docs/migration.md +120 -0
  11. package/docs/model-format.md +175 -57
  12. package/docs/provenance.md +206 -0
  13. package/docs/release-notes-v1.1.0.md +154 -0
  14. package/docs/testing.md +117 -7
  15. package/package.json +31 -5
  16. package/spec/api-baseline-v1.0.17.json +1 -0
  17. package/spec/baseline-provenance-v1.0.17.json +7 -0
  18. package/spec/circuitjson-schema-snapshot.json +321 -0
  19. package/spec/circuitjson-schema-source.json +28 -0
  20. package/spec/feature-preservation.json +1 -0
  21. package/spec/library-scope.md +27 -20
  22. package/src/capabilities.mjs +1 -0
  23. package/src/core/ArchiveEntryPath.mjs +93 -0
  24. package/src/core/ArchiveLimits.mjs +31 -0
  25. package/src/core/ArchiveLimitsValidator.mjs +107 -0
  26. package/src/core/AsyncInputOwnership.mjs +56 -0
  27. package/src/core/AttachedValueLimits.mjs +67 -0
  28. package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
  29. package/src/core/CircuitJsonDocument.mjs +19 -61
  30. package/src/core/CircuitJsonElementTypes.mjs +10 -0
  31. package/src/core/CircuitJsonElementValidator.mjs +98 -847
  32. package/src/core/CircuitJsonIndexer.mjs +274 -194
  33. package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
  34. package/src/core/CircuitJsonParser.mjs +75 -13
  35. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
  36. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
  37. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
  38. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
  39. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
  40. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
  41. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
  42. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
  43. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
  44. package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
  45. package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
  46. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
  47. package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
  48. package/src/core/CircuitJsonUnitParsers.mjs +101 -0
  49. package/src/core/CircuitJsonUnits.mjs +13 -87
  50. package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
  51. package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
  52. package/src/core/CircuitJsonValidationUnits.mjs +6 -0
  53. package/src/core/ManufacturingService.mjs +323 -0
  54. package/src/core/Parser.mjs +343 -0
  55. package/src/core/ParserOptions.mjs +333 -0
  56. package/src/core/PcbBoundsSelectionModel.mjs +55 -19
  57. package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
  58. package/src/core/PcbInteractionIndex.mjs +368 -0
  59. package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
  60. package/src/core/ProjectAsyncInputOwner.mjs +70 -0
  61. package/src/core/ProjectLoader.mjs +975 -0
  62. package/src/core/SimulationService.mjs +790 -0
  63. package/src/core/ToolkitCapabilities.mjs +130 -0
  64. package/src/core/ZipArchiveInspector.mjs +649 -0
  65. package/src/core/context/BinaryDataSnapshot.mjs +217 -0
  66. package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
  67. package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
  68. package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
  69. package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
  70. package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
  71. package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
  72. package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
  73. package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
  74. package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
  75. package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
  76. package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
  77. package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
  78. package/src/core/context/PcbSpatialIndex.mjs +701 -0
  79. package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
  80. package/src/core/context/StructuredDataSnapshot.mjs +683 -0
  81. package/src/core/contracts/DocumentResult.mjs +198 -0
  82. package/src/core/contracts/ProjectResult.mjs +96 -0
  83. package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
  84. package/src/core/contracts/ToolkitAsset.mjs +493 -0
  85. package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
  86. package/src/core/contracts/ToolkitError.mjs +176 -0
  87. package/src/core/contracts/ToolkitProgress.mjs +89 -0
  88. package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
  89. package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
  90. package/src/core/query/CircuitTraversal.mjs +343 -0
  91. package/src/core/query/ComponentGrouping.mjs +275 -0
  92. package/src/core/query/QueryNetlistBuilder.mjs +306 -0
  93. package/src/core/query/QueryService.mjs +435 -0
  94. package/src/core/query/RegexPattern.mjs +75 -0
  95. package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
  96. package/src/core/rendering/CanonicalBomRows.mjs +92 -0
  97. package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
  98. package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
  99. package/src/core/rendering/PcbRenderPlan.mjs +429 -0
  100. package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
  101. package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
  102. package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
  103. package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
  104. package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
  105. package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
  106. package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
  107. package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
  108. package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
  109. package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
  110. package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
  111. package/src/core/scene3d/Scene3dOptions.mjs +363 -0
  112. package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
  113. package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
  114. package/src/core/worker/ParserWorkerClient.mjs +997 -0
  115. package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
  116. package/src/core/worker/WorkerRequestData.mjs +623 -0
  117. package/src/core/worker/WorkerResponseData.mjs +905 -0
  118. package/src/extensions.mjs +37 -0
  119. package/src/index.mjs +14 -9
  120. package/src/interaction.mjs +2 -0
  121. package/src/manufacturing.mjs +1 -0
  122. package/src/parser.mjs +12 -2
  123. package/src/project.mjs +5 -0
  124. package/src/query.mjs +1 -0
  125. package/src/renderers.mjs +3 -29
  126. package/src/scene3d.mjs +3 -0
  127. package/src/simulation.mjs +1 -0
  128. package/src/styles/renderers.css +24 -0
  129. package/src/testing/ToolkitContractFixtures.mjs +124 -0
  130. package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
  131. package/src/testing/runToolkitContract.mjs +705 -0
  132. package/src/testing.mjs +3 -0
  133. package/src/ui/BomTableRenderer.mjs +304 -0
  134. package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
  135. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
  136. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
  137. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
  138. package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
  139. package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
  140. package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
  141. package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
  142. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
  143. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
  144. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
  145. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
  146. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
  147. package/src/ui/PcbSvgRenderer.mjs +41 -0
  148. package/src/ui/SafeSvgPaint.mjs +26 -0
  149. package/src/ui/SafeXmlText.mjs +60 -0
  150. package/src/ui/SchematicGeometryBounds.mjs +540 -0
  151. package/src/ui/SchematicSvgRenderer.mjs +110 -0
  152. package/src/ui/SchematicTableGeometry.mjs +319 -0
  153. package/src/ui/SchematicTextAnchor.mjs +55 -0
  154. package/src/ui/SchematicTextBounds.mjs +98 -0
  155. package/src/workers/parser.worker.mjs +59 -0
@@ -1,4 +1,5 @@
1
1
  import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonLegacyNormalizer } from './context/CircuitJsonLegacyNormalizer.mjs'
2
3
 
3
4
  /**
4
5
  * Builds PCB primitive element indexes with a tolerant local fallback.
@@ -13,10 +14,25 @@ export class CircuitJsonPcbPrimitiveIndex {
13
14
  try {
14
15
  return CircuitJsonIndexer.index(elements)
15
16
  } catch (error) {
16
- if (!CircuitJsonPcbPrimitiveIndex.#isUnknownTypeError(error)) {
17
+ const normalized = CircuitJsonLegacyNormalizer.normalize(elements)
18
+ if (normalized === elements) {
19
+ if (CircuitJsonPcbPrimitiveIndex.#isUnknownTypeError(error)) {
20
+ return CircuitJsonPcbPrimitiveIndex.#localIndex(elements)
21
+ }
17
22
  throw error
18
23
  }
19
- return CircuitJsonPcbPrimitiveIndex.#localIndex(elements)
24
+ try {
25
+ return CircuitJsonIndexer.index(normalized)
26
+ } catch (normalizedError) {
27
+ if (
28
+ CircuitJsonPcbPrimitiveIndex.#isUnknownTypeError(
29
+ normalizedError
30
+ )
31
+ ) {
32
+ return CircuitJsonPcbPrimitiveIndex.#localIndex(normalized)
33
+ }
34
+ throw normalizedError
35
+ }
20
36
  }
21
37
  }
22
38
 
@@ -355,9 +355,15 @@ export class CircuitJsonPcbPrimitiveOverlays {
355
355
  pcb_trace_ids:
356
356
  diagnostic?.pcbTraceIds || diagnostic?.pcb_trace_ids,
357
357
  pcb_smtpad_id:
358
- diagnostic?.pcbSmtpadId || diagnostic?.pcb_smtpad_id,
358
+ diagnostic?.pcbSmtpadId ||
359
+ diagnostic?.pcb_smtpad_id ||
360
+ diagnostic?.pcbPadId ||
361
+ diagnostic?.pcb_pad_id,
359
362
  pcb_smtpad_ids:
360
- diagnostic?.pcbSmtpadIds || diagnostic?.pcb_smtpad_ids,
363
+ diagnostic?.pcbSmtpadIds ||
364
+ diagnostic?.pcb_smtpad_ids ||
365
+ diagnostic?.pcbPadIds ||
366
+ diagnostic?.pcb_pad_ids,
361
367
  pcb_via_id: diagnostic?.pcbViaId || diagnostic?.pcb_via_id,
362
368
  pcb_via_ids: diagnostic?.pcbViaIds || diagnostic?.pcb_via_ids,
363
369
  pcb_plated_hole_id:
@@ -646,7 +652,12 @@ export class CircuitJsonPcbPrimitiveOverlays {
646
652
  ['pcb_trace_id', [element?.pcb_trace_id, element?.pcb_trace_ids]],
647
653
  [
648
654
  'pcb_smtpad_id',
649
- [element?.pcb_smtpad_id, element?.pcb_smtpad_ids]
655
+ [
656
+ element?.pcb_smtpad_id,
657
+ element?.pcb_smtpad_ids,
658
+ element?.pcb_pad_id,
659
+ element?.pcb_pad_ids
660
+ ]
650
661
  ],
651
662
  ['pcb_via_id', [element?.pcb_via_id, element?.pcb_via_ids]],
652
663
  [
@@ -762,12 +773,18 @@ export class CircuitJsonPcbPrimitiveOverlays {
762
773
  * @returns {object | null}
763
774
  */
764
775
  static #mergeBounds(rows) {
765
- const validRows = rows.filter(Boolean)
766
- if (!validRows.length) return null
767
- const minX = Math.min(...validRows.map((bounds) => bounds.minX))
768
- const minY = Math.min(...validRows.map((bounds) => bounds.minY))
769
- const maxX = Math.max(...validRows.map((bounds) => bounds.maxX))
770
- const maxY = Math.max(...validRows.map((bounds) => bounds.maxY))
776
+ let minX = Infinity
777
+ let minY = Infinity
778
+ let maxX = -Infinity
779
+ let maxY = -Infinity
780
+ for (const bounds of rows) {
781
+ if (!bounds) continue
782
+ minX = Math.min(minX, bounds.minX)
783
+ minY = Math.min(minY, bounds.minY)
784
+ maxX = Math.max(maxX, bounds.maxX)
785
+ maxY = Math.max(maxY, bounds.maxY)
786
+ }
787
+ if (!Number.isFinite(minX)) return null
771
788
  return {
772
789
  minX: CircuitJsonPcbPrimitiveOverlays.#round(minX),
773
790
  minY: CircuitJsonPcbPrimitiveOverlays.#round(minY),
@@ -325,12 +325,13 @@ export class CircuitJsonPcbZonePrimitiveBuilder {
325
325
  * @returns {object[]}
326
326
  */
327
327
  static #brepShapes(element) {
328
- return [
329
- ...(Array.isArray(element?.brep_shapes) ? element.brep_shapes : []),
330
- ...(Array.isArray(element?.brepShapes) ? element.brepShapes : []),
331
- element?.brep_shape,
332
- element?.brepShape
333
- ].filter(Boolean)
328
+ if (Array.isArray(element?.brep_shapes) && element.brep_shapes.length) {
329
+ return element.brep_shapes
330
+ }
331
+ if (Array.isArray(element?.brepShapes) && element.brepShapes.length) {
332
+ return element.brepShapes
333
+ }
334
+ return [element?.brep_shape, element?.brepShape].filter(Boolean)
334
335
  }
335
336
 
336
337
  /**
@@ -0,0 +1,87 @@
1
+ import { CIRCUIT_JSON_UPSTREAM_DEFAULT_ID_FIELDS } from './CircuitJsonUpstreamSchema.mjs'
2
+
3
+ /**
4
+ * Reports upstream defaults intentionally left unmaterialized by raw parsing.
5
+ */
6
+ export class CircuitJsonSerializedInputAudit {
7
+ /**
8
+ * Audits missing random upstream identity defaults without mutating input.
9
+ * @param {unknown} model Serialized CircuitJSON model candidate.
10
+ * @param {string} [source] Source identity for diagnostics.
11
+ * @returns {{ diagnostics: object[], statistics: Record<string, number> }} Audit rows.
12
+ */
13
+ static inspect(model, source = '') {
14
+ if (!Array.isArray(model)) {
15
+ return { diagnostics: [], statistics: {} }
16
+ }
17
+ let length
18
+ try {
19
+ length = Object.getOwnPropertyDescriptor(model, 'length')?.value
20
+ } catch {
21
+ return { diagnostics: [], statistics: {} }
22
+ }
23
+ if (!Number.isSafeInteger(length) || length < 0) {
24
+ return { diagnostics: [], statistics: {} }
25
+ }
26
+ const diagnostics = []
27
+ for (let index = 0; index < length; index += 1) {
28
+ const element = CircuitJsonSerializedInputAudit.#dataField(
29
+ model,
30
+ String(index)
31
+ )
32
+ const type = CircuitJsonSerializedInputAudit.#dataField(
33
+ element,
34
+ 'type'
35
+ )
36
+ if (typeof type !== 'string') continue
37
+ const idField = CIRCUIT_JSON_UPSTREAM_DEFAULT_ID_FIELDS[type]
38
+ if (!idField) continue
39
+ const identity = CircuitJsonSerializedInputAudit.#dataField(
40
+ element,
41
+ idField
42
+ )
43
+ if (identity !== undefined) continue
44
+ diagnostics.push({
45
+ code: 'CIRCUITJSON_UPSTREAM_DEFAULT_ID_OMITTED',
46
+ severity: 'warning',
47
+ message:
48
+ `Serialized CircuitJSON omitted ${idField}; ` +
49
+ 'the random upstream default was not materialized.',
50
+ source,
51
+ location: { elementIndex: index },
52
+ details: {
53
+ elementType: type,
54
+ idField,
55
+ materialized: false
56
+ }
57
+ })
58
+ }
59
+ return {
60
+ diagnostics,
61
+ statistics: diagnostics.length
62
+ ? { upstreamDefaultIdentityOmissions: diagnostics.length }
63
+ : {}
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Reads one own data field without invoking caller accessors.
69
+ * @param {unknown} owner Field owner.
70
+ * @param {string} key Field name.
71
+ * @returns {unknown} Data value or undefined.
72
+ */
73
+ static #dataField(owner, key) {
74
+ if (!owner || typeof owner !== 'object') return undefined
75
+ try {
76
+ const descriptor = Object.getOwnPropertyDescriptor(owner, key)
77
+ return descriptor && Object.hasOwn(descriptor, 'value')
78
+ ? descriptor.value
79
+ : undefined
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+ }
85
+
86
+ Object.freeze(CircuitJsonSerializedInputAudit.prototype)
87
+ Object.freeze(CircuitJsonSerializedInputAudit)
@@ -185,7 +185,11 @@ export class CircuitJsonSourceMetadata {
185
185
  */
186
186
  static #supplierPartNumbers(component) {
187
187
  const numbers = {}
188
- for (const field of ['supplier_part_numbers', 'supplierPartNumbers']) {
188
+ for (const field of [
189
+ 'supplier_part_numbers',
190
+ 'supplierPartNumbers',
191
+ 'legacy_supplier_part_numbers'
192
+ ]) {
189
193
  const value = component?.[field]
190
194
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
191
195
  continue
@@ -56,12 +56,14 @@ const SCHEMATIC_RENDERED_TYPES = new Set([
56
56
  'schematic_component',
57
57
  'schematic_debug_object',
58
58
  'schematic_group',
59
+ 'schematic_image',
59
60
  'schematic_line',
60
61
  'schematic_net_label',
61
62
  'schematic_path',
62
63
  'schematic_port',
63
64
  'schematic_rect',
64
65
  'schematic_sheet',
66
+ 'schematic_sheet_symbol',
65
67
  'schematic_symbol',
66
68
  'schematic_table',
67
69
  'schematic_table_cell',
@@ -171,7 +173,7 @@ export class CircuitJsonSupportMatrixBuilder {
171
173
  .map((element) => String(element?.type || ''))
172
174
  .filter(Boolean)
173
175
  )
174
- const rows = CircuitJsonElementValidator.knownElementTypes().map(
176
+ const rows = CircuitJsonElementValidator.canonicalElementTypes().map(
175
177
  (type) =>
176
178
  CircuitJsonSupportMatrixBuilder.#row(
177
179
  type,
@@ -0,0 +1,218 @@
1
+ const TOOLKIT_ELEMENT_TYPES = new Set([
2
+ 'schematic_image',
3
+ 'schematic_sheet_symbol'
4
+ ])
5
+
6
+ /**
7
+ * Validates canonical toolkit element types that are newer than the pinned
8
+ * upstream CircuitJSON union.
9
+ */
10
+ export class CircuitJsonToolkitElementSchema {
11
+ /**
12
+ * Returns the toolkit-owned canonical element types.
13
+ * @returns {string[]} Stable element type names.
14
+ */
15
+ static elementTypes() {
16
+ return [...TOOLKIT_ELEMENT_TYPES]
17
+ }
18
+
19
+ /**
20
+ * Returns whether a type is owned by the canonical toolkit overlay.
21
+ * @param {unknown} type Element type.
22
+ * @returns {boolean} Whether the type is recognized.
23
+ */
24
+ static has(type) {
25
+ return typeof type === 'string' && TOOLKIT_ELEMENT_TYPES.has(type)
26
+ }
27
+
28
+ /**
29
+ * Validates one toolkit-owned canonical element.
30
+ * @param {Record<string, any>} value Element value.
31
+ * @param {string} type Element type.
32
+ * @param {string} [location] Human-readable location suffix.
33
+ * @returns {string[]} Empty on success or one validation error.
34
+ */
35
+ static validate(value, type, location = '') {
36
+ const valid =
37
+ (type === 'schematic_image' &&
38
+ CircuitJsonToolkitElementSchema.#schematicImage(value)) ||
39
+ (type === 'schematic_sheet_symbol' &&
40
+ CircuitJsonToolkitElementSchema.#schematicSheetSymbol(value))
41
+ return valid
42
+ ? []
43
+ : [
44
+ `CircuitJSON element ${type}${location} does not match the canonical toolkit schema.`
45
+ ]
46
+ }
47
+
48
+ /**
49
+ * Validates an asset-backed schematic image.
50
+ * @param {Record<string, any>} value Image element.
51
+ * @returns {boolean} Whether the complete image contract matches.
52
+ */
53
+ static #schematicImage(value) {
54
+ if (
55
+ value.type !== 'schematic_image' ||
56
+ !CircuitJsonToolkitElementSchema.#requiredString(
57
+ value.schematic_image_id
58
+ ) ||
59
+ !CircuitJsonToolkitElementSchema.#requiredString(value.asset_id) ||
60
+ !CircuitJsonToolkitElementSchema.#point(value.center) ||
61
+ !CircuitJsonToolkitElementSchema.#size(value.size)
62
+ ) {
63
+ return false
64
+ }
65
+ for (const field of [
66
+ 'schematic_sheet_id',
67
+ 'schematic_component_id',
68
+ 'subcircuit_id',
69
+ 'source_path',
70
+ 'source_name'
71
+ ]) {
72
+ if (
73
+ value[field] !== undefined &&
74
+ typeof value[field] !== 'string'
75
+ ) {
76
+ return false
77
+ }
78
+ }
79
+ if (
80
+ value.rotation !== undefined &&
81
+ !CircuitJsonToolkitElementSchema.#number(value.rotation)
82
+ ) {
83
+ return false
84
+ }
85
+ if (
86
+ value.opacity !== undefined &&
87
+ (!CircuitJsonToolkitElementSchema.#number(value.opacity) ||
88
+ value.opacity < 0 ||
89
+ value.opacity > 1)
90
+ ) {
91
+ return false
92
+ }
93
+ if (
94
+ value.preserve_aspect_ratio !== undefined &&
95
+ typeof value.preserve_aspect_ratio !== 'boolean'
96
+ ) {
97
+ return false
98
+ }
99
+ return (
100
+ value.render_order === undefined ||
101
+ Number.isSafeInteger(value.render_order)
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Validates one hierarchical child-sheet symbol.
107
+ * @param {Record<string, any>} value Sheet-symbol element.
108
+ * @returns {boolean} Whether the complete symbol contract matches.
109
+ */
110
+ static #schematicSheetSymbol(value) {
111
+ if (
112
+ value.type !== 'schematic_sheet_symbol' ||
113
+ !CircuitJsonToolkitElementSchema.#requiredString(
114
+ value.schematic_sheet_symbol_id
115
+ ) ||
116
+ typeof value.name !== 'string' ||
117
+ !CircuitJsonToolkitElementSchema.#point(value.center) ||
118
+ !CircuitJsonToolkitElementSchema.#positive(value.width) ||
119
+ !CircuitJsonToolkitElementSchema.#positive(value.height)
120
+ ) {
121
+ return false
122
+ }
123
+ for (const field of [
124
+ 'schematic_sheet_id',
125
+ 'schematic_group_id',
126
+ 'subcircuit_id',
127
+ 'source_file_name',
128
+ 'color',
129
+ 'fill_color'
130
+ ]) {
131
+ if (
132
+ value[field] !== undefined &&
133
+ typeof value[field] !== 'string'
134
+ ) {
135
+ return false
136
+ }
137
+ }
138
+ for (const field of ['is_dashed', 'is_filled']) {
139
+ if (
140
+ value[field] !== undefined &&
141
+ typeof value[field] !== 'boolean'
142
+ ) {
143
+ return false
144
+ }
145
+ }
146
+ if (
147
+ value.stroke_width !== undefined &&
148
+ (!CircuitJsonToolkitElementSchema.#number(value.stroke_width) ||
149
+ value.stroke_width < 0)
150
+ ) {
151
+ return false
152
+ }
153
+ return (
154
+ value.render_order === undefined ||
155
+ Number.isSafeInteger(value.render_order)
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Validates a required non-empty string.
161
+ * @param {unknown} value Candidate.
162
+ * @returns {boolean} Whether the value is valid.
163
+ */
164
+ static #requiredString(value) {
165
+ return typeof value === 'string' && value.length > 0
166
+ }
167
+
168
+ /**
169
+ * Validates a finite number.
170
+ * @param {unknown} value Candidate.
171
+ * @returns {boolean} Whether the value is valid.
172
+ */
173
+ static #number(value) {
174
+ return typeof value === 'number' && Number.isFinite(value)
175
+ }
176
+
177
+ /**
178
+ * Validates a positive finite number.
179
+ * @param {unknown} value Candidate.
180
+ * @returns {boolean} Whether the value is valid.
181
+ */
182
+ static #positive(value) {
183
+ return CircuitJsonToolkitElementSchema.#number(value) && value > 0
184
+ }
185
+
186
+ /**
187
+ * Validates one finite point.
188
+ * @param {unknown} value Candidate.
189
+ * @returns {boolean} Whether the point is valid.
190
+ */
191
+ static #point(value) {
192
+ return Boolean(
193
+ value &&
194
+ typeof value === 'object' &&
195
+ !Array.isArray(value) &&
196
+ CircuitJsonToolkitElementSchema.#number(value.x) &&
197
+ CircuitJsonToolkitElementSchema.#number(value.y)
198
+ )
199
+ }
200
+
201
+ /**
202
+ * Validates one positive image size.
203
+ * @param {unknown} value Candidate.
204
+ * @returns {boolean} Whether the size is valid.
205
+ */
206
+ static #size(value) {
207
+ return Boolean(
208
+ value &&
209
+ typeof value === 'object' &&
210
+ !Array.isArray(value) &&
211
+ CircuitJsonToolkitElementSchema.#positive(value.width) &&
212
+ CircuitJsonToolkitElementSchema.#positive(value.height)
213
+ )
214
+ }
215
+ }
216
+
217
+ Object.freeze(CircuitJsonToolkitElementSchema.prototype)
218
+ Object.freeze(CircuitJsonToolkitElementSchema)
@@ -0,0 +1,101 @@
1
+ const LENGTH_FACTORS_TO_MM = new Map([
2
+ ['mm', 1],
3
+ ['millimeter', 1],
4
+ ['millimeters', 1],
5
+ ['cm', 10],
6
+ ['centimeter', 10],
7
+ ['centimeters', 10],
8
+ ['m', 1000],
9
+ ['meter', 1000],
10
+ ['meters', 1000],
11
+ ['in', 25.4],
12
+ ['inch', 25.4],
13
+ ['inches', 25.4],
14
+ ['mil', 0.0254],
15
+ ['mils', 0.0254],
16
+ ['um', 0.001],
17
+ ['micrometer', 0.001],
18
+ ['micrometers', 0.001]
19
+ ])
20
+ const ANGLE_FACTORS_TO_DEG = new Map([
21
+ ['deg', 1],
22
+ ['degree', 1],
23
+ ['degrees', 1],
24
+ ['rad', 180 / Math.PI],
25
+ ['radian', 180 / Math.PI],
26
+ ['radians', 180 / Math.PI]
27
+ ])
28
+
29
+ /**
30
+ * Rounds unit conversions to stable precision.
31
+ * @param {number} value Numeric value.
32
+ * @returns {number} Rounded value.
33
+ */
34
+ export function round(value) {
35
+ return Math.round(value * 1_000_000) / 1_000_000
36
+ }
37
+
38
+ /**
39
+ * Parses one numeric value with an optional unit suffix.
40
+ * @param {unknown} value Value candidate.
41
+ * @param {Map<string, number>} unitFactors Unit factor lookup.
42
+ * @returns {number | null} Parsed value or null.
43
+ */
44
+ function parseUnitValue(value, unitFactors) {
45
+ if (typeof value === 'number') {
46
+ return Number.isFinite(value) ? round(value) : null
47
+ }
48
+
49
+ const text = String(value ?? '').trim()
50
+ if (!text) return null
51
+ const match = text.match(
52
+ /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*([a-z]+)?$/iu
53
+ )
54
+ if (!match) return null
55
+
56
+ const number = Number(match[1])
57
+ if (!Number.isFinite(number)) return null
58
+ const unit = String(match[2] || '').toLowerCase()
59
+ const factor = unit ? unitFactors.get(unit) : 1
60
+ return Number.isFinite(factor) ? round(number * factor) : null
61
+ }
62
+
63
+ /**
64
+ * Parses an optional millimeter length.
65
+ * @param {unknown} value Length candidate.
66
+ * @returns {number | null} Parsed length or null.
67
+ */
68
+ export function optionalLength(value) {
69
+ return parseUnitValue(value, LENGTH_FACTORS_TO_MM)
70
+ }
71
+
72
+ /**
73
+ * Parses an optional degree angle.
74
+ * @param {unknown} value Angle candidate.
75
+ * @returns {number | null} Parsed angle or null.
76
+ */
77
+ export function optionalAngle(value) {
78
+ return parseUnitValue(value, ANGLE_FACTORS_TO_DEG)
79
+ }
80
+
81
+ /**
82
+ * Parses an optional point.
83
+ * @param {{ x?: unknown, y?: unknown } | null | undefined} point Point.
84
+ * @returns {{ x: number, y: number } | null} Parsed point or null.
85
+ */
86
+ export function optionalPoint(point) {
87
+ const x = optionalLength(point?.x)
88
+ const y = optionalLength(point?.y)
89
+ return x === null || y === null ? null : { x, y }
90
+ }
91
+
92
+ /**
93
+ * Parses an optional size.
94
+ * @param {{ width?: unknown, height?: unknown } | null | undefined} size Size.
95
+ * @returns {{ width: number, height: number } | null} Parsed size or null.
96
+ */
97
+ export function optionalSize(size) {
98
+ const width = optionalLength(size?.width)
99
+ const height = optionalLength(size?.height)
100
+ return width === null || height === null ? null : { width, height }
101
+ }