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.
- package/AGENTS.md +15 -0
- package/NOTICE.md +31 -0
- package/README.md +267 -107
- package/docs/api.md +501 -96
- package/docs/capabilities.md +70 -0
- package/docs/migration/behaviors.md +45 -0
- package/docs/migration/parser.md +60 -0
- package/docs/migration/renderers.md +515 -0
- package/docs/migration/root.md +740 -0
- package/docs/migration.md +120 -0
- package/docs/model-format.md +175 -57
- package/docs/provenance.md +206 -0
- package/docs/release-notes-v1.1.0.md +154 -0
- package/docs/testing.md +117 -7
- package/package.json +31 -5
- package/spec/api-baseline-v1.0.17.json +1 -0
- package/spec/baseline-provenance-v1.0.17.json +7 -0
- package/spec/circuitjson-schema-snapshot.json +321 -0
- package/spec/circuitjson-schema-source.json +28 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +27 -20
- package/src/capabilities.mjs +1 -0
- package/src/core/ArchiveEntryPath.mjs +93 -0
- package/src/core/ArchiveLimits.mjs +31 -0
- package/src/core/ArchiveLimitsValidator.mjs +107 -0
- package/src/core/AsyncInputOwnership.mjs +56 -0
- package/src/core/AttachedValueLimits.mjs +67 -0
- package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
- package/src/core/CircuitJsonDocument.mjs +19 -61
- package/src/core/CircuitJsonElementTypes.mjs +10 -0
- package/src/core/CircuitJsonElementValidator.mjs +98 -847
- package/src/core/CircuitJsonIndexer.mjs +274 -194
- package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
- package/src/core/CircuitJsonParser.mjs +75 -13
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
- package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
- package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
- package/src/core/CircuitJsonUnitParsers.mjs +101 -0
- package/src/core/CircuitJsonUnits.mjs +13 -87
- package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
- package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
- package/src/core/CircuitJsonValidationUnits.mjs +6 -0
- package/src/core/ManufacturingService.mjs +323 -0
- package/src/core/Parser.mjs +343 -0
- package/src/core/ParserOptions.mjs +333 -0
- package/src/core/PcbBoundsSelectionModel.mjs +55 -19
- package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
- package/src/core/PcbInteractionIndex.mjs +368 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
- package/src/core/ProjectAsyncInputOwner.mjs +70 -0
- package/src/core/ProjectLoader.mjs +975 -0
- package/src/core/SimulationService.mjs +790 -0
- package/src/core/ToolkitCapabilities.mjs +130 -0
- package/src/core/ZipArchiveInspector.mjs +649 -0
- package/src/core/context/BinaryDataSnapshot.mjs +217 -0
- package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
- package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
- package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
- package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
- package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
- package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
- package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
- package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
- package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
- package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
- package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
- package/src/core/context/PcbSpatialIndex.mjs +701 -0
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
- package/src/core/context/StructuredDataSnapshot.mjs +683 -0
- package/src/core/contracts/DocumentResult.mjs +198 -0
- package/src/core/contracts/ProjectResult.mjs +96 -0
- package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
- package/src/core/contracts/ToolkitAsset.mjs +493 -0
- package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
- package/src/core/contracts/ToolkitError.mjs +176 -0
- package/src/core/contracts/ToolkitProgress.mjs +89 -0
- package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
- package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
- package/src/core/query/CircuitTraversal.mjs +343 -0
- package/src/core/query/ComponentGrouping.mjs +275 -0
- package/src/core/query/QueryNetlistBuilder.mjs +306 -0
- package/src/core/query/QueryService.mjs +435 -0
- package/src/core/query/RegexPattern.mjs +75 -0
- package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
- package/src/core/rendering/CanonicalBomRows.mjs +92 -0
- package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
- package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
- package/src/core/rendering/PcbRenderPlan.mjs +429 -0
- package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
- package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
- package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
- package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
- package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
- package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
- package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
- package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
- package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
- package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
- package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
- package/src/core/scene3d/Scene3dOptions.mjs +363 -0
- package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
- package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
- package/src/core/worker/ParserWorkerClient.mjs +997 -0
- package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
- package/src/core/worker/WorkerRequestData.mjs +623 -0
- package/src/core/worker/WorkerResponseData.mjs +905 -0
- package/src/extensions.mjs +37 -0
- package/src/index.mjs +14 -9
- package/src/interaction.mjs +2 -0
- package/src/manufacturing.mjs +1 -0
- package/src/parser.mjs +12 -2
- package/src/project.mjs +5 -0
- package/src/query.mjs +1 -0
- package/src/renderers.mjs +3 -29
- package/src/scene3d.mjs +3 -0
- package/src/simulation.mjs +1 -0
- package/src/styles/renderers.css +24 -0
- package/src/testing/ToolkitContractFixtures.mjs +124 -0
- package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
- package/src/testing/runToolkitContract.mjs +705 -0
- package/src/testing.mjs +3 -0
- package/src/ui/BomTableRenderer.mjs +304 -0
- package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
- package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
- package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
- package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
- package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
- package/src/ui/PcbSvgRenderer.mjs +41 -0
- package/src/ui/SafeSvgPaint.mjs +26 -0
- package/src/ui/SafeXmlText.mjs +60 -0
- package/src/ui/SchematicGeometryBounds.mjs +540 -0
- package/src/ui/SchematicSvgRenderer.mjs +110 -0
- package/src/ui/SchematicTableGeometry.mjs +319 -0
- package/src/ui/SchematicTextAnchor.mjs +55 -0
- package/src/ui/SchematicTextBounds.mjs +98 -0
- package/src/workers/parser.worker.mjs +59 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { CircuitJsonIndexer } from '../CircuitJsonIndexer.mjs'
|
|
2
|
+
import { CircuitJsonUnits } from '../CircuitJsonUnits.mjs'
|
|
3
|
+
|
|
4
|
+
const POINT_LOCATION_LEAF_SIZE = 12
|
|
5
|
+
const POINT_LOCATION_MAX_DEPTH = 12
|
|
6
|
+
const POINT_LOCATION_MAX_NODES = 4096
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keeps aggregate board geometry and per-outline surface heights together.
|
|
10
|
+
*/
|
|
11
|
+
export class Scene3dBoardModel {
|
|
12
|
+
#board
|
|
13
|
+
#outlines
|
|
14
|
+
#pointCache
|
|
15
|
+
#pointIndex
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates one board model from all CircuitJSON board rows.
|
|
19
|
+
* @param {object[]} boards Board elements.
|
|
20
|
+
* @param {object[]} cutouts Board cutout elements.
|
|
21
|
+
* @param {number} fallbackThickness Default millimeter thickness.
|
|
22
|
+
* @param {boolean} overrideThickness Whether the option overrides every board.
|
|
23
|
+
*/
|
|
24
|
+
constructor(boards, cutouts, fallbackThickness, overrideThickness) {
|
|
25
|
+
this.#outlines = boards.map((board) =>
|
|
26
|
+
Scene3dBoardModel.#outline(
|
|
27
|
+
board,
|
|
28
|
+
fallbackThickness,
|
|
29
|
+
overrideThickness
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
const entries = Scene3dBoardModel.#pointEntries(this.#outlines)
|
|
33
|
+
this.#pointCache = new Map()
|
|
34
|
+
this.#pointIndex = Scene3dBoardModel.#pointNode(
|
|
35
|
+
entries,
|
|
36
|
+
entries.length ? Scene3dBoardModel.#entryBounds(entries) : null,
|
|
37
|
+
0,
|
|
38
|
+
{ nodes: 0 }
|
|
39
|
+
)
|
|
40
|
+
const bounds = Scene3dBoardModel.#bounds(this.#outlines)
|
|
41
|
+
this.#board = {
|
|
42
|
+
id: this.#outlines[0]?.id || '',
|
|
43
|
+
center: {
|
|
44
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
45
|
+
y: (bounds.minY + bounds.maxY) / 2,
|
|
46
|
+
z: 0
|
|
47
|
+
},
|
|
48
|
+
size: {
|
|
49
|
+
x: bounds.maxX - bounds.minX,
|
|
50
|
+
y: bounds.maxY - bounds.minY,
|
|
51
|
+
z: this.#outlines.reduce(
|
|
52
|
+
(maximum, outline) => Math.max(maximum, outline.thickness),
|
|
53
|
+
this.#outlines.length ? 0 : fallbackThickness
|
|
54
|
+
)
|
|
55
|
+
},
|
|
56
|
+
material: this.#outlines[0]?.material || 'fr4',
|
|
57
|
+
solderMaskColor: this.#outlines[0]?.solderMaskColor || '',
|
|
58
|
+
silkscreenColor: this.#outlines[0]?.silkscreenColor || '',
|
|
59
|
+
outlines: this.#outlines,
|
|
60
|
+
cutouts: cutouts.map((element) =>
|
|
61
|
+
CircuitJsonIndexer.getElementId(element)
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns aggregate canonical board geometry.
|
|
68
|
+
* @returns {object} Canonical board record.
|
|
69
|
+
*/
|
|
70
|
+
get board() {
|
|
71
|
+
return this.#board
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves the containing board surface for one normalized point.
|
|
76
|
+
* @param {'top' | 'bottom'} side Board side.
|
|
77
|
+
* @param {{ x: number, y: number }} point Geometry point.
|
|
78
|
+
* @returns {number} Board surface Z coordinate.
|
|
79
|
+
*/
|
|
80
|
+
surfaceZ(side, point) {
|
|
81
|
+
const outline = this.#findOutline(point) || this.#outlines[0]
|
|
82
|
+
const thickness = outline?.thickness || this.#board.size.z
|
|
83
|
+
return side === 'bottom' ? -thickness / 2 : thickness / 2
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Queries the spatial hierarchy and preserves first-authored overlap order.
|
|
88
|
+
* @param {{ x: number, y: number }} point Geometry point.
|
|
89
|
+
* @returns {object | null} Containing outline.
|
|
90
|
+
*/
|
|
91
|
+
#findOutline(point) {
|
|
92
|
+
const key = `${point.x}\u0000${point.y}`
|
|
93
|
+
if (this.#pointCache.has(key)) return this.#pointCache.get(key)
|
|
94
|
+
const candidates = Scene3dBoardModel.#pointCandidates(
|
|
95
|
+
this.#pointIndex,
|
|
96
|
+
point
|
|
97
|
+
)
|
|
98
|
+
let outline = null
|
|
99
|
+
for (const entry of candidates) {
|
|
100
|
+
if (Scene3dBoardModel.#contains(entry.outline, point)) {
|
|
101
|
+
outline = entry.outline
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.#pointCache.set(key, outline)
|
|
106
|
+
return outline
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Maps one source board to canonical outline geometry.
|
|
111
|
+
* @param {object} board Board element.
|
|
112
|
+
* @param {number} fallbackThickness Default thickness.
|
|
113
|
+
* @param {boolean} overrideThickness Whether the default is an override.
|
|
114
|
+
* @returns {object} Canonical outline.
|
|
115
|
+
*/
|
|
116
|
+
static #outline(board, fallbackThickness, overrideThickness) {
|
|
117
|
+
const center = Scene3dBoardModel.#point2(
|
|
118
|
+
board.center || { x: board.x, y: board.y }
|
|
119
|
+
)
|
|
120
|
+
const sourceThickness = CircuitJsonUnits.optionalLength(board.thickness)
|
|
121
|
+
const thickness =
|
|
122
|
+
!overrideThickness &&
|
|
123
|
+
sourceThickness !== null &&
|
|
124
|
+
sourceThickness > 0 &&
|
|
125
|
+
sourceThickness <= 1000
|
|
126
|
+
? sourceThickness
|
|
127
|
+
: fallbackThickness
|
|
128
|
+
return {
|
|
129
|
+
id: CircuitJsonIndexer.getElementId(board),
|
|
130
|
+
center,
|
|
131
|
+
size: {
|
|
132
|
+
x: CircuitJsonUnits.length(board.width, 0),
|
|
133
|
+
y: CircuitJsonUnits.length(board.height, 0)
|
|
134
|
+
},
|
|
135
|
+
thickness,
|
|
136
|
+
material: String(board.material || 'fr4'),
|
|
137
|
+
solderMaskColor: String(board.solder_mask_color || ''),
|
|
138
|
+
silkscreenColor: String(board.silkscreen_color || ''),
|
|
139
|
+
points: Scene3dBoardModel.#points(board.points || board.outline)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Indexes only the first outline for each identical containment geometry.
|
|
145
|
+
* Later identical outlines can never win first-authored point location.
|
|
146
|
+
* @param {object[]} outlines Canonical authored outlines.
|
|
147
|
+
* @returns {{ outline: object, index: number, bounds: object }[]} Point-location entries.
|
|
148
|
+
*/
|
|
149
|
+
static #pointEntries(outlines) {
|
|
150
|
+
const entries = []
|
|
151
|
+
const authoredGeometry = new Set()
|
|
152
|
+
for (let index = 0; index < outlines.length; index += 1) {
|
|
153
|
+
const outline = outlines[index]
|
|
154
|
+
const geometry = Scene3dBoardModel.#geometryKey(outline)
|
|
155
|
+
if (authoredGeometry.has(geometry)) continue
|
|
156
|
+
authoredGeometry.add(geometry)
|
|
157
|
+
entries.push({
|
|
158
|
+
outline,
|
|
159
|
+
index,
|
|
160
|
+
bounds: Scene3dBoardModel.#outlineBounds(outline)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
return entries
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Creates a collision-free containment signature from normalized numbers.
|
|
168
|
+
* @param {object} outline Canonical outline.
|
|
169
|
+
* @returns {string} Exact rectangle or authored polygon signature.
|
|
170
|
+
*/
|
|
171
|
+
static #geometryKey(outline) {
|
|
172
|
+
if (!outline.points.length) {
|
|
173
|
+
return [
|
|
174
|
+
'rectangle',
|
|
175
|
+
outline.center.x,
|
|
176
|
+
outline.center.y,
|
|
177
|
+
outline.size.x,
|
|
178
|
+
outline.size.y
|
|
179
|
+
].join('\u0000')
|
|
180
|
+
}
|
|
181
|
+
const values = ['polygon', outline.points.length]
|
|
182
|
+
for (const point of outline.points) {
|
|
183
|
+
values.push(point.x, point.y)
|
|
184
|
+
}
|
|
185
|
+
return values.join('\u0000')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Builds an exact adaptive point-location quadtree over outline geometry.
|
|
190
|
+
* @param {{ outline: object, index: number, bounds: object }[]} entries Authored outline entries.
|
|
191
|
+
* @param {object | null} bounds Cell bounds.
|
|
192
|
+
* @param {number} depth Current tree depth.
|
|
193
|
+
* @param {{ nodes: number }} state Global node budget.
|
|
194
|
+
* @returns {object | null} Point-location node.
|
|
195
|
+
*/
|
|
196
|
+
static #pointNode(entries, bounds, depth, state) {
|
|
197
|
+
if (!bounds) return null
|
|
198
|
+
state.nodes += 1
|
|
199
|
+
return {
|
|
200
|
+
bounds,
|
|
201
|
+
entries,
|
|
202
|
+
depth,
|
|
203
|
+
state,
|
|
204
|
+
children: [null, null, null, null]
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Returns the exact candidate leaf for one query point.
|
|
210
|
+
* @param {object | null} node Point-location root.
|
|
211
|
+
* @param {{ x: number, y: number }} point Query point.
|
|
212
|
+
* @returns {{ outline: object, index: number, bounds: object }[]} Ordered candidates.
|
|
213
|
+
*/
|
|
214
|
+
static #pointCandidates(node, point) {
|
|
215
|
+
if (!node || !Scene3dBoardModel.#boundsContain(node.bounds, point)) {
|
|
216
|
+
return []
|
|
217
|
+
}
|
|
218
|
+
let current = node
|
|
219
|
+
while (
|
|
220
|
+
current.entries.length > POINT_LOCATION_LEAF_SIZE &&
|
|
221
|
+
current.depth < POINT_LOCATION_MAX_DEPTH &&
|
|
222
|
+
current.state.nodes < POINT_LOCATION_MAX_NODES &&
|
|
223
|
+
(current.bounds.minX !== current.bounds.maxX ||
|
|
224
|
+
current.bounds.minY !== current.bounds.maxY)
|
|
225
|
+
) {
|
|
226
|
+
const midX = (current.bounds.minX + current.bounds.maxX) / 2
|
|
227
|
+
const midY = (current.bounds.minY + current.bounds.maxY) / 2
|
|
228
|
+
const index = (point.x >= midX ? 1 : 0) + (point.y >= midY ? 2 : 0)
|
|
229
|
+
if (!current.children[index]) {
|
|
230
|
+
const cell = Scene3dBoardModel.#childBounds(
|
|
231
|
+
current.bounds,
|
|
232
|
+
midX,
|
|
233
|
+
midY,
|
|
234
|
+
index
|
|
235
|
+
)
|
|
236
|
+
const relevant = []
|
|
237
|
+
for (const entry of current.entries) {
|
|
238
|
+
const relation = Scene3dBoardModel.#cellRelation(
|
|
239
|
+
entry,
|
|
240
|
+
cell
|
|
241
|
+
)
|
|
242
|
+
if (relation === 'disjoint') continue
|
|
243
|
+
relevant.push(entry)
|
|
244
|
+
if (relation === 'contains') break
|
|
245
|
+
}
|
|
246
|
+
current.children[index] = Scene3dBoardModel.#pointNode(
|
|
247
|
+
relevant,
|
|
248
|
+
cell,
|
|
249
|
+
current.depth + 1,
|
|
250
|
+
current.state
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
current = current.children[index]
|
|
254
|
+
}
|
|
255
|
+
return current.entries
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Returns one lazily requested quadtree child cell.
|
|
260
|
+
* @param {object} bounds Parent bounds.
|
|
261
|
+
* @param {number} midX Parent X midpoint.
|
|
262
|
+
* @param {number} midY Parent Y midpoint.
|
|
263
|
+
* @param {number} index Child quadrant index.
|
|
264
|
+
* @returns {object} Child bounds.
|
|
265
|
+
*/
|
|
266
|
+
static #childBounds(bounds, midX, midY, index) {
|
|
267
|
+
const right = index % 2 === 1
|
|
268
|
+
const top = index >= 2
|
|
269
|
+
return {
|
|
270
|
+
minX: right ? midX : bounds.minX,
|
|
271
|
+
minY: top ? midY : bounds.minY,
|
|
272
|
+
maxX: right ? bounds.maxX : midX,
|
|
273
|
+
maxY: top ? bounds.maxY : midY
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Classifies one outline relative to a point-location cell.
|
|
279
|
+
* @param {{ outline: object, bounds: object }} entry Outline entry.
|
|
280
|
+
* @param {object} cell Cell bounds.
|
|
281
|
+
* @returns {'disjoint' | 'partial' | 'contains'} Exact conservative relation.
|
|
282
|
+
*/
|
|
283
|
+
static #cellRelation(entry, cell) {
|
|
284
|
+
if (!Scene3dBoardModel.#boundsOverlap(entry.bounds, cell)) {
|
|
285
|
+
return 'disjoint'
|
|
286
|
+
}
|
|
287
|
+
if (!entry.outline.points.length) {
|
|
288
|
+
return Scene3dBoardModel.#boundsContainBounds(entry.bounds, cell)
|
|
289
|
+
? 'contains'
|
|
290
|
+
: 'partial'
|
|
291
|
+
}
|
|
292
|
+
const corners = Scene3dBoardModel.#boundsCorners(cell)
|
|
293
|
+
let insideCount = 0
|
|
294
|
+
for (const corner of corners) {
|
|
295
|
+
if (Scene3dBoardModel.#contains(entry.outline, corner)) {
|
|
296
|
+
insideCount += 1
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const boundary = Scene3dBoardModel.#polygonIntersectsBounds(
|
|
300
|
+
entry.outline.points,
|
|
301
|
+
corners
|
|
302
|
+
)
|
|
303
|
+
if (insideCount === corners.length && !boundary) return 'contains'
|
|
304
|
+
if (insideCount || boundary) return 'partial'
|
|
305
|
+
for (const point of entry.outline.points) {
|
|
306
|
+
if (Scene3dBoardModel.#boundsContain(cell, point)) return 'partial'
|
|
307
|
+
}
|
|
308
|
+
return 'disjoint'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Computes aggregate bounds for indexed outlines.
|
|
313
|
+
* @param {{ bounds: object }[]} entries Indexed outlines.
|
|
314
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
|
|
315
|
+
*/
|
|
316
|
+
static #entryBounds(entries) {
|
|
317
|
+
const result = {
|
|
318
|
+
minX: Infinity,
|
|
319
|
+
minY: Infinity,
|
|
320
|
+
maxX: -Infinity,
|
|
321
|
+
maxY: -Infinity
|
|
322
|
+
}
|
|
323
|
+
for (const entry of entries) {
|
|
324
|
+
result.minX = Math.min(result.minX, entry.bounds.minX)
|
|
325
|
+
result.minY = Math.min(result.minY, entry.bounds.minY)
|
|
326
|
+
result.maxX = Math.max(result.maxX, entry.bounds.maxX)
|
|
327
|
+
result.maxY = Math.max(result.maxY, entry.bounds.maxY)
|
|
328
|
+
}
|
|
329
|
+
return result
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Tests whether two inclusive bounds overlap.
|
|
334
|
+
* @param {object} left First bounds.
|
|
335
|
+
* @param {object} right Second bounds.
|
|
336
|
+
* @returns {boolean} Whether the bounds overlap.
|
|
337
|
+
*/
|
|
338
|
+
static #boundsOverlap(left, right) {
|
|
339
|
+
return !(
|
|
340
|
+
left.maxX < right.minX ||
|
|
341
|
+
left.minX > right.maxX ||
|
|
342
|
+
left.maxY < right.minY ||
|
|
343
|
+
left.minY > right.maxY
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Tests whether outer bounds fully contain inner bounds.
|
|
349
|
+
* @param {object} outer Outer bounds.
|
|
350
|
+
* @param {object} inner Inner bounds.
|
|
351
|
+
* @returns {boolean} Whether the inner bounds are contained.
|
|
352
|
+
*/
|
|
353
|
+
static #boundsContainBounds(outer, inner) {
|
|
354
|
+
return (
|
|
355
|
+
inner.minX >= outer.minX &&
|
|
356
|
+
inner.maxX <= outer.maxX &&
|
|
357
|
+
inner.minY >= outer.minY &&
|
|
358
|
+
inner.maxY <= outer.maxY
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Returns cell corners in closed perimeter order.
|
|
364
|
+
* @param {object} bounds Cell bounds.
|
|
365
|
+
* @returns {{ x: number, y: number }[]} Cell corners.
|
|
366
|
+
*/
|
|
367
|
+
static #boundsCorners(bounds) {
|
|
368
|
+
return [
|
|
369
|
+
{ x: bounds.minX, y: bounds.minY },
|
|
370
|
+
{ x: bounds.maxX, y: bounds.minY },
|
|
371
|
+
{ x: bounds.maxX, y: bounds.maxY },
|
|
372
|
+
{ x: bounds.minX, y: bounds.maxY }
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Tests whether any polygon edge crosses or touches a cell boundary.
|
|
378
|
+
* @param {{ x: number, y: number }[]} points Polygon points.
|
|
379
|
+
* @param {{ x: number, y: number }[]} corners Cell corners.
|
|
380
|
+
* @returns {boolean} Whether boundaries intersect.
|
|
381
|
+
*/
|
|
382
|
+
static #polygonIntersectsBounds(points, corners) {
|
|
383
|
+
for (let pointIndex = 0; pointIndex < points.length; pointIndex += 1) {
|
|
384
|
+
const a = points[pointIndex]
|
|
385
|
+
const b = points[(pointIndex + 1) % points.length]
|
|
386
|
+
for (
|
|
387
|
+
let edgeIndex = 0;
|
|
388
|
+
edgeIndex < corners.length;
|
|
389
|
+
edgeIndex += 1
|
|
390
|
+
) {
|
|
391
|
+
const c = corners[edgeIndex]
|
|
392
|
+
const d = corners[(edgeIndex + 1) % corners.length]
|
|
393
|
+
if (Scene3dBoardModel.#segmentsIntersect(a, b, c, d)) {
|
|
394
|
+
return true
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Tests two closed line segments for intersection.
|
|
403
|
+
* @param {{ x: number, y: number }} a First segment start.
|
|
404
|
+
* @param {{ x: number, y: number }} b First segment end.
|
|
405
|
+
* @param {{ x: number, y: number }} c Second segment start.
|
|
406
|
+
* @param {{ x: number, y: number }} d Second segment end.
|
|
407
|
+
* @returns {boolean} Whether the segments intersect.
|
|
408
|
+
*/
|
|
409
|
+
static #segmentsIntersect(a, b, c, d) {
|
|
410
|
+
const abC = Scene3dBoardModel.#orientation(a, b, c)
|
|
411
|
+
const abD = Scene3dBoardModel.#orientation(a, b, d)
|
|
412
|
+
const cdA = Scene3dBoardModel.#orientation(c, d, a)
|
|
413
|
+
const cdB = Scene3dBoardModel.#orientation(c, d, b)
|
|
414
|
+
if (abC * abD < 0 && cdA * cdB < 0) return true
|
|
415
|
+
return (
|
|
416
|
+
(abC === 0 && Scene3dBoardModel.#onSegment(c, a, b)) ||
|
|
417
|
+
(abD === 0 && Scene3dBoardModel.#onSegment(d, a, b)) ||
|
|
418
|
+
(cdA === 0 && Scene3dBoardModel.#onSegment(a, c, d)) ||
|
|
419
|
+
(cdB === 0 && Scene3dBoardModel.#onSegment(b, c, d))
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Returns a tolerance-normalized orientation cross product.
|
|
425
|
+
* @param {{ x: number, y: number }} a Segment start.
|
|
426
|
+
* @param {{ x: number, y: number }} b Segment end.
|
|
427
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
428
|
+
* @returns {number} Signed orientation or zero.
|
|
429
|
+
*/
|
|
430
|
+
static #orientation(a, b, point) {
|
|
431
|
+
const cross =
|
|
432
|
+
(b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
|
|
433
|
+
return Math.abs(cross) <= 1e-9 ? 0 : cross
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Tests a point against one axis-aligned bounds record.
|
|
438
|
+
* @param {object} bounds Bounds record.
|
|
439
|
+
* @param {{ x: number, y: number }} point Query point.
|
|
440
|
+
* @returns {boolean} Whether the bounds contain the point.
|
|
441
|
+
*/
|
|
442
|
+
static #boundsContain(bounds, point) {
|
|
443
|
+
return (
|
|
444
|
+
point.x >= bounds.minX &&
|
|
445
|
+
point.x <= bounds.maxX &&
|
|
446
|
+
point.y >= bounds.minY &&
|
|
447
|
+
point.y <= bounds.maxY
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Computes exact axis-aligned bounds for one canonical outline.
|
|
453
|
+
* @param {object} outline Canonical outline.
|
|
454
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
|
|
455
|
+
*/
|
|
456
|
+
static #outlineBounds(outline) {
|
|
457
|
+
if (!outline.points.length) {
|
|
458
|
+
return {
|
|
459
|
+
minX: outline.center.x - outline.size.x / 2,
|
|
460
|
+
minY: outline.center.y - outline.size.y / 2,
|
|
461
|
+
maxX: outline.center.x + outline.size.x / 2,
|
|
462
|
+
maxY: outline.center.y + outline.size.y / 2
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return Scene3dBoardModel.#entryBounds(
|
|
466
|
+
outline.points.map((point) => ({
|
|
467
|
+
bounds: {
|
|
468
|
+
minX: point.x,
|
|
469
|
+
minY: point.y,
|
|
470
|
+
maxX: point.x,
|
|
471
|
+
maxY: point.y
|
|
472
|
+
}
|
|
473
|
+
}))
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Tests whether a point lies within an outline polygon or rectangle.
|
|
479
|
+
* @param {object} outline Canonical outline.
|
|
480
|
+
* @param {{ x: number, y: number }} point Point.
|
|
481
|
+
* @returns {boolean} Whether the outline contains the point.
|
|
482
|
+
*/
|
|
483
|
+
static #contains(outline, point) {
|
|
484
|
+
if (!outline.points.length) {
|
|
485
|
+
return (
|
|
486
|
+
point.x >= outline.center.x - outline.size.x / 2 &&
|
|
487
|
+
point.x <= outline.center.x + outline.size.x / 2 &&
|
|
488
|
+
point.y >= outline.center.y - outline.size.y / 2 &&
|
|
489
|
+
point.y <= outline.center.y + outline.size.y / 2
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
let inside = false
|
|
493
|
+
for (
|
|
494
|
+
let current = 0, previous = outline.points.length - 1;
|
|
495
|
+
current < outline.points.length;
|
|
496
|
+
previous = current, current += 1
|
|
497
|
+
) {
|
|
498
|
+
const a = outline.points[current]
|
|
499
|
+
const b = outline.points[previous]
|
|
500
|
+
if (Scene3dBoardModel.#onSegment(point, a, b)) return true
|
|
501
|
+
const crosses =
|
|
502
|
+
a.y > point.y !== b.y > point.y &&
|
|
503
|
+
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
|
|
504
|
+
if (crosses) inside = !inside
|
|
505
|
+
}
|
|
506
|
+
return inside
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Tests whether a point lies on one polygon edge.
|
|
511
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
512
|
+
* @param {{ x: number, y: number }} a Segment start.
|
|
513
|
+
* @param {{ x: number, y: number }} b Segment end.
|
|
514
|
+
* @returns {boolean} Whether the point is on the segment.
|
|
515
|
+
*/
|
|
516
|
+
static #onSegment(point, a, b) {
|
|
517
|
+
const cross =
|
|
518
|
+
(point.y - a.y) * (b.x - a.x) - (point.x - a.x) * (b.y - a.y)
|
|
519
|
+
if (Math.abs(cross) > 1e-9) return false
|
|
520
|
+
return (
|
|
521
|
+
point.x >= Math.min(a.x, b.x) &&
|
|
522
|
+
point.x <= Math.max(a.x, b.x) &&
|
|
523
|
+
point.y >= Math.min(a.y, b.y) &&
|
|
524
|
+
point.y <= Math.max(a.y, b.y)
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Computes aggregate bounds from outline rectangles or points.
|
|
530
|
+
* @param {object[]} outlines Canonical outlines.
|
|
531
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
|
|
532
|
+
*/
|
|
533
|
+
static #bounds(outlines) {
|
|
534
|
+
if (!outlines.length) {
|
|
535
|
+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 }
|
|
536
|
+
}
|
|
537
|
+
const bounds = {
|
|
538
|
+
minX: Infinity,
|
|
539
|
+
minY: Infinity,
|
|
540
|
+
maxX: -Infinity,
|
|
541
|
+
maxY: -Infinity
|
|
542
|
+
}
|
|
543
|
+
for (const outline of outlines) {
|
|
544
|
+
const points = outline.points.length
|
|
545
|
+
? outline.points
|
|
546
|
+
: [
|
|
547
|
+
{
|
|
548
|
+
x: outline.center.x - outline.size.x / 2,
|
|
549
|
+
y: outline.center.y - outline.size.y / 2
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
x: outline.center.x + outline.size.x / 2,
|
|
553
|
+
y: outline.center.y + outline.size.y / 2
|
|
554
|
+
}
|
|
555
|
+
]
|
|
556
|
+
for (const point of points) {
|
|
557
|
+
bounds.minX = Math.min(bounds.minX, point.x)
|
|
558
|
+
bounds.minY = Math.min(bounds.minY, point.y)
|
|
559
|
+
bounds.maxX = Math.max(bounds.maxX, point.x)
|
|
560
|
+
bounds.maxY = Math.max(bounds.maxY, point.y)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return bounds
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Normalizes a point without accepting non-object input.
|
|
568
|
+
* @param {unknown} point Point candidate.
|
|
569
|
+
* @returns {{ x: number, y: number }} Canonical point.
|
|
570
|
+
*/
|
|
571
|
+
static #point2(point) {
|
|
572
|
+
return {
|
|
573
|
+
x: CircuitJsonUnits.length(point?.x, 0),
|
|
574
|
+
y: CircuitJsonUnits.length(point?.y, 0)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Normalizes a board polygon.
|
|
580
|
+
* @param {unknown} points Point candidates.
|
|
581
|
+
* @returns {{ x: number, y: number }[]} Canonical points.
|
|
582
|
+
*/
|
|
583
|
+
static #points(points) {
|
|
584
|
+
if (!Array.isArray(points)) return []
|
|
585
|
+
const result = []
|
|
586
|
+
for (const point of points) {
|
|
587
|
+
if (point && typeof point === 'object') {
|
|
588
|
+
result.push(Scene3dBoardModel.#point2(point))
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return result
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
Object.freeze(Scene3dBoardModel.prototype)
|
|
596
|
+
Object.freeze(Scene3dBoardModel)
|