altium-toolkit 1.1.3 → 1.1.22

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 (39) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  8. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  9. package/src/core/altium/AltiumParser.mjs +5 -2
  10. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  11. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  12. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  13. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  14. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  15. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  16. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  17. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  18. package/src/core/altium/SchematicPinParser.mjs +87 -20
  19. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  20. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  21. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  22. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  23. package/src/core/altium/SourceComponentClient.mjs +239 -0
  24. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  25. package/src/parser.mjs +8 -0
  26. package/src/styles/altium-renderers.css +6 -6
  27. package/src/ui/PcbArcUtils.mjs +19 -2
  28. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  29. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  30. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  31. package/src/ui/SchematicColorResolver.mjs +185 -0
  32. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  33. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  34. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  35. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  36. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  37. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  38. package/src/ui/SchematicShapeRenderer.mjs +37 -11
  39. package/src/ui/SchematicSvgRenderer.mjs +944 -51
@@ -12,6 +12,7 @@ import { SchematicDirectiveRenderer } from './SchematicDirectiveRenderer.mjs'
12
12
  import { SchematicShapeRenderer } from './SchematicShapeRenderer.mjs'
13
13
  import { SchematicPinSvgRenderer } from './SchematicPinSvgRenderer.mjs'
14
14
  import { SchematicColorResolver } from './SchematicColorResolver.mjs'
15
+ import { SchematicLineColorResolver } from './SchematicLineColorResolver.mjs'
15
16
  import { SchematicSheetChromeRenderer } from './SchematicSheetChromeRenderer.mjs'
16
17
  import { SchematicContentLayout } from './SchematicContentLayout.mjs'
17
18
  import { SchematicOwnerPinLabelLayout } from './SchematicOwnerPinLabelLayout.mjs'
@@ -19,6 +20,7 @@ import { SchematicRegionRenderer } from './SchematicRegionRenderer.mjs'
19
20
  import { SchematicSheetSymbolRenderer } from './SchematicSheetSymbolRenderer.mjs'
20
21
  import { SchematicImageRenderer } from './SchematicImageRenderer.mjs'
21
22
  import { SchematicNativeFooterPartitioner } from './SchematicNativeFooterPartitioner.mjs'
23
+ import { SchematicOwnerPinMarkerLineThemer } from './SchematicOwnerPinMarkerLineThemer.mjs'
22
24
  import { TextGeometrySidecarBuilder } from './TextGeometrySidecarBuilder.mjs'
23
25
  import { SchematicRenderOpsSidecarBuilder } from './SchematicRenderOpsSidecarBuilder.mjs'
24
26
  import { SchematicProjectParameterResolver } from '../core/altium/SchematicProjectParameterResolver.mjs'
@@ -29,6 +31,11 @@ const SECTION_HEADING_MIN_FONT_SIZE = 18
29
31
  const SECTION_HEADING_BASELINE_LIFT_RATIO = 0.36
30
32
  const SECTION_HEADING_LINE_Y_TOLERANCE = 0.75
31
33
  const SECTION_HEADING_LINE_X_PADDING = 15
34
+ const BODY_TEXT_ASCENT_RATIO = 0.72
35
+ const BODY_TEXT_DESCENT_RATIO = 0.14
36
+ const BODY_TEXT_FLAT_DESCENT_RATIO = 0
37
+ const BODY_TEXT_MIN_SEPARATOR_SPAN_RATIO = 0.3
38
+ const BODY_TEXT_DESCENDER_PATTERN = /[gjpqyQ_,;]/
32
39
 
33
40
  /**
34
41
  * Renders normalized schematic models into presentational SVG.
@@ -179,8 +186,13 @@ export class SchematicSvgRenderer {
179
186
  },
180
187
  renderedSchematic.sheet
181
188
  )
189
+ const contentMarkerThemedLines =
190
+ SchematicOwnerPinMarkerLineThemer.theme(
191
+ partitionedPrimitives.content.lines,
192
+ pins
193
+ )
182
194
  const contentLines = SchematicSvgRenderer.#themeFooterChromeLines(
183
- partitionedPrimitives.content.lines,
195
+ contentMarkerThemedLines,
184
196
  renderedSheet.sheet,
185
197
  contentHeight
186
198
  )
@@ -196,7 +208,18 @@ export class SchematicSvgRenderer {
196
208
  const contentTexts = partitionedPrimitives.content.texts
197
209
  const contentImages = partitionedPrimitives.content.images
198
210
  const footerPrimitives = partitionedPrimitives.footer
199
- const ownerlessLines = contentLines.filter((line) => !line.ownerIndex)
211
+ const foregroundOwnerlessLines =
212
+ SchematicSvgRenderer.#collectForegroundOwnerlessLines(
213
+ contentLines,
214
+ contentPolygons,
215
+ contentRectangles,
216
+ contentRoundedRectangles,
217
+ contentEllipses,
218
+ contentPies
219
+ )
220
+ const ownerlessLines = contentLines.filter(
221
+ (line) => !line.ownerIndex && !foregroundOwnerlessLines.has(line)
222
+ )
200
223
  const ownerlessPolygons = contentPolygons.filter(
201
224
  (polygon) => !polygon.ownerIndex
202
225
  )
@@ -408,6 +431,21 @@ export class SchematicSvgRenderer {
408
431
  contentHeight,
409
432
  semanticContext
410
433
  )
434
+ const foregroundLineMarkup = [...foregroundOwnerlessLines]
435
+ .map((line, index) =>
436
+ SchematicSvgRenderer.#buildSchematicLineMarkup(
437
+ line,
438
+ contentHeight,
439
+ SchematicSvgRenderer.#primitiveIndex(
440
+ semanticContext,
441
+ 'lines',
442
+ line,
443
+ index
444
+ ),
445
+ semanticContext
446
+ )
447
+ )
448
+ .join('')
411
449
  const sheetSymbolMarkup =
412
450
  SchematicSheetSymbolRenderer.buildSheetSymbolMarkup(
413
451
  sheetSymbols,
@@ -453,6 +491,16 @@ export class SchematicSvgRenderer {
453
491
  )
454
492
  const markerDefsMarkup =
455
493
  SchematicSvgRenderer.#buildSchematicLineMarkerDefs(contentLines)
494
+ const ownerTextBodyBounds =
495
+ SchematicSvgRenderer.#collectOwnerTextBodyBounds(
496
+ contentLines,
497
+ contentPolygons,
498
+ contentRectangles,
499
+ contentRoundedRectangles,
500
+ contentEllipses,
501
+ contentArcs,
502
+ contentPies
503
+ )
456
504
 
457
505
  const textMarkup = contentTexts
458
506
  .map((text, index) =>
@@ -467,7 +515,8 @@ export class SchematicSvgRenderer {
467
515
  text,
468
516
  index
469
517
  ),
470
- semanticContext
518
+ semanticContext,
519
+ ownerTextBodyBounds
471
520
  )
472
521
  )
473
522
  .join('')
@@ -585,6 +634,12 @@ export class SchematicSvgRenderer {
585
634
  pins,
586
635
  contentRectangles
587
636
  )
637
+ const overlappingExternalNumberLabelKeys =
638
+ SchematicOwnerPinLabelLayout.collectOverlappingExternalNumberLabelKeys(
639
+ pins,
640
+ contentTexts,
641
+ internalNumberLabelBoxes
642
+ )
588
643
  const pinMarkup = pins
589
644
  .map((pin, index) =>
590
645
  SchematicSvgRenderer.#appendSvgAttributes(
@@ -596,7 +651,8 @@ export class SchematicSvgRenderer {
596
651
  explicitOwnerPinNameLabels,
597
652
  explicitOwnerPinLabelOffsets,
598
653
  compactExternalNumberLabelSides,
599
- internalNumberLabelBoxes
654
+ internalNumberLabelBoxes,
655
+ overlappingExternalNumberLabelKeys
600
656
  ),
601
657
  SchematicSvgRenderer.#semanticAttributes(
602
658
  'pin',
@@ -726,6 +782,9 @@ export class SchematicSvgRenderer {
726
782
  '<g class="schematic-owner-geometry" stroke-linecap="round">' +
727
783
  ownerGeometryMarkup +
728
784
  '</g>' +
785
+ '<g class="schematic-foreground-lines" stroke-linecap="round">' +
786
+ foregroundLineMarkup +
787
+ '</g>' +
729
788
  '<g class="schematic-sheet-symbols">' +
730
789
  sheetSymbolMarkup +
731
790
  '</g>' +
@@ -845,8 +904,10 @@ export class SchematicSvgRenderer {
845
904
  return false
846
905
  }
847
906
  if (
848
- SchematicSvgRenderer.#resolveSchematicLineColor(line) !==
849
- 'var(--schematic-text-color)'
907
+ SchematicColorResolver.resolveColor(
908
+ line.color,
909
+ '--schematic-default-ink-color'
910
+ ) !== 'var(--schematic-text-color)'
850
911
  ) {
851
912
  return false
852
913
  }
@@ -872,6 +933,13 @@ export class SchematicSvgRenderer {
872
933
  }
873
934
 
874
935
  const margin = Math.max(Number(sheet?.marginWidth || 20), 10)
936
+ const lineLength = Math.hypot(x2 - x1, y2 - y1)
937
+ const minChromeLineLength = Math.max(margin * 0.6, 12)
938
+
939
+ if (lineLength < minChromeLineLength) {
940
+ return false
941
+ }
942
+
875
943
  const footerBandHeight = Math.max(margin * 6, 120)
876
944
  const footerTop = Math.max(height - footerBandHeight, margin)
877
945
  const projectedY1 = projectSchematicY(height, y1)
@@ -1791,6 +1859,311 @@ export class SchematicSvgRenderer {
1791
1859
  return height + margin * 2
1792
1860
  }
1793
1861
 
1862
+ /**
1863
+ * Collects ownerless line primitives that were authored after an
1864
+ * owner-linked fill they overlap. These are symbol decoration strokes
1865
+ * stored outside the owner group and must paint above the body fill.
1866
+ * @param {{ ownerIndex?: string, renderOrder?: number, x1: number, y1: number, x2: number, y2: number }[]} lines Line primitives.
1867
+ * @param {{ ownerIndex?: string, renderOrder?: number, points?: { x: number, y: number }[] }[]} polygons Polygon primitives.
1868
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, width: number, height: number }[]} rectangles Rectangle primitives.
1869
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, width: number, height: number }[]} roundedRectangles Rounded rectangle primitives.
1870
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, radiusX: number, radiusY: number }[]} ellipses Ellipse primitives.
1871
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, radius: number, radiusY?: number }[]} pies Pie primitives.
1872
+ * @returns {Set<object>}
1873
+ */
1874
+ static #collectForegroundOwnerlessLines(
1875
+ lines,
1876
+ polygons,
1877
+ rectangles,
1878
+ roundedRectangles,
1879
+ ellipses,
1880
+ pies
1881
+ ) {
1882
+ const ownerFillBounds = SchematicSvgRenderer.#collectOwnerFillBounds(
1883
+ polygons,
1884
+ rectangles,
1885
+ roundedRectangles,
1886
+ ellipses,
1887
+ pies
1888
+ )
1889
+
1890
+ if (!ownerFillBounds.length) {
1891
+ return new Set()
1892
+ }
1893
+
1894
+ return new Set(
1895
+ (lines || []).filter((line) =>
1896
+ SchematicSvgRenderer.#isForegroundOwnerlessLine(
1897
+ line,
1898
+ ownerFillBounds
1899
+ )
1900
+ )
1901
+ )
1902
+ }
1903
+
1904
+ /**
1905
+ * Collects sortable bounds for owner-linked fill primitives.
1906
+ * @param {object[]} polygons Polygon primitives.
1907
+ * @param {object[]} rectangles Rectangle primitives.
1908
+ * @param {object[]} roundedRectangles Rounded rectangle primitives.
1909
+ * @param {object[]} ellipses Ellipse primitives.
1910
+ * @param {object[]} pies Pie primitives.
1911
+ * @returns {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, renderOrder: number }[]}
1912
+ */
1913
+ static #collectOwnerFillBounds(
1914
+ polygons,
1915
+ rectangles,
1916
+ roundedRectangles,
1917
+ ellipses,
1918
+ pies
1919
+ ) {
1920
+ return [
1921
+ ...(polygons || []),
1922
+ ...(rectangles || []),
1923
+ ...(roundedRectangles || []),
1924
+ ...(ellipses || []),
1925
+ ...(pies || [])
1926
+ ]
1927
+ .filter((primitive) => primitive?.ownerIndex)
1928
+ .map((primitive) => ({
1929
+ bounds: SchematicSvgRenderer.#primitiveBounds(primitive),
1930
+ renderOrder:
1931
+ SchematicSvgRenderer.#resolvePrimitiveRenderOrder(primitive)
1932
+ }))
1933
+ .filter(
1934
+ (entry) =>
1935
+ entry.bounds &&
1936
+ Number.isFinite(entry.renderOrder) &&
1937
+ entry.renderOrder < Number.MAX_SAFE_INTEGER
1938
+ )
1939
+ }
1940
+
1941
+ /**
1942
+ * Collects unprojected owner body envelopes used to identify text that is
1943
+ * drawn inside a component body instead of on surrounding wires or sheet
1944
+ * chrome.
1945
+ * @param {object[]} lines Line primitives.
1946
+ * @param {object[]} polygons Polygon primitives.
1947
+ * @param {object[]} rectangles Rectangle primitives.
1948
+ * @param {object[]} roundedRectangles Rounded rectangle primitives.
1949
+ * @param {object[]} ellipses Ellipse primitives.
1950
+ * @param {object[]} arcs Arc primitives.
1951
+ * @param {object[]} pies Pie primitives.
1952
+ * @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
1953
+ */
1954
+ static #collectOwnerTextBodyBounds(
1955
+ lines,
1956
+ polygons,
1957
+ rectangles,
1958
+ roundedRectangles,
1959
+ ellipses,
1960
+ arcs,
1961
+ pies
1962
+ ) {
1963
+ const ownerBounds = new Map()
1964
+
1965
+ for (const primitive of [
1966
+ ...(lines || []),
1967
+ ...(polygons || []),
1968
+ ...(rectangles || []),
1969
+ ...(roundedRectangles || []),
1970
+ ...(ellipses || []),
1971
+ ...(arcs || []),
1972
+ ...(pies || [])
1973
+ ]) {
1974
+ SchematicSvgRenderer.#extendOwnerTextBodyBounds(
1975
+ ownerBounds,
1976
+ primitive
1977
+ )
1978
+ }
1979
+
1980
+ return ownerBounds
1981
+ }
1982
+
1983
+ /**
1984
+ * Extends the body-bounds map with one owner-linked primitive.
1985
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds Owner bounds map.
1986
+ * @param {object} primitive Primitive candidate.
1987
+ * @returns {void}
1988
+ */
1989
+ static #extendOwnerTextBodyBounds(ownerBounds, primitive) {
1990
+ const ownerIndex = String(primitive?.ownerIndex || '').trim()
1991
+ const bounds = SchematicSvgRenderer.#primitiveBounds(primitive)
1992
+
1993
+ if (!ownerIndex || !bounds) {
1994
+ return
1995
+ }
1996
+
1997
+ const current = ownerBounds.get(ownerIndex)
1998
+
1999
+ if (!current) {
2000
+ ownerBounds.set(ownerIndex, { ...bounds })
2001
+ return
2002
+ }
2003
+
2004
+ current.minX = Math.min(current.minX, bounds.minX)
2005
+ current.minY = Math.min(current.minY, bounds.minY)
2006
+ current.maxX = Math.max(current.maxX, bounds.maxX)
2007
+ current.maxY = Math.max(current.maxY, bounds.maxY)
2008
+ }
2009
+
2010
+ /**
2011
+ * Returns true when one ownerless line should paint above an overlapping
2012
+ * owner fill because the native stream authored it later.
2013
+ * @param {{ ownerIndex?: string, renderOrder?: number, x1: number, y1: number, x2: number, y2: number }} line Line primitive.
2014
+ * @param {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, renderOrder: number }[]} ownerFillBounds Fill bounds.
2015
+ * @returns {boolean}
2016
+ */
2017
+ static #isForegroundOwnerlessLine(line, ownerFillBounds) {
2018
+ if (line?.ownerIndex) {
2019
+ return false
2020
+ }
2021
+
2022
+ const renderOrder = Number(line?.renderOrder)
2023
+ if (!Number.isFinite(renderOrder)) {
2024
+ return false
2025
+ }
2026
+
2027
+ const lineBounds = SchematicSvgRenderer.#primitiveBounds(line)
2028
+ if (!lineBounds) {
2029
+ return false
2030
+ }
2031
+
2032
+ return ownerFillBounds.some(
2033
+ (fill) =>
2034
+ renderOrder > fill.renderOrder &&
2035
+ SchematicSvgRenderer.#boundsIntersect(
2036
+ lineBounds,
2037
+ fill.bounds,
2038
+ 0.5
2039
+ )
2040
+ )
2041
+ }
2042
+
2043
+ /**
2044
+ * Resolves an unprojected coordinate envelope for one schematic primitive.
2045
+ * @param {object} primitive Primitive.
2046
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2047
+ */
2048
+ static #primitiveBounds(primitive) {
2049
+ if (Array.isArray(primitive?.points) && primitive.points.length > 0) {
2050
+ return SchematicSvgRenderer.#pointBounds(primitive.points)
2051
+ }
2052
+
2053
+ if (
2054
+ Number.isFinite(Number(primitive?.x1)) &&
2055
+ Number.isFinite(Number(primitive?.y1)) &&
2056
+ Number.isFinite(Number(primitive?.x2)) &&
2057
+ Number.isFinite(Number(primitive?.y2))
2058
+ ) {
2059
+ return SchematicSvgRenderer.#pointBounds([
2060
+ { x: Number(primitive.x1), y: Number(primitive.y1) },
2061
+ { x: Number(primitive.x2), y: Number(primitive.y2) }
2062
+ ])
2063
+ }
2064
+
2065
+ if (
2066
+ Number.isFinite(Number(primitive?.x)) &&
2067
+ Number.isFinite(Number(primitive?.y)) &&
2068
+ Number.isFinite(Number(primitive?.width)) &&
2069
+ Number.isFinite(Number(primitive?.height))
2070
+ ) {
2071
+ const x = Number(primitive.x)
2072
+ const y = Number(primitive.y)
2073
+ const x2 = x + Number(primitive.width)
2074
+ const y2 = y + Number(primitive.height)
2075
+
2076
+ return {
2077
+ minX: Math.min(x, x2),
2078
+ minY: Math.min(y, y2),
2079
+ maxX: Math.max(x, x2),
2080
+ maxY: Math.max(y, y2)
2081
+ }
2082
+ }
2083
+
2084
+ if (
2085
+ Number.isFinite(Number(primitive?.x)) &&
2086
+ Number.isFinite(Number(primitive?.y)) &&
2087
+ Number.isFinite(Number(primitive?.radiusX)) &&
2088
+ Number.isFinite(Number(primitive?.radiusY))
2089
+ ) {
2090
+ const x = Number(primitive.x)
2091
+ const y = Number(primitive.y)
2092
+ const radiusX = Math.abs(Number(primitive.radiusX))
2093
+ const radiusY = Math.abs(Number(primitive.radiusY))
2094
+
2095
+ return {
2096
+ minX: x - radiusX,
2097
+ minY: y - radiusY,
2098
+ maxX: x + radiusX,
2099
+ maxY: y + radiusY
2100
+ }
2101
+ }
2102
+
2103
+ if (
2104
+ Number.isFinite(Number(primitive?.x)) &&
2105
+ Number.isFinite(Number(primitive?.y)) &&
2106
+ Number.isFinite(Number(primitive?.radius))
2107
+ ) {
2108
+ const x = Number(primitive.x)
2109
+ const y = Number(primitive.y)
2110
+ const radiusX = Math.abs(Number(primitive.radius))
2111
+ const radiusY = Math.abs(Number(primitive.radiusY ?? radiusX))
2112
+
2113
+ return {
2114
+ minX: x - radiusX,
2115
+ minY: y - radiusY,
2116
+ maxX: x + radiusX,
2117
+ maxY: y + radiusY
2118
+ }
2119
+ }
2120
+
2121
+ return null
2122
+ }
2123
+
2124
+ /**
2125
+ * Resolves bounds for a point list.
2126
+ * @param {{ x: number, y: number }[]} points Points.
2127
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2128
+ */
2129
+ static #pointBounds(points) {
2130
+ const numericPoints = (points || [])
2131
+ .map((point) => ({
2132
+ x: Number(point?.x),
2133
+ y: Number(point?.y)
2134
+ }))
2135
+ .filter(
2136
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
2137
+ )
2138
+
2139
+ if (!numericPoints.length) {
2140
+ return null
2141
+ }
2142
+
2143
+ return {
2144
+ minX: Math.min(...numericPoints.map((point) => point.x)),
2145
+ minY: Math.min(...numericPoints.map((point) => point.y)),
2146
+ maxX: Math.max(...numericPoints.map((point) => point.x)),
2147
+ maxY: Math.max(...numericPoints.map((point) => point.y))
2148
+ }
2149
+ }
2150
+
2151
+ /**
2152
+ * Returns true when two unprojected coordinate bounds overlap.
2153
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Left bounds.
2154
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Right bounds.
2155
+ * @param {number} tolerance Coordinate tolerance.
2156
+ * @returns {boolean}
2157
+ */
2158
+ static #boundsIntersect(left, right, tolerance = 0) {
2159
+ return (
2160
+ left.maxX >= right.minX - tolerance &&
2161
+ left.minX <= right.maxX + tolerance &&
2162
+ left.maxY >= right.minY - tolerance &&
2163
+ left.minY <= right.maxY + tolerance
2164
+ )
2165
+ }
2166
+
1794
2167
  /**
1795
2168
  * Builds interleaved owner geometry so symbol-internal primitives preserve
1796
2169
  * their recovered Altium paint order instead of batching fills ahead of all
@@ -2131,7 +2504,7 @@ export class SchematicSvgRenderer {
2131
2504
  '" y2="' +
2132
2505
  formatNumber(projectSchematicY(sheetHeight, line.y2)) +
2133
2506
  '" stroke="' +
2134
- escapeHtml(SchematicSvgRenderer.#resolveSchematicLineColor(line)) +
2507
+ escapeHtml(SchematicLineColorResolver.resolveColor(line)) +
2135
2508
  '" stroke-width="' +
2136
2509
  formatNumber(
2137
2510
  SchematicSvgRenderer.#resolveSchematicLineWidth(line)
@@ -2162,26 +2535,6 @@ export class SchematicSvgRenderer {
2162
2535
  return Math.max(baseWidth * 3, 3)
2163
2536
  }
2164
2537
 
2165
- /**
2166
- * Resolves line stroke color, treating black electrical wires as themed
2167
- * schematic connectivity while leaving graphic linework source-colored.
2168
- * @param {{ color: string, ownerIndex?: string, isBus?: boolean, recordType?: string }} line
2169
- * @returns {string}
2170
- */
2171
- static #resolveSchematicLineColor(line) {
2172
- if (SchematicSvgRenderer.#isElectricalSchematicLine(line)) {
2173
- return SchematicColorResolver.resolveNonTextColor(
2174
- line.color,
2175
- '--schematic-default-ink-color'
2176
- )
2177
- }
2178
-
2179
- return SchematicColorResolver.resolveColor(
2180
- line.color,
2181
- '--schematic-default-ink-color'
2182
- )
2183
- }
2184
-
2185
2538
  /**
2186
2539
  * Returns SVG stroke attributes for one schematic line style.
2187
2540
  * @param {{ width: number, lineStyle?: number }} line
@@ -2261,6 +2614,7 @@ export class SchematicSvgRenderer {
2261
2614
  * @param {{ x: number, y: number, length: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
2262
2615
  * @param {number} index Stable primitive index.
2263
2616
  * @param {object | undefined} semanticContext Semantic lookup context.
2617
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2264
2618
  * @returns {string}
2265
2619
  */
2266
2620
  static #buildSchematicTextMarkup(
@@ -2269,7 +2623,8 @@ export class SchematicSvgRenderer {
2269
2623
  lines,
2270
2624
  pins,
2271
2625
  index = 0,
2272
- semanticContext = undefined
2626
+ semanticContext = undefined,
2627
+ ownerTextBodyBounds = new Map()
2273
2628
  ) {
2274
2629
  const matchedOwnerPin =
2275
2630
  SchematicOwnerPinLabelLayout.findExplicitOwnerPinLabelMatch(
@@ -2309,7 +2664,8 @@ export class SchematicSvgRenderer {
2309
2664
  text,
2310
2665
  sheetHeight,
2311
2666
  lines,
2312
- matchedOwnerPin
2667
+ matchedOwnerPin,
2668
+ ownerTextBodyBounds
2313
2669
  )
2314
2670
 
2315
2671
  return SchematicSvgRenderer.#appendSvgAttributes(
@@ -2344,13 +2700,15 @@ export class SchematicSvgRenderer {
2344
2700
  * @param {number} sheetHeight
2345
2701
  * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines
2346
2702
  * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin
2703
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2347
2704
  * @returns {{ x: number, y: number, anchor: 'start' | 'middle' | 'end' }}
2348
2705
  */
2349
2706
  static #resolveSchematicTextPlacement(
2350
2707
  text,
2351
2708
  sheetHeight,
2352
2709
  lines,
2353
- matchedOwnerPin
2710
+ matchedOwnerPin,
2711
+ ownerTextBodyBounds
2354
2712
  ) {
2355
2713
  const mirroredOwnerPinPlacement =
2356
2714
  SchematicOwnerPinLabelLayout.resolveMirroredOwnerPinLabelPlacement(
@@ -2358,6 +2716,7 @@ export class SchematicSvgRenderer {
2358
2716
  matchedOwnerPin
2359
2717
  )
2360
2718
  const sourceY = mirroredOwnerPinPlacement?.y ?? text.y
2719
+ const x = mirroredOwnerPinPlacement?.x ?? text.x
2361
2720
  const projectedY = projectSchematicY(sheetHeight, sourceY)
2362
2721
  const fontSize =
2363
2722
  SchematicTypography.resolveViewerFontSize(text.fontSize) || 0
@@ -2369,14 +2728,500 @@ export class SchematicSvgRenderer {
2369
2728
  fontSize,
2370
2729
  matchedOwnerPin
2371
2730
  )
2731
+ const ownerBodyBaselineOffset =
2732
+ SchematicSvgRenderer.#resolveOwnerBodyTextBaselineOffset(
2733
+ text,
2734
+ x,
2735
+ sourceY,
2736
+ fontSize,
2737
+ matchedOwnerPin,
2738
+ ownerTextBodyBounds
2739
+ )
2740
+
2741
+ const unclampedY = projectedY - baselineLift + ownerBodyBaselineOffset
2372
2742
 
2373
2743
  return {
2374
- x: mirroredOwnerPinPlacement?.x ?? text.x,
2375
- y: projectedY - baselineLift,
2744
+ x,
2745
+ y: SchematicSvgRenderer.#clampOwnerBodyTextBaseline(
2746
+ text,
2747
+ x,
2748
+ sourceY,
2749
+ unclampedY,
2750
+ sheetHeight,
2751
+ fontSize,
2752
+ matchedOwnerPin,
2753
+ ownerTextBodyBounds,
2754
+ lines
2755
+ ),
2376
2756
  anchor: text.anchor || 'start'
2377
2757
  }
2378
2758
  }
2379
2759
 
2760
+ /**
2761
+ * Keeps free schematic body annotations inside the owner body cell they
2762
+ * were authored over, including ownerless overlay text used by some
2763
+ * symbols for truth-table and switch-state labels.
2764
+ * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, rotation?: number }} text Text primitive.
2765
+ * @param {number} x Render X coordinate.
2766
+ * @param {number} sourceY Unprojected source Y coordinate.
2767
+ * @param {number} projectedY Candidate SVG baseline.
2768
+ * @param {number} sheetHeight Rendered sheet height.
2769
+ * @param {number} fontSize Viewer font size.
2770
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
2771
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2772
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
2773
+ * @returns {number}
2774
+ */
2775
+ static #clampOwnerBodyTextBaseline(
2776
+ text,
2777
+ x,
2778
+ sourceY,
2779
+ projectedY,
2780
+ sheetHeight,
2781
+ fontSize,
2782
+ matchedOwnerPin,
2783
+ ownerTextBodyBounds,
2784
+ lines
2785
+ ) {
2786
+ const bounds = SchematicSvgRenderer.#resolveOwnerBodyTextCellBounds(
2787
+ text,
2788
+ x,
2789
+ sourceY,
2790
+ fontSize,
2791
+ matchedOwnerPin,
2792
+ ownerTextBodyBounds,
2793
+ lines
2794
+ )
2795
+
2796
+ if (!bounds) {
2797
+ return projectedY
2798
+ }
2799
+
2800
+ return SchematicSvgRenderer.#clampTextBaselineToBounds(
2801
+ text,
2802
+ projectedY,
2803
+ bounds,
2804
+ sheetHeight,
2805
+ fontSize
2806
+ )
2807
+ }
2808
+
2809
+ /**
2810
+ * Resolves the owner body or internal owner body cell containing text.
2811
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number }} text Text primitive.
2812
+ * @param {number} x Render X coordinate.
2813
+ * @param {number} sourceY Unprojected source Y coordinate.
2814
+ * @param {number} fontSize Viewer font size.
2815
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
2816
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2817
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
2818
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2819
+ */
2820
+ static #resolveOwnerBodyTextCellBounds(
2821
+ text,
2822
+ x,
2823
+ sourceY,
2824
+ fontSize,
2825
+ matchedOwnerPin,
2826
+ ownerTextBodyBounds,
2827
+ lines
2828
+ ) {
2829
+ const bounds = SchematicSvgRenderer.#resolveOwnerBodyTextBounds(
2830
+ text,
2831
+ x,
2832
+ sourceY,
2833
+ fontSize,
2834
+ matchedOwnerPin,
2835
+ ownerTextBodyBounds
2836
+ )
2837
+
2838
+ if (!bounds) {
2839
+ return null
2840
+ }
2841
+
2842
+ return (
2843
+ SchematicSvgRenderer.#resolveOwnerBodyTextLineCellBounds(
2844
+ text,
2845
+ x,
2846
+ sourceY,
2847
+ fontSize,
2848
+ bounds,
2849
+ lines
2850
+ ) || bounds
2851
+ )
2852
+ }
2853
+
2854
+ /**
2855
+ * Resolves the owner body containing one free text primitive.
2856
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number }} text Text primitive.
2857
+ * @param {number} x Render X coordinate.
2858
+ * @param {number} sourceY Unprojected source Y coordinate.
2859
+ * @param {number} fontSize Viewer font size.
2860
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
2861
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2862
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2863
+ */
2864
+ static #resolveOwnerBodyTextBounds(
2865
+ text,
2866
+ x,
2867
+ sourceY,
2868
+ fontSize,
2869
+ matchedOwnerPin,
2870
+ ownerTextBodyBounds
2871
+ ) {
2872
+ if (matchedOwnerPin) return null
2873
+ if (text.recordType !== '4') return null
2874
+ if (SchematicSvgRenderer.#normalizeDegrees(text.rotation) !== 0) {
2875
+ return null
2876
+ }
2877
+ if (!Number.isFinite(fontSize) || fontSize <= 0) return null
2878
+
2879
+ const ownerIndex = String(text.ownerIndex || '').trim()
2880
+ const tolerance = Math.max(fontSize * 0.4, 1)
2881
+ const explicitBounds = ownerIndex
2882
+ ? ownerTextBodyBounds.get(ownerIndex)
2883
+ : null
2884
+
2885
+ if (
2886
+ explicitBounds &&
2887
+ SchematicSvgRenderer.#containsPointWithTolerance(
2888
+ explicitBounds,
2889
+ x,
2890
+ sourceY,
2891
+ tolerance
2892
+ )
2893
+ ) {
2894
+ return explicitBounds
2895
+ }
2896
+
2897
+ if (ownerIndex) {
2898
+ return null
2899
+ }
2900
+
2901
+ return SchematicSvgRenderer.#findContainingOwnerBodyBounds(
2902
+ ownerTextBodyBounds,
2903
+ x,
2904
+ sourceY,
2905
+ tolerance
2906
+ )
2907
+ }
2908
+
2909
+ /**
2910
+ * Finds the smallest owner body containing one ownerless overlay label.
2911
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2912
+ * @param {number} x Text X coordinate.
2913
+ * @param {number} y Text baseline Y coordinate.
2914
+ * @param {number} tolerance Coordinate tolerance.
2915
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2916
+ */
2917
+ static #findContainingOwnerBodyBounds(
2918
+ ownerTextBodyBounds,
2919
+ x,
2920
+ y,
2921
+ tolerance
2922
+ ) {
2923
+ let bestBounds = null
2924
+ let bestArea = Number.POSITIVE_INFINITY
2925
+
2926
+ for (const bounds of ownerTextBodyBounds.values()) {
2927
+ if (
2928
+ !SchematicSvgRenderer.#containsPointWithTolerance(
2929
+ bounds,
2930
+ x,
2931
+ y,
2932
+ tolerance
2933
+ )
2934
+ ) {
2935
+ continue
2936
+ }
2937
+
2938
+ const area =
2939
+ Math.max(Number(bounds.maxX) - Number(bounds.minX), 0) *
2940
+ Math.max(Number(bounds.maxY) - Number(bounds.minY), 0)
2941
+
2942
+ if (area < bestArea) {
2943
+ bestArea = area
2944
+ bestBounds = bounds
2945
+ }
2946
+ }
2947
+
2948
+ return bestBounds
2949
+ }
2950
+
2951
+ /**
2952
+ * Resolves the internal horizontal-line cell containing one text label.
2953
+ * @param {number} x Text X coordinate.
2954
+ * @param {number} y Text Y coordinate.
2955
+ * @param {number} fontSize Viewer font size.
2956
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Owner body bounds.
2957
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
2958
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2959
+ */
2960
+ static #resolveOwnerBodyTextLineCellBounds(
2961
+ text,
2962
+ x,
2963
+ y,
2964
+ fontSize,
2965
+ bounds,
2966
+ lines
2967
+ ) {
2968
+ const separators =
2969
+ SchematicSvgRenderer.#collectOwnerBodyHorizontalSeparators(
2970
+ x,
2971
+ fontSize,
2972
+ bounds,
2973
+ lines
2974
+ )
2975
+
2976
+ if (separators.length === 0) {
2977
+ return null
2978
+ }
2979
+
2980
+ const edges = [
2981
+ Number(bounds.minY),
2982
+ ...separators,
2983
+ Number(bounds.maxY)
2984
+ ].sort((left, right) => left - right)
2985
+ const tolerance = Math.max(fontSize * 0.4, 1)
2986
+ const descent =
2987
+ fontSize * SchematicSvgRenderer.#resolveBodyTextDescentRatio(text)
2988
+ const sampleY = y + (fontSize * BODY_TEXT_ASCENT_RATIO - descent) / 2
2989
+
2990
+ for (let index = 0; index < edges.length - 1; index += 1) {
2991
+ const minY = edges[index]
2992
+ const maxY = edges[index + 1]
2993
+
2994
+ if (sampleY >= minY && sampleY <= maxY) {
2995
+ return {
2996
+ minX: bounds.minX,
2997
+ maxX: bounds.maxX,
2998
+ minY,
2999
+ maxY
3000
+ }
3001
+ }
3002
+ }
3003
+
3004
+ for (let index = 0; index < edges.length - 1; index += 1) {
3005
+ const minY = edges[index]
3006
+ const maxY = edges[index + 1]
3007
+
3008
+ if (sampleY >= minY - tolerance && sampleY <= maxY + tolerance) {
3009
+ return {
3010
+ minX: bounds.minX,
3011
+ maxX: bounds.maxX,
3012
+ minY,
3013
+ maxY
3014
+ }
3015
+ }
3016
+ }
3017
+
3018
+ return null
3019
+ }
3020
+
3021
+ /**
3022
+ * Collects horizontal separators that behave like internal body cell rows.
3023
+ * @param {number} x Text X coordinate.
3024
+ * @param {number} fontSize Viewer font size.
3025
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Owner body bounds.
3026
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
3027
+ * @returns {number[]}
3028
+ */
3029
+ static #collectOwnerBodyHorizontalSeparators(x, fontSize, bounds, lines) {
3030
+ const tolerance = Math.max(fontSize * 0.4, 1)
3031
+ const bodyWidth = Math.max(Number(bounds.maxX) - Number(bounds.minX), 0)
3032
+ const minSpan = Math.max(
3033
+ bodyWidth * BODY_TEXT_MIN_SEPARATOR_SPAN_RATIO,
3034
+ fontSize * 1.5
3035
+ )
3036
+ const separators = new Set()
3037
+
3038
+ for (const line of lines || []) {
3039
+ const y1 = Number(line?.y1)
3040
+ const y2 = Number(line?.y2)
3041
+ const x1 = Number(line?.x1)
3042
+ const x2 = Number(line?.x2)
3043
+
3044
+ if (
3045
+ !Number.isFinite(x1) ||
3046
+ !Number.isFinite(x2) ||
3047
+ !Number.isFinite(y1) ||
3048
+ !Number.isFinite(y2) ||
3049
+ Math.abs(y1 - y2) > tolerance
3050
+ ) {
3051
+ continue
3052
+ }
3053
+
3054
+ const minX = Math.min(x1, x2)
3055
+ const maxX = Math.max(x1, x2)
3056
+ const span = maxX - minX
3057
+
3058
+ if (span < minSpan) continue
3059
+ if (y1 <= bounds.minY + tolerance) continue
3060
+ if (y1 >= bounds.maxY - tolerance) continue
3061
+ if (minX < bounds.minX - tolerance) continue
3062
+ if (maxX > bounds.maxX + tolerance) continue
3063
+ if (x < minX - tolerance || x > maxX + tolerance) continue
3064
+
3065
+ separators.add(Number(formatNumber(y1)))
3066
+ }
3067
+
3068
+ return [...separators].sort((left, right) => left - right)
3069
+ }
3070
+
3071
+ /**
3072
+ * Clamps a projected SVG text baseline so its visual box stays in bounds.
3073
+ * @param {{ text?: string, resolvedText?: string }} text Text primitive.
3074
+ * @param {number} y Candidate SVG baseline.
3075
+ * @param {{ minY: number, maxY: number }} bounds Unprojected source bounds.
3076
+ * @param {number} sheetHeight Rendered sheet height.
3077
+ * @param {number} fontSize Viewer font size.
3078
+ * @returns {number}
3079
+ */
3080
+ static #clampTextBaselineToBounds(text, y, bounds, sheetHeight, fontSize) {
3081
+ const top = projectSchematicY(sheetHeight, bounds.maxY)
3082
+ const bottom = projectSchematicY(sheetHeight, bounds.minY)
3083
+ const ascent = fontSize * BODY_TEXT_ASCENT_RATIO
3084
+ const descent =
3085
+ fontSize * SchematicSvgRenderer.#resolveBodyTextDescentRatio(text)
3086
+ const visualHeight = ascent + descent
3087
+
3088
+ if (bottom - top < visualHeight) {
3089
+ return (top + bottom) / 2 + (ascent - descent) / 2
3090
+ }
3091
+
3092
+ let resolvedY = y
3093
+ const visualTop = resolvedY - ascent
3094
+ if (visualTop < top) {
3095
+ resolvedY += top - visualTop
3096
+ }
3097
+
3098
+ const visualBottom = resolvedY + descent
3099
+ if (visualBottom > bottom) {
3100
+ resolvedY -= visualBottom - bottom
3101
+ }
3102
+
3103
+ return resolvedY
3104
+ }
3105
+
3106
+ /**
3107
+ * Estimates the descent needed for body-cell text clamping.
3108
+ * @param {{ text?: string, resolvedText?: string }} text Text primitive.
3109
+ * @returns {number}
3110
+ */
3111
+ static #resolveBodyTextDescentRatio(text) {
3112
+ const value = String(text?.resolvedText ?? text?.text ?? '')
3113
+
3114
+ return BODY_TEXT_DESCENDER_PATTERN.test(value)
3115
+ ? BODY_TEXT_DESCENT_RATIO
3116
+ : BODY_TEXT_FLAT_DESCENT_RATIO
3117
+ }
3118
+
3119
+ /**
3120
+ * Returns true when a point falls inside bounds with tolerance.
3121
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
3122
+ * @param {number} x X coordinate.
3123
+ * @param {number} y Y coordinate.
3124
+ * @param {number} tolerance Coordinate tolerance.
3125
+ * @returns {boolean}
3126
+ */
3127
+ static #containsPointWithTolerance(bounds, x, y, tolerance) {
3128
+ const numericX = Number(x)
3129
+ const numericY = Number(y)
3130
+
3131
+ return (
3132
+ Number.isFinite(numericX) &&
3133
+ Number.isFinite(numericY) &&
3134
+ numericX >= bounds.minX - tolerance &&
3135
+ numericX <= bounds.maxX + tolerance &&
3136
+ numericY >= bounds.minY - tolerance &&
3137
+ numericY <= bounds.maxY + tolerance
3138
+ )
3139
+ }
3140
+
3141
+ /**
3142
+ * Adds the SVG baseline correction for mirrored owner-local symbol text
3143
+ * authored with the native orientation that stores the top of the text run.
3144
+ * Baseline-oriented mirrored labels already use SVG-like y coordinates.
3145
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number, sourceOrientation?: number, isMirrored?: boolean }} text Text primitive.
3146
+ * @param {number} x Render X coordinate.
3147
+ * @param {number} sourceY Unprojected source Y coordinate.
3148
+ * @param {number} fontSize Viewer font size.
3149
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
3150
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
3151
+ * @returns {number}
3152
+ */
3153
+ static #resolveOwnerBodyTextBaselineOffset(
3154
+ text,
3155
+ x,
3156
+ sourceY,
3157
+ fontSize,
3158
+ matchedOwnerPin,
3159
+ ownerTextBodyBounds
3160
+ ) {
3161
+ if (
3162
+ !SchematicSvgRenderer.#isOwnerBodyBaselineText(
3163
+ text,
3164
+ x,
3165
+ sourceY,
3166
+ fontSize,
3167
+ matchedOwnerPin,
3168
+ ownerTextBodyBounds
3169
+ )
3170
+ ) {
3171
+ return 0
3172
+ }
3173
+
3174
+ return fontSize
3175
+ }
3176
+
3177
+ /**
3178
+ * Returns true when one text primitive is a top-origin mirrored owner-local
3179
+ * symbol annotation sitting inside its owner's drawable body.
3180
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number, sourceOrientation?: number, isMirrored?: boolean }} text Text primitive.
3181
+ * @param {number} x Render X coordinate.
3182
+ * @param {number} sourceY Unprojected source Y coordinate.
3183
+ * @param {number} fontSize Viewer font size.
3184
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
3185
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
3186
+ * @returns {boolean}
3187
+ */
3188
+ static #isOwnerBodyBaselineText(
3189
+ text,
3190
+ x,
3191
+ sourceY,
3192
+ fontSize,
3193
+ matchedOwnerPin,
3194
+ ownerTextBodyBounds
3195
+ ) {
3196
+ if (matchedOwnerPin) return false
3197
+ if (text.recordType !== '4') return false
3198
+ if (!text.isMirrored) return false
3199
+ if (Number(text.sourceOrientation ?? 0) !== 2) return false
3200
+ if (SchematicSvgRenderer.#normalizeDegrees(text.rotation) !== 0) {
3201
+ return false
3202
+ }
3203
+ if (!Number.isFinite(fontSize) || fontSize <= 0) return false
3204
+
3205
+ const ownerIndex = String(text.ownerIndex || '').trim()
3206
+ if (!ownerIndex) return false
3207
+
3208
+ const bounds = ownerTextBodyBounds.get(ownerIndex)
3209
+ if (!bounds) return false
3210
+
3211
+ const numericX = Number(x)
3212
+ const numericY = Number(sourceY)
3213
+ const tolerance = Math.max(fontSize * 0.4, 1)
3214
+
3215
+ return (
3216
+ Number.isFinite(numericX) &&
3217
+ Number.isFinite(numericY) &&
3218
+ numericX >= bounds.minX - tolerance &&
3219
+ numericX <= bounds.maxX + tolerance &&
3220
+ numericY >= bounds.minY - tolerance &&
3221
+ numericY <= bounds.maxY + tolerance
3222
+ )
3223
+ }
3224
+
2380
3225
  /**
2381
3226
  * Lifts large section headings clear of authored dash-dot frame baselines.
2382
3227
  * @param {{ x: number, y: number, ownerIndex?: string, recordType?: string, fontSize?: number, rotation?: number }} text
@@ -2475,11 +3320,18 @@ export class SchematicSvgRenderer {
2475
3320
 
2476
3321
  /**
2477
3322
  * Builds one schematic cross marker.
2478
- * @param {{ x: number, y: number, size: number, color: string }} cross
3323
+ * @param {{ x: number, y: number, size: number, color: string, symbolName?: string }} cross
2479
3324
  * @param {number} sheetHeight
2480
3325
  * @returns {string}
2481
3326
  */
2482
3327
  static #buildSchematicCrossMarkup(cross, sheetHeight) {
3328
+ if (String(cross.symbolName || '').toLowerCase() === 'checkbox') {
3329
+ return SchematicSvgRenderer.#buildSchematicCheckboxMarkup(
3330
+ cross,
3331
+ sheetHeight
3332
+ )
3333
+ }
3334
+
2483
3335
  const x = cross.x
2484
3336
  const y = projectSchematicY(sheetHeight, cross.y)
2485
3337
  const half = Math.max(Number(cross.size || 6), 4) / 2
@@ -2519,6 +3371,64 @@ export class SchematicSvgRenderer {
2519
3371
  )
2520
3372
  }
2521
3373
 
3374
+ /**
3375
+ * Builds one checkbox-shaped no-ERC marker.
3376
+ * @param {{ x: number, y: number, size: number, color: string }} cross
3377
+ * @param {number} sheetHeight
3378
+ * @returns {string}
3379
+ */
3380
+ static #buildSchematicCheckboxMarkup(cross, sheetHeight) {
3381
+ const x = Number(cross.x)
3382
+ const y = projectSchematicY(sheetHeight, cross.y)
3383
+ const size = Math.max(Number(cross.size || 6), 4)
3384
+ const left = x - 1
3385
+ const top = y - size - 7
3386
+ const color = escapeHtml(
3387
+ SchematicColorResolver.resolveColor(
3388
+ cross.color,
3389
+ '--schematic-alert-color'
3390
+ )
3391
+ )
3392
+
3393
+ return (
3394
+ '<g class="schematic-cross schematic-cross--checkbox"><line x1="' +
3395
+ formatNumber(x) +
3396
+ '" y1="' +
3397
+ formatNumber(y) +
3398
+ '" x2="' +
3399
+ formatNumber(left) +
3400
+ '" y2="' +
3401
+ formatNumber(top + size) +
3402
+ '" stroke="' +
3403
+ color +
3404
+ '" /><rect x="' +
3405
+ formatNumber(left) +
3406
+ '" y="' +
3407
+ formatNumber(top) +
3408
+ '" width="' +
3409
+ formatNumber(size) +
3410
+ '" height="' +
3411
+ formatNumber(size) +
3412
+ '" fill="none" stroke="' +
3413
+ color +
3414
+ '" /><polyline points="' +
3415
+ formatNumber(left + size * 0.2) +
3416
+ ',' +
3417
+ formatNumber(top + size * 0.5) +
3418
+ ' ' +
3419
+ formatNumber(left + size * 0.5) +
3420
+ ',' +
3421
+ formatNumber(top + size * 0.8) +
3422
+ ' ' +
3423
+ formatNumber(left + size * 0.9666666667) +
3424
+ ',' +
3425
+ formatNumber(top + size * 0.2) +
3426
+ '" fill="none" stroke="' +
3427
+ color +
3428
+ '" /></g>'
3429
+ )
3430
+ }
3431
+
2522
3432
  /**
2523
3433
  * Builds one authored schematic junction dot.
2524
3434
  * @param {{ x: number, y: number, color: string }} junction
@@ -2567,30 +3477,13 @@ export class SchematicSvgRenderer {
2567
3477
  static #resolveAuthoredSchematicJunctionColor(junction, lines) {
2568
3478
  const connectedLine = lines.find(
2569
3479
  (line) =>
2570
- SchematicSvgRenderer.#isElectricalSchematicLine(line) &&
3480
+ SchematicLineColorResolver.isElectricalLine(line) &&
2571
3481
  SchematicSvgRenderer.#schematicLineContainsPoint(line, junction)
2572
3482
  )
2573
3483
 
2574
3484
  return connectedLine?.color || junction.color
2575
3485
  }
2576
3486
 
2577
- /**
2578
- * Returns true when one normalized line can carry schematic net color.
2579
- * @param {{ ownerIndex?: string, isBus?: boolean, recordType?: string } | null | undefined} line
2580
- * @returns {boolean}
2581
- */
2582
- static #isElectricalSchematicLine(line) {
2583
- if (line?.ownerIndex || line?.isBus === true) {
2584
- return false
2585
- }
2586
-
2587
- if (!Object.prototype.hasOwnProperty.call(line || {}, 'recordType')) {
2588
- return true
2589
- }
2590
-
2591
- return !['6', '7', '26'].includes(String(line.recordType || ''))
2592
- }
2593
-
2594
3487
  /**
2595
3488
  * Returns true when one schematic line segment contains the given point.
2596
3489
  * @param {{ x1: number, y1: number, x2: number, y2: number }} line