@tscircuit/cli 0.1.2287 → 0.1.2288
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/dist/cli/main.js +3950 -7
- package/dist/cli/python/flux.py +99 -0
- package/dist/cli/python/mesh.py +485 -0
- package/dist/cli/python/mesh_multilayer.py +376 -0
- package/dist/cli/python/requirements.txt +4 -0
- package/dist/cli/python/sample.py +352 -0
- package/dist/cli/python/surface_sample.py +88 -0
- package/dist/lib/index.js +10 -6
- package/package.json +11 -7
package/dist/cli/main.js
CHANGED
|
@@ -172192,6 +172192,3645 @@ var require_emoji_regex = __commonJS2(function(exports2, module2) {
|
|
|
172192
172192
|
};
|
|
172193
172193
|
});
|
|
172194
172194
|
|
|
172195
|
+
// node_modules/simulate-return-current/dist/index.js
|
|
172196
|
+
import { BaseSolver as BaseSolver43 } from "@tscircuit/solver-utils";
|
|
172197
|
+
import {
|
|
172198
|
+
applyToPoint as applyToPoint49,
|
|
172199
|
+
compose as compose19,
|
|
172200
|
+
rotateDEG as rotateDEG4,
|
|
172201
|
+
translate as translate19
|
|
172202
|
+
} from "transformation-matrix";
|
|
172203
|
+
import { applyToPoint as applyToPoint214, compose as compose24, scale as scale13, translate as translate24 } from "transformation-matrix";
|
|
172204
|
+
import {
|
|
172205
|
+
any_circuit_element as any_circuit_element3,
|
|
172206
|
+
current as current4,
|
|
172207
|
+
getZodPrefixedIdWithDefault as getZodPrefixedIdWithDefault3,
|
|
172208
|
+
point as point11,
|
|
172209
|
+
pcb_silkscreen_text as pcb_silkscreen_text3,
|
|
172210
|
+
simulation_return_current_excitation
|
|
172211
|
+
} from "circuit-json";
|
|
172212
|
+
import { z as z226 } from "zod";
|
|
172213
|
+
import {
|
|
172214
|
+
simulation_experiment as simulation_experiment3,
|
|
172215
|
+
simulation_return_current_excitation as simulation_return_current_excitation2
|
|
172216
|
+
} from "circuit-json";
|
|
172217
|
+
import {
|
|
172218
|
+
applyToPoint as applyToPoint310,
|
|
172219
|
+
compose as compose34,
|
|
172220
|
+
rotateDEG as rotateDEG22,
|
|
172221
|
+
translate as translate34
|
|
172222
|
+
} from "transformation-matrix";
|
|
172223
|
+
import { z as z227 } from "zod";
|
|
172224
|
+
function rectangleOutline(rect) {
|
|
172225
|
+
if (![
|
|
172226
|
+
rect.center.x,
|
|
172227
|
+
rect.center.y,
|
|
172228
|
+
rect.rotation ?? 0,
|
|
172229
|
+
rect.width,
|
|
172230
|
+
rect.height
|
|
172231
|
+
].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0)
|
|
172232
|
+
throw new Error("Rectangles need finite coordinates, rotation, and positive dimensions");
|
|
172233
|
+
const localToWorld = compose19(translate19(rect.center.x, rect.center.y), rotateDEG4(rect.rotation ?? 0));
|
|
172234
|
+
return [
|
|
172235
|
+
{ x: -rect.width / 2, y: -rect.height / 2 },
|
|
172236
|
+
{ x: rect.width / 2, y: -rect.height / 2 },
|
|
172237
|
+
{ x: rect.width / 2, y: rect.height / 2 },
|
|
172238
|
+
{ x: -rect.width / 2, y: rect.height / 2 }
|
|
172239
|
+
].map((corner) => applyToPoint49(localToWorld, corner));
|
|
172240
|
+
}
|
|
172241
|
+
function flattenRing(vertices) {
|
|
172242
|
+
const outline = [];
|
|
172243
|
+
for (let vertexIndex = 0;vertexIndex < vertices.length; vertexIndex++) {
|
|
172244
|
+
const start = vertices[vertexIndex];
|
|
172245
|
+
const end = vertices[(vertexIndex + 1) % vertices.length];
|
|
172246
|
+
outline.push({ x: start.x, y: start.y });
|
|
172247
|
+
const bulge = start.bulge ?? 0;
|
|
172248
|
+
if (Math.abs(bulge) < 0.000000000001)
|
|
172249
|
+
continue;
|
|
172250
|
+
const dx = end.x - start.x;
|
|
172251
|
+
const dy = end.y - start.y;
|
|
172252
|
+
const center = {
|
|
172253
|
+
x: (start.x + end.x) / 2 - dy * (1 - bulge * bulge) / (4 * bulge),
|
|
172254
|
+
y: (start.y + end.y) / 2 + dx * (1 - bulge * bulge) / (4 * bulge)
|
|
172255
|
+
};
|
|
172256
|
+
const sweep = 4 * Math.atan(bulge);
|
|
172257
|
+
const radius = Math.hypot(start.x - center.x, start.y - center.y);
|
|
172258
|
+
const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
|
|
172259
|
+
const steps = Math.ceil(Math.abs(sweep) / (Math.PI / 60));
|
|
172260
|
+
for (let arcIndex = 1;arcIndex < steps; arcIndex++) {
|
|
172261
|
+
const angle = startAngle + sweep * arcIndex / steps;
|
|
172262
|
+
outline.push({
|
|
172263
|
+
x: center.x + radius * Math.cos(angle),
|
|
172264
|
+
y: center.y + radius * Math.sin(angle)
|
|
172265
|
+
});
|
|
172266
|
+
}
|
|
172267
|
+
}
|
|
172268
|
+
return outline;
|
|
172269
|
+
}
|
|
172270
|
+
function pointInPolygon(point, outline) {
|
|
172271
|
+
let inside = false;
|
|
172272
|
+
for (let cornerIndex = 0;cornerIndex < outline.length; cornerIndex++) {
|
|
172273
|
+
const start = outline[cornerIndex];
|
|
172274
|
+
const end = outline[(cornerIndex + 1) % outline.length];
|
|
172275
|
+
const cross = (point.x - start.x) * (end.y - start.y) - (point.y - start.y) * (end.x - start.x);
|
|
172276
|
+
if (Math.abs(cross) < 0.000000001 && point.x >= Math.min(start.x, end.x) - 0.000000001 && point.x <= Math.max(start.x, end.x) + 0.000000001 && point.y >= Math.min(start.y, end.y) - 0.000000001 && point.y <= Math.max(start.y, end.y) + 0.000000001)
|
|
172277
|
+
return true;
|
|
172278
|
+
if (start.y > point.y !== end.y > point.y && point.x < start.x + (point.y - start.y) * (end.x - start.x) / (end.y - start.y))
|
|
172279
|
+
inside = !inside;
|
|
172280
|
+
}
|
|
172281
|
+
return inside;
|
|
172282
|
+
}
|
|
172283
|
+
function pourRegion(pour) {
|
|
172284
|
+
if (pour.shape === "rect")
|
|
172285
|
+
return { outer: rectangleOutline(pour), holes: [] };
|
|
172286
|
+
if (pour.shape === "polygon")
|
|
172287
|
+
return { outer: pour.points, holes: [] };
|
|
172288
|
+
return {
|
|
172289
|
+
outer: flattenRing(pour.brep_shape.outer_ring.vertices),
|
|
172290
|
+
holes: pour.brep_shape.inner_rings.map((ring) => flattenRing(ring.vertices))
|
|
172291
|
+
};
|
|
172292
|
+
}
|
|
172293
|
+
function normalizeDisplayOffsets(element) {
|
|
172294
|
+
if (element.type === "pcb_hole" && element.pcb_component_id === null)
|
|
172295
|
+
return { ...element, pcb_component_id: undefined };
|
|
172296
|
+
if (element.type === "pcb_plated_hole" && element.shape === "circular_hole_with_rect_pad")
|
|
172297
|
+
return {
|
|
172298
|
+
...element,
|
|
172299
|
+
hole_shape: element.hole_shape ?? "circle",
|
|
172300
|
+
pad_shape: element.pad_shape ?? "rect"
|
|
172301
|
+
};
|
|
172302
|
+
if (element.type !== "pcb_component" && element.type !== "pcb_board")
|
|
172303
|
+
return element;
|
|
172304
|
+
return {
|
|
172305
|
+
...element,
|
|
172306
|
+
display_offset_x: typeof element.display_offset_x === "number" ? String(element.display_offset_x) : element.display_offset_x,
|
|
172307
|
+
display_offset_y: typeof element.display_offset_y === "number" ? String(element.display_offset_y) : element.display_offset_y
|
|
172308
|
+
};
|
|
172309
|
+
}
|
|
172310
|
+
function parseReturnCurrentCircuitJson(input) {
|
|
172311
|
+
const elements = z226.array(z226.object({ type: z226.string() }).passthrough()).parse(input);
|
|
172312
|
+
return elements.map((element) => {
|
|
172313
|
+
if (element.type === "pcb_silkscreen_text" && element.pcb_component_id === null)
|
|
172314
|
+
return pcb_silkscreen_text3.extend({ pcb_component_id: z226.null() }).parse(element);
|
|
172315
|
+
if (element.type !== "simulation_return_current_excitation")
|
|
172316
|
+
return {
|
|
172317
|
+
...element,
|
|
172318
|
+
...any_circuit_element3.parse(normalizeDisplayOffsets(element))
|
|
172319
|
+
};
|
|
172320
|
+
if (element.simulation_experiment_id !== undefined)
|
|
172321
|
+
return simulation_return_current_excitation.parse(element);
|
|
172322
|
+
const excitation = excitationSchema.parse(element);
|
|
172323
|
+
return excitation;
|
|
172324
|
+
});
|
|
172325
|
+
}
|
|
172326
|
+
function normalizeLayeredRoute(trace) {
|
|
172327
|
+
const route = [];
|
|
172328
|
+
for (const [index, point2] of trace.route.entries()) {
|
|
172329
|
+
if (point2.route_type === "wire") {
|
|
172330
|
+
route.push(point2);
|
|
172331
|
+
continue;
|
|
172332
|
+
}
|
|
172333
|
+
if (point2.route_type !== "via")
|
|
172334
|
+
throw new Error("Through-pad route points are not supported");
|
|
172335
|
+
const previous = route.at(-1);
|
|
172336
|
+
const next = trace.route[index + 1];
|
|
172337
|
+
if (!previous || previous.route_type !== "wire" || !next)
|
|
172338
|
+
throw new Error("Via needs signal route endpoints on both sides");
|
|
172339
|
+
if (previous.layer !== point2.from_layer)
|
|
172340
|
+
throw new Error("Via from_layer does not match preceding wire layer");
|
|
172341
|
+
if (previous.x !== point2.x || previous.y !== point2.y)
|
|
172342
|
+
route.push({
|
|
172343
|
+
route_type: "wire",
|
|
172344
|
+
x: point2.x,
|
|
172345
|
+
y: point2.y,
|
|
172346
|
+
width: previous.width,
|
|
172347
|
+
layer: point2.from_layer
|
|
172348
|
+
});
|
|
172349
|
+
route.push(point2);
|
|
172350
|
+
if (next.route_type !== "wire" || next.x !== point2.x || next.y !== point2.y)
|
|
172351
|
+
route.push({
|
|
172352
|
+
route_type: "wire",
|
|
172353
|
+
x: point2.x,
|
|
172354
|
+
y: point2.y,
|
|
172355
|
+
width: next.route_type === "wire" ? next.width : previous.width,
|
|
172356
|
+
layer: point2.to_layer
|
|
172357
|
+
});
|
|
172358
|
+
}
|
|
172359
|
+
return { ...trace, route };
|
|
172360
|
+
}
|
|
172361
|
+
function portNetIds(circuitJson, sourcePortId) {
|
|
172362
|
+
const ports = new Set([sourcePortId]);
|
|
172363
|
+
const nets = new Set;
|
|
172364
|
+
const pending = circuitJson.filter((element) => element.type === "source_trace");
|
|
172365
|
+
let changed = true;
|
|
172366
|
+
while (changed) {
|
|
172367
|
+
changed = false;
|
|
172368
|
+
for (const trace of pending) {
|
|
172369
|
+
if (!trace.connected_source_port_ids.some((id) => ports.has(id)) && !trace.connected_source_net_ids.some((id) => nets.has(id)))
|
|
172370
|
+
continue;
|
|
172371
|
+
for (const id of trace.connected_source_port_ids)
|
|
172372
|
+
if (!ports.has(id)) {
|
|
172373
|
+
ports.add(id);
|
|
172374
|
+
changed = true;
|
|
172375
|
+
}
|
|
172376
|
+
for (const id of trace.connected_source_net_ids)
|
|
172377
|
+
if (!nets.has(id)) {
|
|
172378
|
+
nets.add(id);
|
|
172379
|
+
changed = true;
|
|
172380
|
+
}
|
|
172381
|
+
}
|
|
172382
|
+
}
|
|
172383
|
+
return [...nets];
|
|
172384
|
+
}
|
|
172385
|
+
function assertUniqueIds(circuitJson) {
|
|
172386
|
+
const ids = new Set;
|
|
172387
|
+
for (const element of circuitJson) {
|
|
172388
|
+
const key = `${element.type}_id`;
|
|
172389
|
+
const value = element[key];
|
|
172390
|
+
if (typeof value !== "string")
|
|
172391
|
+
continue;
|
|
172392
|
+
if (ids.has(value))
|
|
172393
|
+
throw new Error(`Duplicate circuit-json ID: ${value}`);
|
|
172394
|
+
ids.add(value);
|
|
172395
|
+
}
|
|
172396
|
+
}
|
|
172397
|
+
function validateContact(circuitJson, contact, groundId) {
|
|
172398
|
+
if (contact.contact_type === "pcb_port") {
|
|
172399
|
+
const port = circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === contact.pcb_port_id);
|
|
172400
|
+
if (!port || port.type !== "pcb_port" || !port.layers.includes(contact.layer) || Math.hypot(port.x - contact.x, port.y - contact.y) > 0.000001)
|
|
172401
|
+
throw new Error(`Return PCB port ${contact.pcb_port_id} does not match contact location/layer`);
|
|
172402
|
+
if (!portNetIds(circuitJson, port.source_port_id).includes(groundId))
|
|
172403
|
+
throw new Error(`Return PCB port ${contact.pcb_port_id} is not on the selected ground net`);
|
|
172404
|
+
} else if (contact.contact_type === "pcb_via") {
|
|
172405
|
+
const via = circuitJson.find((e) => e.type === "pcb_via" && e.pcb_via_id === contact.pcb_via_id);
|
|
172406
|
+
if (!via || via.type !== "pcb_via" || !via.layers.includes(contact.layer) || Math.hypot(via.x - contact.x, via.y - contact.y) > 0.000001)
|
|
172407
|
+
throw new Error(`Return PCB via ${contact.pcb_via_id} does not match contact location/layer`);
|
|
172408
|
+
if (via.source_net_id !== groundId)
|
|
172409
|
+
throw new Error(`Return PCB via ${contact.pcb_via_id} needs explicit selected-ground ownership`);
|
|
172410
|
+
} else {
|
|
172411
|
+
const pour = circuitJson.find((e) => e.type === "pcb_copper_pour" && e.pcb_copper_pour_id === contact.pcb_copper_pour_id);
|
|
172412
|
+
if (!pour || pour.type !== "pcb_copper_pour" || pour.layer !== contact.layer || pour.source_net_id !== groundId)
|
|
172413
|
+
throw new Error(`Return copper pour ${contact.pcb_copper_pour_id} is missing or not on the selected ground/layer`);
|
|
172414
|
+
const region = pourRegion(pour);
|
|
172415
|
+
if (!pointInPolygon(contact, region.outer) || region.holes.some((h) => pointInPolygon(contact, h)))
|
|
172416
|
+
throw new Error(`Return contact is outside copper pour ${contact.pcb_copper_pour_id}`);
|
|
172417
|
+
}
|
|
172418
|
+
}
|
|
172419
|
+
function selectReturnCurrentExperiment(options) {
|
|
172420
|
+
const { circuitJson } = options;
|
|
172421
|
+
assertUniqueIds(circuitJson);
|
|
172422
|
+
if (circuitJson.filter((e) => e.type === "pcb_board").length !== 1)
|
|
172423
|
+
throw new Error("Exactly one PCB board is required");
|
|
172424
|
+
const experiments = circuitJson.filter((e) => e.type === "simulation_experiment" && e.experiment_type === "pcb_return_current" && (!options.experimentId || e.simulation_experiment_id === options.experimentId));
|
|
172425
|
+
if (experiments.length !== 1 || experiments[0].type !== "simulation_experiment")
|
|
172426
|
+
throw new Error(`Select exactly one pcb_return_current experiment with --experiment-id; found ${experiments.length}`);
|
|
172427
|
+
const experiment = simulation_experiment3.parse(experiments[0]);
|
|
172428
|
+
const excitations = circuitJson.filter((e) => e.type === "simulation_return_current_excitation" && e.simulation_experiment_id === experiment.simulation_experiment_id).map((e) => simulation_return_current_excitation2.parse(e));
|
|
172429
|
+
if (!excitations.length)
|
|
172430
|
+
throw new Error(`Experiment ${experiment.simulation_experiment_id} has no excitations`);
|
|
172431
|
+
if (new Set(excitations.map((e) => e.ground_source_net_id)).size !== 1)
|
|
172432
|
+
throw new Error("Selected excitations must share one ground net");
|
|
172433
|
+
if (new Set(excitations.map((e) => e.pcb_trace_id)).size !== excitations.length)
|
|
172434
|
+
throw new Error("Selected excitations must reference distinct signal traces");
|
|
172435
|
+
const oriented = new Map;
|
|
172436
|
+
for (const excitation of excitations) {
|
|
172437
|
+
const originalTrace = circuitJson.find((e) => e.type === "pcb_trace" && e.pcb_trace_id === excitation.pcb_trace_id);
|
|
172438
|
+
if (!originalTrace || originalTrace.type !== "pcb_trace")
|
|
172439
|
+
throw new Error(`Missing signal trace ${excitation.pcb_trace_id}`);
|
|
172440
|
+
let trace = normalizeLayeredRoute(originalTrace);
|
|
172441
|
+
if (excitation.source_port) {
|
|
172442
|
+
const sourcePort = circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === excitation.source_port.signal_pcb_port_id);
|
|
172443
|
+
const first = trace.route[0], last = trace.route.at(-1);
|
|
172444
|
+
if (sourcePort?.type === "pcb_port" && last?.route_type === "wire" && Math.hypot(sourcePort.x - last.x, sourcePort.y - last.y) < 0.000001 && !(first?.route_type === "wire" && Math.hypot(sourcePort.x - first.x, sourcePort.y - first.y) < 0.000001))
|
|
172445
|
+
trace = {
|
|
172446
|
+
...trace,
|
|
172447
|
+
route: trace.route.map((point2, index) => {
|
|
172448
|
+
if (point2.route_type === "via")
|
|
172449
|
+
return {
|
|
172450
|
+
...point2,
|
|
172451
|
+
from_layer: point2.to_layer,
|
|
172452
|
+
to_layer: point2.from_layer
|
|
172453
|
+
};
|
|
172454
|
+
if (point2.route_type !== "wire")
|
|
172455
|
+
return point2;
|
|
172456
|
+
const previous = trace.route[Math.max(0, index - 1)];
|
|
172457
|
+
return {
|
|
172458
|
+
...point2,
|
|
172459
|
+
width: previous.route_type === "wire" ? previous.width : point2.width,
|
|
172460
|
+
start_pcb_port_id: point2.end_pcb_port_id,
|
|
172461
|
+
end_pcb_port_id: point2.start_pcb_port_id
|
|
172462
|
+
};
|
|
172463
|
+
}).reverse()
|
|
172464
|
+
};
|
|
172465
|
+
}
|
|
172466
|
+
oriented.set(trace.pcb_trace_id, trace);
|
|
172467
|
+
if (!circuitJson.some((e) => e.type === "source_net" && e.source_net_id === excitation.ground_source_net_id))
|
|
172468
|
+
throw new Error(`Missing ground net ${excitation.ground_source_net_id}`);
|
|
172469
|
+
validateContact(circuitJson, excitation.return_source, excitation.ground_source_net_id);
|
|
172470
|
+
validateContact(circuitJson, excitation.return_sink, excitation.ground_source_net_id);
|
|
172471
|
+
for (const [terminal, contact, endpoint] of [
|
|
172472
|
+
[excitation.source_port, excitation.return_sink, trace.route[0]],
|
|
172473
|
+
[excitation.load_port, excitation.return_source, trace.route.at(-1)]
|
|
172474
|
+
]) {
|
|
172475
|
+
if (!terminal) {
|
|
172476
|
+
if (contact.contact_type === "pcb_port" || contact.layer !== "bottom")
|
|
172477
|
+
throw new Error("An identified return PCB port or non-bottom reference requires explicit source_port/load_port terminal metadata");
|
|
172478
|
+
continue;
|
|
172479
|
+
}
|
|
172480
|
+
if (contact.contact_type === "pcb_port" && terminal.reference_pcb_port_id !== contact.pcb_port_id)
|
|
172481
|
+
throw new Error("A PCB-port return contact requires its matching reference_pcb_port_id");
|
|
172482
|
+
if (terminal.reference_layer !== contact.layer || terminal.reference_pcb_port_id !== undefined && (contact.contact_type !== "pcb_port" || terminal.reference_pcb_port_id !== contact.pcb_port_id))
|
|
172483
|
+
throw new Error("Terminal reference must match the identified return contact");
|
|
172484
|
+
const port = circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === terminal.signal_pcb_port_id);
|
|
172485
|
+
if (!port || port.type !== "pcb_port" || endpoint?.route_type !== "wire" || !port.layers.includes(endpoint.layer) || Math.hypot(port.x - endpoint.x, port.y - endpoint.y) > 0.000001)
|
|
172486
|
+
throw new Error("Signal terminal must match the source/load trace endpoint");
|
|
172487
|
+
if (terminal.signal_pcb_port_id === terminal.reference_pcb_port_id)
|
|
172488
|
+
throw new Error("Signal and reference terminals must be different ports");
|
|
172489
|
+
}
|
|
172490
|
+
if ([excitation.return_source, excitation.return_sink].some((c) => c.contact_type === "pcb_via"))
|
|
172491
|
+
throw new Error("Via return contacts are not supported by the current solver adapter; use a PCB port or copper-pour contact");
|
|
172492
|
+
}
|
|
172493
|
+
return {
|
|
172494
|
+
experiment,
|
|
172495
|
+
excitations,
|
|
172496
|
+
solverCircuitJson: circuitJson.map((e) => e.type === "pcb_trace" ? oriented.get(e.pcb_trace_id) ?? e : e)
|
|
172497
|
+
};
|
|
172498
|
+
}
|
|
172499
|
+
var excitationSchema, copperLayer, fabricationStackupSchema;
|
|
172500
|
+
var init_dist6 = __esm(() => {
|
|
172501
|
+
excitationSchema = z226.object({
|
|
172502
|
+
type: z226.literal("simulation_return_current_excitation"),
|
|
172503
|
+
simulation_return_current_excitation_id: getZodPrefixedIdWithDefault3("simulation_return_current_excitation"),
|
|
172504
|
+
pcb_trace_id: z226.string(),
|
|
172505
|
+
ground_source_net_id: z226.string(),
|
|
172506
|
+
current: current4.pipe(z226.number().finite()),
|
|
172507
|
+
return_source: point11.refine((contact) => Number.isFinite(contact.x) && Number.isFinite(contact.y)),
|
|
172508
|
+
return_sink: point11.refine((contact) => Number.isFinite(contact.x) && Number.isFinite(contact.y)),
|
|
172509
|
+
source_port: z226.object({
|
|
172510
|
+
signal_pcb_port_id: z226.string(),
|
|
172511
|
+
reference_pcb_port_id: z226.string().optional(),
|
|
172512
|
+
reference_layer: z226.string().regex(/^(top|bottom|inner[1-8])$/).transform((layer) => layer),
|
|
172513
|
+
resistance: z226.number().finite().positive()
|
|
172514
|
+
}).optional(),
|
|
172515
|
+
load_port: z226.object({
|
|
172516
|
+
signal_pcb_port_id: z226.string(),
|
|
172517
|
+
reference_pcb_port_id: z226.string().optional(),
|
|
172518
|
+
reference_layer: z226.string().regex(/^(top|bottom|inner[1-8])$/).transform((layer) => layer),
|
|
172519
|
+
resistance: z226.number().finite().positive()
|
|
172520
|
+
}).optional()
|
|
172521
|
+
});
|
|
172522
|
+
copperLayer = z227.string().regex(/^(top|bottom|inner[1-8])$/);
|
|
172523
|
+
fabricationStackupSchema = z227.object({
|
|
172524
|
+
nominalBoardThicknessMm: z227.number().finite().positive().optional(),
|
|
172525
|
+
layers: z227.array(z227.union([
|
|
172526
|
+
z227.object({
|
|
172527
|
+
name: copperLayer,
|
|
172528
|
+
copperThicknessMm: z227.number().finite().positive()
|
|
172529
|
+
}),
|
|
172530
|
+
z227.object({
|
|
172531
|
+
material: z227.string().min(1),
|
|
172532
|
+
dielectricThicknessMm: z227.number().finite().positive(),
|
|
172533
|
+
dielectricConstant: z227.number().finite().positive(),
|
|
172534
|
+
lossTangent: z227.number().finite().nonnegative().optional()
|
|
172535
|
+
})
|
|
172536
|
+
]))
|
|
172537
|
+
});
|
|
172538
|
+
});
|
|
172539
|
+
|
|
172540
|
+
// node_modules/simulate-return-current/dist/palace.js
|
|
172541
|
+
import { writeFile as writeFile4 } from "node:fs/promises";
|
|
172542
|
+
import { resolve as resolve32, join as join22 } from "node:path";
|
|
172543
|
+
import { existsSync as existsSync17 } from "node:fs";
|
|
172544
|
+
import { resolve as resolve12, join as join18 } from "node:path";
|
|
172545
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
172546
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
172547
|
+
import { open as open2 } from "node:fs/promises";
|
|
172548
|
+
import { mkdir as mkdir22, writeFile as writeFile22 } from "node:fs/promises";
|
|
172549
|
+
import { resolve as resolve22 } from "node:path";
|
|
172550
|
+
import {
|
|
172551
|
+
applyToPoint as applyToPoint50,
|
|
172552
|
+
compose as compose20,
|
|
172553
|
+
rotateDEG as rotateDEG5,
|
|
172554
|
+
translate as translate20
|
|
172555
|
+
} from "transformation-matrix";
|
|
172556
|
+
import {
|
|
172557
|
+
applyToPoint as applyToPoint215,
|
|
172558
|
+
compose as compose25,
|
|
172559
|
+
rotateDEG as rotateDEG23,
|
|
172560
|
+
translate as translate25
|
|
172561
|
+
} from "transformation-matrix";
|
|
172562
|
+
import { z as z228 } from "zod";
|
|
172563
|
+
import { writeFile as writeFile3 } from "node:fs/promises";
|
|
172564
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
172565
|
+
import { writeFile as writeFile32 } from "node:fs/promises";
|
|
172566
|
+
import { Resvg as Resvg3 } from "@resvg/resvg-js";
|
|
172567
|
+
import { applyToPoint as applyToPoint311, compose as compose35, scale as scale14, translate as translate35 } from "transformation-matrix";
|
|
172568
|
+
import { Resvg as Resvg22 } from "@resvg/resvg-js";
|
|
172569
|
+
import { BaseSolver as BaseSolver44 } from "@tscircuit/solver-utils";
|
|
172570
|
+
function normalizeLayeredRoute2(trace) {
|
|
172571
|
+
const route = [];
|
|
172572
|
+
for (const [index, point] of trace.route.entries()) {
|
|
172573
|
+
if (point.route_type === "wire") {
|
|
172574
|
+
route.push(point);
|
|
172575
|
+
continue;
|
|
172576
|
+
}
|
|
172577
|
+
if (point.route_type !== "via")
|
|
172578
|
+
throw new Error("Through-pad route points are not supported");
|
|
172579
|
+
const previous = route.at(-1);
|
|
172580
|
+
const next = trace.route[index + 1];
|
|
172581
|
+
if (!previous || previous.route_type !== "wire" || !next)
|
|
172582
|
+
throw new Error("Via needs signal route endpoints on both sides");
|
|
172583
|
+
if (previous.layer !== point.from_layer)
|
|
172584
|
+
throw new Error("Via from_layer does not match preceding wire layer");
|
|
172585
|
+
if (previous.x !== point.x || previous.y !== point.y)
|
|
172586
|
+
route.push({
|
|
172587
|
+
route_type: "wire",
|
|
172588
|
+
x: point.x,
|
|
172589
|
+
y: point.y,
|
|
172590
|
+
width: previous.width,
|
|
172591
|
+
layer: point.from_layer
|
|
172592
|
+
});
|
|
172593
|
+
route.push(point);
|
|
172594
|
+
if (next.route_type !== "wire" || next.x !== point.x || next.y !== point.y)
|
|
172595
|
+
route.push({
|
|
172596
|
+
route_type: "wire",
|
|
172597
|
+
x: point.x,
|
|
172598
|
+
y: point.y,
|
|
172599
|
+
width: next.route_type === "wire" ? next.width : previous.width,
|
|
172600
|
+
layer: point.to_layer
|
|
172601
|
+
});
|
|
172602
|
+
}
|
|
172603
|
+
return { ...trace, route };
|
|
172604
|
+
}
|
|
172605
|
+
function parseCurrentAmps(value) {
|
|
172606
|
+
return parseQuantity(value, { A: 1, mA: 0.001, uA: 0.000001, µA: 0.000001, μA: 0.000001, nA: 0.000000001 }, "current", false);
|
|
172607
|
+
}
|
|
172608
|
+
function parseResistanceOhms(value) {
|
|
172609
|
+
return parseQuantity(value, {
|
|
172610
|
+
ohm: 1,
|
|
172611
|
+
ohms: 1,
|
|
172612
|
+
Ohm: 1,
|
|
172613
|
+
Ω: 1,
|
|
172614
|
+
kohm: 1000,
|
|
172615
|
+
kOhm: 1000,
|
|
172616
|
+
kΩ: 1000,
|
|
172617
|
+
Mohm: 1e6,
|
|
172618
|
+
MOhm: 1e6,
|
|
172619
|
+
MΩ: 1e6
|
|
172620
|
+
}, "resistance", true);
|
|
172621
|
+
}
|
|
172622
|
+
function parseQuantity(value, units, label, positive) {
|
|
172623
|
+
let result;
|
|
172624
|
+
if (typeof value === "number")
|
|
172625
|
+
result = value;
|
|
172626
|
+
else if (typeof value === "string") {
|
|
172627
|
+
const match = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*([^\s]*)\s*$/.exec(value);
|
|
172628
|
+
const scale = match ? match[2] === "" ? 1 : units[match[2]] : undefined;
|
|
172629
|
+
if (!match || scale === undefined)
|
|
172630
|
+
throw new Error(`Invalid ${label} "${value}"; supported units: ${Object.keys(units).join(", ")}`);
|
|
172631
|
+
result = Number(match[1]) * scale;
|
|
172632
|
+
} else
|
|
172633
|
+
throw new Error(`${label} must be a number or a value with units`);
|
|
172634
|
+
if (!Number.isFinite(result) || positive && result <= 0)
|
|
172635
|
+
throw new Error(`${label} must be finite${positive ? " and greater than zero" : ""}`);
|
|
172636
|
+
return result;
|
|
172637
|
+
}
|
|
172638
|
+
function portNetIds2(circuitJson, sourcePortId) {
|
|
172639
|
+
const ports = new Set([sourcePortId]);
|
|
172640
|
+
const nets = new Set;
|
|
172641
|
+
const pending = circuitJson.filter((element) => element.type === "source_trace");
|
|
172642
|
+
let changed = true;
|
|
172643
|
+
while (changed) {
|
|
172644
|
+
changed = false;
|
|
172645
|
+
for (const trace of pending) {
|
|
172646
|
+
if (!trace.connected_source_port_ids.some((id) => ports.has(id)) && !trace.connected_source_net_ids.some((id) => nets.has(id)))
|
|
172647
|
+
continue;
|
|
172648
|
+
for (const id of trace.connected_source_port_ids)
|
|
172649
|
+
if (!ports.has(id)) {
|
|
172650
|
+
ports.add(id);
|
|
172651
|
+
changed = true;
|
|
172652
|
+
}
|
|
172653
|
+
for (const id of trace.connected_source_net_ids)
|
|
172654
|
+
if (!nets.has(id)) {
|
|
172655
|
+
nets.add(id);
|
|
172656
|
+
changed = true;
|
|
172657
|
+
}
|
|
172658
|
+
}
|
|
172659
|
+
}
|
|
172660
|
+
return [...nets];
|
|
172661
|
+
}
|
|
172662
|
+
function aliases2(port) {
|
|
172663
|
+
return [
|
|
172664
|
+
...new Set([
|
|
172665
|
+
port.name,
|
|
172666
|
+
...port.port_hints ?? [],
|
|
172667
|
+
...port.pin_number === undefined ? [] : [`pin${port.pin_number}`, String(port.pin_number)]
|
|
172668
|
+
])
|
|
172669
|
+
];
|
|
172670
|
+
}
|
|
172671
|
+
function resolvePort(circuitJson, selector) {
|
|
172672
|
+
const match = /^([^\.\s]+)\.([^\.\s]+)$/.exec(selector);
|
|
172673
|
+
if (!match)
|
|
172674
|
+
throw new Error(`Port "${selector}" must use refdes.pin syntax, e.g. R1.pin1 or U1.VDDIO1`);
|
|
172675
|
+
const components = circuitJson.filter((element) => element.type === "source_component" && element.name === match[1]);
|
|
172676
|
+
if (components.length !== 1 || components[0].type !== "source_component")
|
|
172677
|
+
throw new Error(`Component "${match[1]}" is ${components.length ? "ambiguous" : "missing"}`);
|
|
172678
|
+
const componentId = components[0].source_component_id;
|
|
172679
|
+
const ports = circuitJson.filter((element) => element.type === "source_port" && element.source_component_id === componentId && aliases2(element).includes(match[2]));
|
|
172680
|
+
if (ports.length !== 1)
|
|
172681
|
+
throw new Error(`Port "${selector}" is ${ports.length ? "ambiguous" : "missing"}; use the ports command to inspect selectors`);
|
|
172682
|
+
const pcbPorts = circuitJson.filter((element) => element.type === "pcb_port" && element.source_port_id === ports[0].source_port_id);
|
|
172683
|
+
if (pcbPorts.length !== 1)
|
|
172684
|
+
throw new Error(`Port "${selector}" needs exactly one PCB port location`);
|
|
172685
|
+
return { source: ports[0], pcb: pcbPorts[0] };
|
|
172686
|
+
}
|
|
172687
|
+
function endpointMatches(circuitJson, trace, endpoint, port) {
|
|
172688
|
+
if (endpoint.route_type !== "wire" || !port.pcb.layers.includes(endpoint.layer) || Math.hypot(endpoint.x - port.pcb.x, endpoint.y - port.pcb.y) > 0.000001)
|
|
172689
|
+
return false;
|
|
172690
|
+
const ids = [endpoint.start_pcb_port_id, endpoint.end_pcb_port_id].filter(Boolean);
|
|
172691
|
+
if (ids.length)
|
|
172692
|
+
return ids.includes(port.pcb.pcb_port_id);
|
|
172693
|
+
const sourceTrace = circuitJson.find((element) => element.type === "source_trace" && element.source_trace_id === trace.source_trace_id);
|
|
172694
|
+
return sourceTrace?.type === "source_trace" && sourceTrace.connected_source_port_ids.includes(port.source.source_port_id);
|
|
172695
|
+
}
|
|
172696
|
+
function withNamedExcitations(options) {
|
|
172697
|
+
options = {
|
|
172698
|
+
...options,
|
|
172699
|
+
circuitJson: options.circuitJson.map((e) => e.type === "pcb_trace" ? normalizeLayeredRoute2(e) : e)
|
|
172700
|
+
};
|
|
172701
|
+
if (!options.ports.length)
|
|
172702
|
+
throw new Error("Specify at least one source/load/current excitation");
|
|
172703
|
+
const nets = options.circuitJson.filter((element) => element.type === "source_net" && (element.name === options.groundNet || element.source_net_id === options.groundNet));
|
|
172704
|
+
if (nets.length !== 1 || nets[0].type !== "source_net")
|
|
172705
|
+
throw new Error(`Ground net "${options.groundNet}" is ${nets.length ? "ambiguous" : "missing"}`);
|
|
172706
|
+
const groundId = nets[0].source_net_id;
|
|
172707
|
+
const oriented = new Map;
|
|
172708
|
+
const excitations = [];
|
|
172709
|
+
for (const [index, excitation] of options.ports.entries()) {
|
|
172710
|
+
const current = parseCurrentAmps(excitation.current);
|
|
172711
|
+
const source = resolvePort(options.circuitJson, excitation.source);
|
|
172712
|
+
const load2 = resolvePort(options.circuitJson, excitation.load);
|
|
172713
|
+
const reference = (selector, signal) => {
|
|
172714
|
+
if (!selector)
|
|
172715
|
+
return {
|
|
172716
|
+
point: { x: signal.pcb.x, y: signal.pcb.y },
|
|
172717
|
+
layer: options.referenceLayer ?? "bottom",
|
|
172718
|
+
pcbPortId: undefined
|
|
172719
|
+
};
|
|
172720
|
+
const port = resolvePort(options.circuitJson, selector);
|
|
172721
|
+
if (!portNetIds2(options.circuitJson, port.source.source_port_id).includes(groundId))
|
|
172722
|
+
throw new Error(`Reference "${selector}" is not connected to the selected ground net; connect it explicitly in TSX`);
|
|
172723
|
+
if (port.pcb.pcb_port_id === signal.pcb.pcb_port_id)
|
|
172724
|
+
throw new Error("Signal and reference terminals must be different ports");
|
|
172725
|
+
const layer = port.pcb.layers.includes("top") ? "top" : port.pcb.layers.includes("bottom") ? "bottom" : port.pcb.layers[0];
|
|
172726
|
+
if (!port.pcb.layers.includes(layer))
|
|
172727
|
+
throw new Error(`Reference "${selector}" needs a physical copper layer`);
|
|
172728
|
+
return {
|
|
172729
|
+
point: { x: port.pcb.x, y: port.pcb.y },
|
|
172730
|
+
layer,
|
|
172731
|
+
pcbPortId: port.pcb.pcb_port_id
|
|
172732
|
+
};
|
|
172733
|
+
};
|
|
172734
|
+
const sourceReference = reference(excitation.sourceReference, source);
|
|
172735
|
+
const loadReference = reference(excitation.loadReference, load2);
|
|
172736
|
+
if (source.pcb.pcb_port_id === load2.pcb.pcb_port_id)
|
|
172737
|
+
throw new Error("Source and load must be different ports");
|
|
172738
|
+
const candidates = options.circuitJson.flatMap((element) => {
|
|
172739
|
+
if (element.type !== "pcb_trace" || element.route.length < 2)
|
|
172740
|
+
return [];
|
|
172741
|
+
const first = element.route[0];
|
|
172742
|
+
const last = element.route[element.route.length - 1];
|
|
172743
|
+
const forward = endpointMatches(options.circuitJson, element, first, source) && endpointMatches(options.circuitJson, element, last, load2);
|
|
172744
|
+
const reverse2 = endpointMatches(options.circuitJson, element, last, source) && endpointMatches(options.circuitJson, element, first, load2);
|
|
172745
|
+
return forward || reverse2 ? [{ trace: element, reverse: reverse2 }] : [];
|
|
172746
|
+
});
|
|
172747
|
+
if (candidates.length !== 1)
|
|
172748
|
+
throw new Error(`"${excitation.source}" → "${excitation.load}" needs exactly one continuous PCB trace; found ${candidates.length}. Branched nets and component-spanning paths are not supported`);
|
|
172749
|
+
const { trace, reverse } = candidates[0];
|
|
172750
|
+
if (oriented.has(trace.pcb_trace_id))
|
|
172751
|
+
throw new Error(`Trace ${trace.pcb_trace_id} is selected more than once`);
|
|
172752
|
+
const route = reverse ? trace.route.map((point, routeIndex) => {
|
|
172753
|
+
if (point.route_type === "via")
|
|
172754
|
+
return {
|
|
172755
|
+
...point,
|
|
172756
|
+
from_layer: point.to_layer,
|
|
172757
|
+
to_layer: point.from_layer
|
|
172758
|
+
};
|
|
172759
|
+
if (point.route_type !== "wire")
|
|
172760
|
+
return point;
|
|
172761
|
+
const previous = trace.route[Math.max(0, routeIndex - 1)];
|
|
172762
|
+
return {
|
|
172763
|
+
...point,
|
|
172764
|
+
width: previous.route_type === "wire" ? previous.width : point.width,
|
|
172765
|
+
start_pcb_port_id: point.end_pcb_port_id,
|
|
172766
|
+
end_pcb_port_id: point.start_pcb_port_id
|
|
172767
|
+
};
|
|
172768
|
+
}).reverse() : trace.route;
|
|
172769
|
+
oriented.set(trace.pcb_trace_id, { ...trace, route });
|
|
172770
|
+
excitations.push({
|
|
172771
|
+
type: "simulation_return_current_excitation",
|
|
172772
|
+
simulation_return_current_excitation_id: `simulation_return_current_excitation_${index}`,
|
|
172773
|
+
pcb_trace_id: trace.pcb_trace_id,
|
|
172774
|
+
ground_source_net_id: groundId,
|
|
172775
|
+
current,
|
|
172776
|
+
return_source: loadReference.point,
|
|
172777
|
+
return_sink: sourceReference.point,
|
|
172778
|
+
source_port: {
|
|
172779
|
+
signal_pcb_port_id: source.pcb.pcb_port_id,
|
|
172780
|
+
reference_pcb_port_id: sourceReference.pcbPortId,
|
|
172781
|
+
reference_layer: sourceReference.layer,
|
|
172782
|
+
resistance: parseResistanceOhms(excitation.sourceImpedance ?? 50)
|
|
172783
|
+
},
|
|
172784
|
+
load_port: {
|
|
172785
|
+
signal_pcb_port_id: load2.pcb.pcb_port_id,
|
|
172786
|
+
reference_pcb_port_id: loadReference.pcbPortId,
|
|
172787
|
+
reference_layer: loadReference.layer,
|
|
172788
|
+
resistance: parseResistanceOhms(excitation.loadImpedance ?? 50)
|
|
172789
|
+
}
|
|
172790
|
+
});
|
|
172791
|
+
}
|
|
172792
|
+
return [
|
|
172793
|
+
...options.circuitJson.filter((element) => element.type !== "simulation_return_current_excitation").map((element) => element.type === "pcb_trace" ? oriented.get(element.pcb_trace_id) ?? element : element),
|
|
172794
|
+
...excitations
|
|
172795
|
+
];
|
|
172796
|
+
}
|
|
172797
|
+
async function runCommand2(options) {
|
|
172798
|
+
const log = await open2(options.logPath, "w");
|
|
172799
|
+
try {
|
|
172800
|
+
await new Promise((resolve, reject) => {
|
|
172801
|
+
const child = spawn2(options.command[0], options.command.slice(1), {
|
|
172802
|
+
cwd: options.cwd,
|
|
172803
|
+
stdio: ["ignore", log.fd, log.fd]
|
|
172804
|
+
});
|
|
172805
|
+
child.once("error", (error) => reject(new Error(`Could not start ${options.command[0]}: ${error.message}; see ${options.logPath}`)));
|
|
172806
|
+
child.once("close", (status, signal) => status === 0 ? resolve() : reject(new Error(`${options.command[0]} exited with ${status ?? signal}; see ${options.logPath}`)));
|
|
172807
|
+
});
|
|
172808
|
+
} finally {
|
|
172809
|
+
await log.close();
|
|
172810
|
+
}
|
|
172811
|
+
}
|
|
172812
|
+
function palacePythonAsset(filename) {
|
|
172813
|
+
return fileURLToPath2(new URL(`./python/${filename}`, import.meta.url));
|
|
172814
|
+
}
|
|
172815
|
+
function venvPython(directory) {
|
|
172816
|
+
return join18(directory, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
|
|
172817
|
+
}
|
|
172818
|
+
function palacePython(explicit) {
|
|
172819
|
+
if (explicit || process.env.PALACE_PYTHON)
|
|
172820
|
+
return explicit ?? process.env.PALACE_PYTHON;
|
|
172821
|
+
const local = venvPython(resolve12(".return-current-python"));
|
|
172822
|
+
return existsSync17(local) ? local : "python3";
|
|
172823
|
+
}
|
|
172824
|
+
function rectangleOutline2(rect) {
|
|
172825
|
+
if (![
|
|
172826
|
+
rect.center.x,
|
|
172827
|
+
rect.center.y,
|
|
172828
|
+
rect.rotation ?? 0,
|
|
172829
|
+
rect.width,
|
|
172830
|
+
rect.height
|
|
172831
|
+
].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0)
|
|
172832
|
+
throw new Error("Rectangles need finite coordinates, rotation, and positive dimensions");
|
|
172833
|
+
const localToWorld = compose20(translate20(rect.center.x, rect.center.y), rotateDEG5(rect.rotation ?? 0));
|
|
172834
|
+
return [
|
|
172835
|
+
{ x: -rect.width / 2, y: -rect.height / 2 },
|
|
172836
|
+
{ x: rect.width / 2, y: -rect.height / 2 },
|
|
172837
|
+
{ x: rect.width / 2, y: rect.height / 2 },
|
|
172838
|
+
{ x: -rect.width / 2, y: rect.height / 2 }
|
|
172839
|
+
].map((corner) => applyToPoint50(localToWorld, corner));
|
|
172840
|
+
}
|
|
172841
|
+
function flattenRing2(vertices) {
|
|
172842
|
+
const outline = [];
|
|
172843
|
+
for (let vertexIndex = 0;vertexIndex < vertices.length; vertexIndex++) {
|
|
172844
|
+
const start = vertices[vertexIndex];
|
|
172845
|
+
const end = vertices[(vertexIndex + 1) % vertices.length];
|
|
172846
|
+
outline.push({ x: start.x, y: start.y });
|
|
172847
|
+
const bulge = start.bulge ?? 0;
|
|
172848
|
+
if (Math.abs(bulge) < 0.000000000001)
|
|
172849
|
+
continue;
|
|
172850
|
+
const dx = end.x - start.x;
|
|
172851
|
+
const dy = end.y - start.y;
|
|
172852
|
+
const center = {
|
|
172853
|
+
x: (start.x + end.x) / 2 - dy * (1 - bulge * bulge) / (4 * bulge),
|
|
172854
|
+
y: (start.y + end.y) / 2 + dx * (1 - bulge * bulge) / (4 * bulge)
|
|
172855
|
+
};
|
|
172856
|
+
const sweep = 4 * Math.atan(bulge);
|
|
172857
|
+
const radius = Math.hypot(start.x - center.x, start.y - center.y);
|
|
172858
|
+
const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
|
|
172859
|
+
const steps = Math.ceil(Math.abs(sweep) / (Math.PI / 60));
|
|
172860
|
+
for (let arcIndex = 1;arcIndex < steps; arcIndex++) {
|
|
172861
|
+
const angle = startAngle + sweep * arcIndex / steps;
|
|
172862
|
+
outline.push({
|
|
172863
|
+
x: center.x + radius * Math.cos(angle),
|
|
172864
|
+
y: center.y + radius * Math.sin(angle)
|
|
172865
|
+
});
|
|
172866
|
+
}
|
|
172867
|
+
}
|
|
172868
|
+
return outline;
|
|
172869
|
+
}
|
|
172870
|
+
function cutoutOutline(cutout) {
|
|
172871
|
+
if (cutout.shape === "polygon")
|
|
172872
|
+
return cutout.points;
|
|
172873
|
+
if (cutout.shape === "circle") {
|
|
172874
|
+
if (!Number.isFinite(cutout.radius) || cutout.radius <= 0)
|
|
172875
|
+
throw new Error("Circular cutouts need a finite positive radius");
|
|
172876
|
+
return Array.from({ length: 120 }, (_, circleIndex) => ({
|
|
172877
|
+
x: cutout.center.x + cutout.radius * Math.cos(circleIndex * Math.PI / 60),
|
|
172878
|
+
y: cutout.center.y + cutout.radius * Math.sin(circleIndex * Math.PI / 60)
|
|
172879
|
+
}));
|
|
172880
|
+
}
|
|
172881
|
+
if (cutout.shape === "rect" && !cutout.corner_radius)
|
|
172882
|
+
return rectangleOutline2(cutout);
|
|
172883
|
+
throw new Error("Path and rounded rectangular PCB cutouts are not supported; use a polygon cutout");
|
|
172884
|
+
}
|
|
172885
|
+
function pointInPolygon2(point, outline) {
|
|
172886
|
+
let inside = false;
|
|
172887
|
+
for (let cornerIndex = 0;cornerIndex < outline.length; cornerIndex++) {
|
|
172888
|
+
const start = outline[cornerIndex];
|
|
172889
|
+
const end = outline[(cornerIndex + 1) % outline.length];
|
|
172890
|
+
const cross = (point.x - start.x) * (end.y - start.y) - (point.y - start.y) * (end.x - start.x);
|
|
172891
|
+
if (Math.abs(cross) < 0.000000001 && point.x >= Math.min(start.x, end.x) - 0.000000001 && point.x <= Math.max(start.x, end.x) + 0.000000001 && point.y >= Math.min(start.y, end.y) - 0.000000001 && point.y <= Math.max(start.y, end.y) + 0.000000001)
|
|
172892
|
+
return true;
|
|
172893
|
+
if (start.y > point.y !== end.y > point.y && point.x < start.x + (point.y - start.y) * (end.x - start.x) / (end.y - start.y))
|
|
172894
|
+
inside = !inside;
|
|
172895
|
+
}
|
|
172896
|
+
return inside;
|
|
172897
|
+
}
|
|
172898
|
+
function pointInRegion(point, region) {
|
|
172899
|
+
return pointInPolygon2(point, region.outer) && !region.holes.some((hole) => pointInPolygon2(point, hole)) && !(region.maskCutouts ?? []).some((hole) => pointInPolygon2(point, hole));
|
|
172900
|
+
}
|
|
172901
|
+
function isCopper(point, geometry) {
|
|
172902
|
+
return pointInPolygon2(point, geometry.boardOutline) && !geometry.cutouts.some((cutout) => pointInPolygon2(point, cutout)) && geometry.groundRegions.some((region) => pointInRegion(point, region));
|
|
172903
|
+
}
|
|
172904
|
+
function segmentInCopper(segment, geometry) {
|
|
172905
|
+
const outlines = [
|
|
172906
|
+
geometry.boardOutline,
|
|
172907
|
+
...geometry.cutouts,
|
|
172908
|
+
...geometry.groundRegions.flatMap((region) => [
|
|
172909
|
+
region.outer,
|
|
172910
|
+
...region.holes
|
|
172911
|
+
])
|
|
172912
|
+
];
|
|
172913
|
+
const dx = segment.end.x - segment.start.x;
|
|
172914
|
+
const dy = segment.end.y - segment.start.y;
|
|
172915
|
+
const crossings = [0, 1];
|
|
172916
|
+
for (const outline of outlines) {
|
|
172917
|
+
for (let cornerIndex = 0;cornerIndex < outline.length; cornerIndex++) {
|
|
172918
|
+
const start = outline[cornerIndex];
|
|
172919
|
+
const end = outline[(cornerIndex + 1) % outline.length];
|
|
172920
|
+
const ex = end.x - start.x;
|
|
172921
|
+
const ey = end.y - start.y;
|
|
172922
|
+
const determinant = dx * ey - dy * ex;
|
|
172923
|
+
if (Math.abs(determinant) < 0.000000000001)
|
|
172924
|
+
continue;
|
|
172925
|
+
const ax = start.x - segment.start.x;
|
|
172926
|
+
const ay = start.y - segment.start.y;
|
|
172927
|
+
const alongSegment = (ax * ey - ay * ex) / determinant;
|
|
172928
|
+
const alongBoundary = (ax * dy - ay * dx) / determinant;
|
|
172929
|
+
if (alongSegment > 0 && alongSegment < 1 && alongBoundary >= 0 && alongBoundary <= 1)
|
|
172930
|
+
crossings.push(alongSegment);
|
|
172931
|
+
}
|
|
172932
|
+
}
|
|
172933
|
+
crossings.sort((a, b) => a - b);
|
|
172934
|
+
for (let crossingIndex = 1;crossingIndex < crossings.length; crossingIndex++) {
|
|
172935
|
+
const fraction = (crossings[crossingIndex - 1] + crossings[crossingIndex]) / 2;
|
|
172936
|
+
if (!isCopper({
|
|
172937
|
+
x: segment.start.x + fraction * dx,
|
|
172938
|
+
y: segment.start.y + fraction * dy
|
|
172939
|
+
}, geometry))
|
|
172940
|
+
return false;
|
|
172941
|
+
}
|
|
172942
|
+
return true;
|
|
172943
|
+
}
|
|
172944
|
+
function pourRegion2(pour) {
|
|
172945
|
+
if (pour.shape === "rect")
|
|
172946
|
+
return { outer: rectangleOutline2(pour), holes: [] };
|
|
172947
|
+
if (pour.shape === "polygon")
|
|
172948
|
+
return { outer: pour.points, holes: [] };
|
|
172949
|
+
return {
|
|
172950
|
+
outer: flattenRing2(pour.brep_shape.outer_ring.vertices),
|
|
172951
|
+
holes: pour.brep_shape.inner_rings.map((ring) => flattenRing2(ring.vertices))
|
|
172952
|
+
};
|
|
172953
|
+
}
|
|
172954
|
+
function positiveFinite(number, label) {
|
|
172955
|
+
if (!Number.isFinite(number) || number <= 0)
|
|
172956
|
+
throw new Error(`${label} must be finite and greater than zero`);
|
|
172957
|
+
return number;
|
|
172958
|
+
}
|
|
172959
|
+
function validateOutline(outline) {
|
|
172960
|
+
if (outline.length < 3 || outline.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y)))
|
|
172961
|
+
throw new Error("Copper and board polygons need at least three finite points");
|
|
172962
|
+
}
|
|
172963
|
+
function readGeometry(options) {
|
|
172964
|
+
const boards = options.circuitJson.filter((element) => element.type === "pcb_board");
|
|
172965
|
+
if (boards.length !== 1)
|
|
172966
|
+
throw new Error("Exactly one PCB board is required");
|
|
172967
|
+
const board = boards[0];
|
|
172968
|
+
if (board.num_layers !== 2)
|
|
172969
|
+
throw new Error("Return-current simulation currently requires a two-layer board");
|
|
172970
|
+
if (options.circuitJson.some((element) => element.type === "pcb_hole" || element.type === "pcb_plated_hole" || element.type === "pcb_via"))
|
|
172971
|
+
throw new Error("Drilled holes and vias are not supported yet; represent voids with PCB cutouts and model return contacts explicitly");
|
|
172972
|
+
const boardOutline = board.outline?.length ? board.outline : rectangleOutline2({
|
|
172973
|
+
center: board.center,
|
|
172974
|
+
width: positiveFinite(board.width ?? 0, "Board width"),
|
|
172975
|
+
height: positiveFinite(board.height ?? 0, "Board height")
|
|
172976
|
+
});
|
|
172977
|
+
const excitations = options.excitations ?? options.circuitJson.filter((element) => element.type === "simulation_return_current_excitation");
|
|
172978
|
+
if (!excitations.length)
|
|
172979
|
+
throw new Error("Specify return-current excitations with signal current and return contacts");
|
|
172980
|
+
const groundNetIds = new Set(excitations.map((excitation) => excitation.ground_source_net_id));
|
|
172981
|
+
if (groundNetIds.size !== 1)
|
|
172982
|
+
throw new Error("All excitations must share one ground net");
|
|
172983
|
+
const pours = options.circuitJson.filter((element) => element.type === "pcb_copper_pour" && element.layer === "bottom" && groundNetIds.has(element.source_net_id ?? ""));
|
|
172984
|
+
const groundPlanes = options.circuitJson.filter((element) => element.type === "pcb_ground_plane" && groundNetIds.has(element.source_net_id));
|
|
172985
|
+
const groundPlaneIds = new Set(groundPlanes.flatMap((element) => element.type === "pcb_ground_plane" ? [element.pcb_ground_plane_id] : []));
|
|
172986
|
+
const planeRegions = options.circuitJson.filter((element) => element.type === "pcb_ground_plane_region" && element.layer === "bottom" && groundPlaneIds.has(element.pcb_ground_plane_id));
|
|
172987
|
+
const groundRegions = [
|
|
172988
|
+
...pours.map(pourRegion2),
|
|
172989
|
+
...planeRegions.flatMap((region) => region.type === "pcb_ground_plane_region" ? [{ outer: region.points, holes: [] }] : [])
|
|
172990
|
+
];
|
|
172991
|
+
if (!groundRegions.length)
|
|
172992
|
+
throw new Error("The selected ground net needs a bottom-layer copper pour or ground-plane region");
|
|
172993
|
+
const cutouts = options.circuitJson.filter((element) => element.type === "pcb_cutout").map(cutoutOutline);
|
|
172994
|
+
for (const outline of [
|
|
172995
|
+
boardOutline,
|
|
172996
|
+
...cutouts,
|
|
172997
|
+
...groundRegions.flatMap((region) => [region.outer, ...region.holes])
|
|
172998
|
+
])
|
|
172999
|
+
validateOutline(outline);
|
|
173000
|
+
const geometry = {
|
|
173001
|
+
board,
|
|
173002
|
+
boardOutline,
|
|
173003
|
+
groundRegions,
|
|
173004
|
+
cutouts,
|
|
173005
|
+
signals: [],
|
|
173006
|
+
segments: [],
|
|
173007
|
+
excitations
|
|
173008
|
+
};
|
|
173009
|
+
for (const excitation of excitations) {
|
|
173010
|
+
if (!Number.isFinite(excitation.current))
|
|
173011
|
+
throw new Error("Excitation current must be finite (amperes)");
|
|
173012
|
+
for (const contact of [excitation.return_source, excitation.return_sink]) {
|
|
173013
|
+
if (!Number.isFinite(contact.x) || !Number.isFinite(contact.y) || !isCopper(contact, geometry))
|
|
173014
|
+
throw new Error("Each return contact must lie on the selected ground copper");
|
|
173015
|
+
}
|
|
173016
|
+
const signal = options.circuitJson.find((element) => element.type === "pcb_trace" && element.pcb_trace_id === excitation.pcb_trace_id);
|
|
173017
|
+
if (!signal || signal.type !== "pcb_trace")
|
|
173018
|
+
throw new Error("An excitation references a missing signal trace");
|
|
173019
|
+
if (signal.route.length < 2)
|
|
173020
|
+
throw new Error("Each signal trace needs at least two route points");
|
|
173021
|
+
const wires = signal.route.map((routePoint) => {
|
|
173022
|
+
if (routePoint.route_type !== "wire" || routePoint.layer !== "top")
|
|
173023
|
+
throw new Error("Only continuous top-layer wire routes are supported; split routes at vias or pads");
|
|
173024
|
+
if (!Number.isFinite(routePoint.x) || !Number.isFinite(routePoint.y))
|
|
173025
|
+
throw new Error("Signal coordinates must be finite");
|
|
173026
|
+
positiveFinite(routePoint.width, "Signal width");
|
|
173027
|
+
return routePoint;
|
|
173028
|
+
});
|
|
173029
|
+
geometry.signals.push(signal);
|
|
173030
|
+
for (let routeIndex = 1;routeIndex < wires.length; routeIndex++) {
|
|
173031
|
+
const start = wires[routeIndex - 1];
|
|
173032
|
+
const end = wires[routeIndex];
|
|
173033
|
+
if (start.x === end.x && start.y === end.y)
|
|
173034
|
+
continue;
|
|
173035
|
+
const boardMaterial = {
|
|
173036
|
+
...geometry,
|
|
173037
|
+
groundRegions: [{ outer: boardOutline, holes: [] }]
|
|
173038
|
+
};
|
|
173039
|
+
if (!segmentInCopper({ start, end }, boardMaterial))
|
|
173040
|
+
throw new Error("A top-layer signal leaves the board or crosses a physical PCB cutout");
|
|
173041
|
+
geometry.segments.push({
|
|
173042
|
+
start,
|
|
173043
|
+
end,
|
|
173044
|
+
width: start.width,
|
|
173045
|
+
current: excitation.current
|
|
173046
|
+
});
|
|
173047
|
+
}
|
|
173048
|
+
}
|
|
173049
|
+
if (!geometry.segments.length)
|
|
173050
|
+
throw new Error("Signal traces must contain a segment of nonzero length");
|
|
173051
|
+
return geometry;
|
|
173052
|
+
}
|
|
173053
|
+
function copperConnectivity(circuitJson) {
|
|
173054
|
+
const parents = new Map;
|
|
173055
|
+
const find = (id) => {
|
|
173056
|
+
const parent = parents.get(id);
|
|
173057
|
+
if (!parent) {
|
|
173058
|
+
parents.set(id, id);
|
|
173059
|
+
return id;
|
|
173060
|
+
}
|
|
173061
|
+
if (parent === id)
|
|
173062
|
+
return id;
|
|
173063
|
+
const root = find(parent);
|
|
173064
|
+
parents.set(id, root);
|
|
173065
|
+
return root;
|
|
173066
|
+
};
|
|
173067
|
+
for (const element of circuitJson) {
|
|
173068
|
+
if (element.type !== "source_trace")
|
|
173069
|
+
continue;
|
|
173070
|
+
const ids = [
|
|
173071
|
+
element.source_trace_id,
|
|
173072
|
+
...element.connected_source_net_ids,
|
|
173073
|
+
...element.connected_source_port_ids
|
|
173074
|
+
];
|
|
173075
|
+
for (const id of ids)
|
|
173076
|
+
parents.set(find(id), find(ids[0]));
|
|
173077
|
+
}
|
|
173078
|
+
const netIds = new Map;
|
|
173079
|
+
for (const element of circuitJson)
|
|
173080
|
+
if (element.type === "source_net") {
|
|
173081
|
+
const root = find(element.source_net_id);
|
|
173082
|
+
const existing = netIds.get(root);
|
|
173083
|
+
if (existing && existing !== element.source_net_id)
|
|
173084
|
+
throw new Error(`Source connectivity joins distinct nets ${existing} and ${element.source_net_id}`);
|
|
173085
|
+
netIds.set(root, element.source_net_id);
|
|
173086
|
+
}
|
|
173087
|
+
const owner = (id) => netIds.get(find(id)) ?? find(id);
|
|
173088
|
+
const pcbPorts = new Map;
|
|
173089
|
+
const pcbTraces = new Map;
|
|
173090
|
+
for (const element of circuitJson) {
|
|
173091
|
+
if (element.type === "pcb_port")
|
|
173092
|
+
pcbPorts.set(element.pcb_port_id, owner(element.source_port_id));
|
|
173093
|
+
if (element.type === "pcb_trace")
|
|
173094
|
+
pcbTraces.set(element.pcb_trace_id, owner(element.source_trace_id ?? element.pcb_trace_id));
|
|
173095
|
+
}
|
|
173096
|
+
return { owner, pcbPorts, pcbTraces };
|
|
173097
|
+
}
|
|
173098
|
+
function traceOutline(segment) {
|
|
173099
|
+
const dx = segment.end.x - segment.start.x;
|
|
173100
|
+
const dy = segment.end.y - segment.start.y;
|
|
173101
|
+
const length = Math.hypot(dx, dy);
|
|
173102
|
+
const transform = compose25(translate25((segment.start.x + segment.end.x) / 2, (segment.start.y + segment.end.y) / 2), rotateDEG23(Math.atan2(dy, dx) * 180 / Math.PI));
|
|
173103
|
+
return [-1, 1].flatMap((end) => Array.from({ length: 17 }, (_, index) => {
|
|
173104
|
+
const angle = index * Math.PI / 16 + (end === 1 ? -Math.PI / 2 : Math.PI / 2);
|
|
173105
|
+
return applyToPoint215(transform, {
|
|
173106
|
+
x: end * length / 2 + segment.width / 2 * Math.cos(angle),
|
|
173107
|
+
y: segment.width / 2 * Math.sin(angle)
|
|
173108
|
+
});
|
|
173109
|
+
}));
|
|
173110
|
+
}
|
|
173111
|
+
function roundedOutline(options) {
|
|
173112
|
+
positiveFinite(options.width, "outline width");
|
|
173113
|
+
positiveFinite(options.height, "outline height");
|
|
173114
|
+
const radius = options.radius ?? Math.min(options.width, options.height) / 2;
|
|
173115
|
+
if (!Number.isFinite(radius) || radius <= 0 || radius > Math.min(options.width, options.height) / 2 + 0.0000001)
|
|
173116
|
+
throw new Error("Invalid rounded copper radius");
|
|
173117
|
+
const transform = compose25(translate25(options.x, options.y), rotateDEG23(options.ccwRotationDegrees ?? 0));
|
|
173118
|
+
return Array.from({ length: 32 }, (_, index) => {
|
|
173119
|
+
const angle = index * 2 * Math.PI / 32;
|
|
173120
|
+
const x = Math.cos(angle), y = Math.sin(angle);
|
|
173121
|
+
return applyToPoint215(transform, {
|
|
173122
|
+
x: Math.sign(x) * (options.width / 2 - radius) + radius * x,
|
|
173123
|
+
y: Math.sign(y) * (options.height / 2 - radius) + radius * y
|
|
173124
|
+
});
|
|
173125
|
+
});
|
|
173126
|
+
}
|
|
173127
|
+
function smtPadOutline(pad) {
|
|
173128
|
+
if (pad.shape === "polygon")
|
|
173129
|
+
return pad.points;
|
|
173130
|
+
if (pad.shape === "circle")
|
|
173131
|
+
return roundedOutline({
|
|
173132
|
+
...pad,
|
|
173133
|
+
width: 2 * pad.radius,
|
|
173134
|
+
height: 2 * pad.radius
|
|
173135
|
+
});
|
|
173136
|
+
if (pad.shape === "rect" || pad.shape === "rotated_rect")
|
|
173137
|
+
return rectangleOutline2({
|
|
173138
|
+
center: pad,
|
|
173139
|
+
width: pad.width,
|
|
173140
|
+
height: pad.height,
|
|
173141
|
+
rotation: pad.shape === "rotated_rect" ? pad.ccw_rotation : 0
|
|
173142
|
+
});
|
|
173143
|
+
if (pad.shape === "pill" || pad.shape === "rotated_pill")
|
|
173144
|
+
return roundedOutline({
|
|
173145
|
+
...pad,
|
|
173146
|
+
radius: pad.radius,
|
|
173147
|
+
ccwRotationDegrees: pad.shape === "rotated_pill" ? pad.ccw_rotation : 0
|
|
173148
|
+
});
|
|
173149
|
+
throw new Error("Unsupported SMT pad shape");
|
|
173150
|
+
}
|
|
173151
|
+
function drillOutline(hole) {
|
|
173152
|
+
if ("hole_diameter" in hole)
|
|
173153
|
+
return roundedOutline({
|
|
173154
|
+
x: hole.x + ("hole_offset_x" in hole ? hole.hole_offset_x ?? 0 : 0),
|
|
173155
|
+
y: hole.y + ("hole_offset_y" in hole ? hole.hole_offset_y ?? 0 : 0),
|
|
173156
|
+
width: positiveFinite(hole.hole_diameter ?? 0, "hole diameter"),
|
|
173157
|
+
height: positiveFinite(hole.hole_diameter ?? 0, "hole diameter")
|
|
173158
|
+
});
|
|
173159
|
+
if ("hole_width" in hole)
|
|
173160
|
+
return roundedOutline({
|
|
173161
|
+
...hole,
|
|
173162
|
+
width: positiveFinite(hole.hole_width ?? 0, "hole width"),
|
|
173163
|
+
height: positiveFinite(hole.hole_height ?? 0, "hole height"),
|
|
173164
|
+
ccwRotationDegrees: "ccw_rotation" in hole ? hole.ccw_rotation : 0
|
|
173165
|
+
});
|
|
173166
|
+
throw new Error("Unsupported drilled hole shape");
|
|
173167
|
+
}
|
|
173168
|
+
function platedPadOutline(hole) {
|
|
173169
|
+
if ("outer_diameter" in hole)
|
|
173170
|
+
return roundedOutline({
|
|
173171
|
+
...hole,
|
|
173172
|
+
width: hole.outer_diameter,
|
|
173173
|
+
height: hole.outer_diameter
|
|
173174
|
+
});
|
|
173175
|
+
if ("outer_width" in hole)
|
|
173176
|
+
return roundedOutline({
|
|
173177
|
+
...hole,
|
|
173178
|
+
width: hole.outer_width,
|
|
173179
|
+
height: hole.outer_height,
|
|
173180
|
+
ccwRotationDegrees: "ccw_rotation" in hole ? hole.ccw_rotation : 0
|
|
173181
|
+
});
|
|
173182
|
+
if (hole.shape === "circular_hole_with_rect_pad")
|
|
173183
|
+
return rectangleOutline2({
|
|
173184
|
+
center: hole,
|
|
173185
|
+
width: hole.rect_pad_width,
|
|
173186
|
+
height: hole.rect_pad_height,
|
|
173187
|
+
rotation: hole.rect_ccw_rotation
|
|
173188
|
+
});
|
|
173189
|
+
throw new Error(`Unsupported plated hole shape: ${hole.shape}`);
|
|
173190
|
+
}
|
|
173191
|
+
function parseFabricationStackup(input) {
|
|
173192
|
+
return fabricationStackupSchema2.parse(input);
|
|
173193
|
+
}
|
|
173194
|
+
function physicalStackup(options) {
|
|
173195
|
+
const stackup = parseFabricationStackup(options.stackup);
|
|
173196
|
+
const expected = [
|
|
173197
|
+
"top",
|
|
173198
|
+
...Array.from({ length: options.numLayers - 2 }, (_, i) => `inner${i + 1}`),
|
|
173199
|
+
"bottom"
|
|
173200
|
+
];
|
|
173201
|
+
if (stackup.layers.length !== 2 * options.numLayers - 1)
|
|
173202
|
+
throw new Error("Stackup must alternate copper and dielectric, top to bottom");
|
|
173203
|
+
const copperLayers = [];
|
|
173204
|
+
const dielectrics = [];
|
|
173205
|
+
const bottom = stackup.layers.at(-1);
|
|
173206
|
+
if (!bottom || !("name" in bottom) || bottom.name !== "bottom")
|
|
173207
|
+
throw new Error("Stackup must end with bottom copper");
|
|
173208
|
+
let z2 = -bottom.copperThicknessMm;
|
|
173209
|
+
for (let index = stackup.layers.length - 1;index >= 0; index--) {
|
|
173210
|
+
const layer = stackup.layers[index];
|
|
173211
|
+
if (index % 2 === 0) {
|
|
173212
|
+
if (!("name" in layer) || layer.name !== expected[index / 2])
|
|
173213
|
+
throw new Error(`Stackup copper order must be ${expected.join(", ")}`);
|
|
173214
|
+
const thickness = positiveFinite(layer.copperThicknessMm, "copper thickness");
|
|
173215
|
+
copperLayers.unshift({ name: layer.name, zMin: z2, zMax: z2 + thickness });
|
|
173216
|
+
z2 += thickness;
|
|
173217
|
+
} else {
|
|
173218
|
+
if (!("material" in layer))
|
|
173219
|
+
throw new Error("A dielectric is required between copper layers");
|
|
173220
|
+
dielectrics.push({
|
|
173221
|
+
material: layer.material,
|
|
173222
|
+
zMin: z2,
|
|
173223
|
+
zMax: z2 + layer.dielectricThicknessMm,
|
|
173224
|
+
dielectricConstant: layer.dielectricConstant,
|
|
173225
|
+
lossTangent: layer.lossTangent ?? options.lossTangent,
|
|
173226
|
+
attribute: 100 + index
|
|
173227
|
+
});
|
|
173228
|
+
z2 += layer.dielectricThicknessMm;
|
|
173229
|
+
}
|
|
173230
|
+
}
|
|
173231
|
+
return {
|
|
173232
|
+
copperLayers,
|
|
173233
|
+
dielectrics,
|
|
173234
|
+
physicalThicknessMm: z2 + bottom.copperThicknessMm,
|
|
173235
|
+
nominalBoardThicknessMm: stackup.nominalBoardThicknessMm
|
|
173236
|
+
};
|
|
173237
|
+
}
|
|
173238
|
+
function createMultilayerPalaceModel(options) {
|
|
173239
|
+
options = {
|
|
173240
|
+
...options,
|
|
173241
|
+
circuitJson: options.circuitJson.map((e) => e.type === "pcb_trace" ? normalizeLayeredRoute2(e) : e)
|
|
173242
|
+
};
|
|
173243
|
+
const boards = options.circuitJson.filter((e) => e.type === "pcb_board");
|
|
173244
|
+
if (boards.length !== 1)
|
|
173245
|
+
throw new Error("Exactly one PCB board is required");
|
|
173246
|
+
const board = boards[0];
|
|
173247
|
+
if (options.copperModel === "surface_impedance_copper")
|
|
173248
|
+
throw new Error("surface_impedance_copper currently supports the two-layer mesher only");
|
|
173249
|
+
if (!options.stackup)
|
|
173250
|
+
throw new Error("Multilayer boards require an explicit fabrication stackup (--stackup-file); layer spacing is not inferred");
|
|
173251
|
+
if (!Number.isInteger(board.num_layers) || board.num_layers < 2)
|
|
173252
|
+
throw new Error("Board must have at least two copper layers");
|
|
173253
|
+
if (options.layerSeparation !== undefined || options.copperThickness !== undefined)
|
|
173254
|
+
throw new Error("Use stackup layer thicknesses instead of layerSeparation/copperThickness");
|
|
173255
|
+
const substrateLossTangent = options.substrateLossTangent ?? 0.02;
|
|
173256
|
+
if (!Number.isFinite(substrateLossTangent) || substrateLossTangent < 0)
|
|
173257
|
+
throw new Error("substrateLossTangent must be finite and nonnegative");
|
|
173258
|
+
const stackup = physicalStackup({
|
|
173259
|
+
stackup: options.stackup,
|
|
173260
|
+
numLayers: board.num_layers,
|
|
173261
|
+
lossTangent: substrateLossTangent
|
|
173262
|
+
});
|
|
173263
|
+
const sampleLayer = options.sampleLayer ?? "bottom";
|
|
173264
|
+
const sampleFoil = stackup.copperLayers.find((layer) => layer.name === sampleLayer);
|
|
173265
|
+
if (!sampleFoil)
|
|
173266
|
+
throw new Error(`Sample layer ${sampleLayer} is absent from the stackup`);
|
|
173267
|
+
const frequencyHz = positiveFinite(options.frequencyHz, "frequencyHz");
|
|
173268
|
+
const copperConductivity = positiveFinite(options.copperConductivity ?? 58000000, "copperConductivity");
|
|
173269
|
+
const skinDepthMm = 1000 / Math.sqrt(Math.PI * frequencyHz * 0.0000004 * Math.PI * copperConductivity);
|
|
173270
|
+
if (stackup.copperLayers.some((layer) => layer.zMax - layer.zMin > skinDepthMm))
|
|
173271
|
+
throw new Error("The current volume mesher requires copper thickness <= skin depth; refine copper through its thickness before using higher frequencies");
|
|
173272
|
+
const excitations = options.excitations ?? options.circuitJson.filter((e) => e.type === "simulation_return_current_excitation");
|
|
173273
|
+
const groundIds = new Set(excitations.map((e) => e.ground_source_net_id));
|
|
173274
|
+
if (!excitations.length || groundIds.size !== 1)
|
|
173275
|
+
throw new Error("Specify excitations sharing one reference net");
|
|
173276
|
+
const referenceNetId = excitations[0].ground_source_net_id;
|
|
173277
|
+
const connectivity = copperConnectivity(options.circuitJson);
|
|
173278
|
+
const boardOutline = board.outline?.length ? board.outline : rectangleOutline2({
|
|
173279
|
+
center: board.center,
|
|
173280
|
+
width: board.width ?? 0,
|
|
173281
|
+
height: board.height ?? 0
|
|
173282
|
+
});
|
|
173283
|
+
const boardCutouts = options.circuitJson.filter((e) => e.type === "pcb_cutout").map(cutoutOutline);
|
|
173284
|
+
const viaClearance = positiveFinite(options.viaClearance ?? board.min_trace_to_pad_edge_clearance ?? 0.2, "viaClearance");
|
|
173285
|
+
const layered = {
|
|
173286
|
+
stackup,
|
|
173287
|
+
sampleLayer,
|
|
173288
|
+
referenceNetId,
|
|
173289
|
+
viaClearance,
|
|
173290
|
+
copper: [],
|
|
173291
|
+
drills: [],
|
|
173292
|
+
barrels: [],
|
|
173293
|
+
boardCutouts,
|
|
173294
|
+
audit: {
|
|
173295
|
+
traces: 0,
|
|
173296
|
+
vias: 0,
|
|
173297
|
+
pads: 0,
|
|
173298
|
+
platedHoles: 0,
|
|
173299
|
+
unplatedHoles: 0,
|
|
173300
|
+
pours: 0,
|
|
173301
|
+
omittedCopperElements: 0,
|
|
173302
|
+
warnings: [
|
|
173303
|
+
"Component bodies, package/decoupling impedances, solder mask and silkscreen are not modeled.",
|
|
173304
|
+
"Circular copper/drills use 32-sided polygons. Via plating uses 0.025 mm; override support is not yet provided."
|
|
173305
|
+
]
|
|
173306
|
+
}
|
|
173307
|
+
};
|
|
173308
|
+
const copperByKey = new Map;
|
|
173309
|
+
for (const layer of stackup.copperLayers)
|
|
173310
|
+
for (const netId of [referenceNetId]) {
|
|
173311
|
+
const copper = { layer: layer.name, netId, regions: [], segments: [] };
|
|
173312
|
+
copperByKey.set(`${layer.name}:${netId}`, copper);
|
|
173313
|
+
layered.copper.push(copper);
|
|
173314
|
+
}
|
|
173315
|
+
const traces = new Map;
|
|
173316
|
+
for (const element of options.circuitJson) {
|
|
173317
|
+
if (element.type === "pcb_trace")
|
|
173318
|
+
traces.set(element.pcb_trace_id, element);
|
|
173319
|
+
const additions = [];
|
|
173320
|
+
if (element.type === "pcb_copper_pour") {
|
|
173321
|
+
if (!element.source_net_id)
|
|
173322
|
+
throw new Error("Copper pours require a source_net_id");
|
|
173323
|
+
additions.push({
|
|
173324
|
+
layer: element.layer,
|
|
173325
|
+
netId: connectivity.owner(element.source_net_id),
|
|
173326
|
+
region: { ...pourRegion2(element), isPlane: true }
|
|
173327
|
+
});
|
|
173328
|
+
layered.audit.pours++;
|
|
173329
|
+
}
|
|
173330
|
+
if (element.type === "pcb_ground_plane_region") {
|
|
173331
|
+
const plane = options.circuitJson.find((e) => e.type === "pcb_ground_plane" && e.pcb_ground_plane_id === element.pcb_ground_plane_id);
|
|
173332
|
+
if (!plane || plane.type !== "pcb_ground_plane")
|
|
173333
|
+
throw new Error("Missing ground-plane owner");
|
|
173334
|
+
additions.push({
|
|
173335
|
+
layer: element.layer,
|
|
173336
|
+
netId: connectivity.owner(plane.source_net_id),
|
|
173337
|
+
region: { outer: element.points, holes: [], isPlane: true }
|
|
173338
|
+
});
|
|
173339
|
+
}
|
|
173340
|
+
if (element.type === "pcb_smtpad") {
|
|
173341
|
+
const netId = element.pcb_port_id ? connectivity.pcbPorts.get(element.pcb_port_id) : undefined;
|
|
173342
|
+
if (!netId)
|
|
173343
|
+
throw new Error(`Pad ${element.pcb_smtpad_id} has no PCB port ownership`);
|
|
173344
|
+
additions.push({
|
|
173345
|
+
layer: element.layer,
|
|
173346
|
+
netId,
|
|
173347
|
+
region: { outer: smtPadOutline(element), holes: [] }
|
|
173348
|
+
});
|
|
173349
|
+
layered.audit.pads++;
|
|
173350
|
+
}
|
|
173351
|
+
if (element.type === "pcb_trace") {
|
|
173352
|
+
const netId = connectivity.pcbTraces.get(element.pcb_trace_id);
|
|
173353
|
+
validateLayeredRoute(element, stackup.copperLayers.map((l) => l.name));
|
|
173354
|
+
for (let index = 1;index < element.route.length; index++) {
|
|
173355
|
+
const start = element.route[index - 1], end = element.route[index];
|
|
173356
|
+
if (start.route_type !== "wire" || end.route_type !== "wire")
|
|
173357
|
+
continue;
|
|
173358
|
+
if (start.x === end.x && start.y === end.y)
|
|
173359
|
+
continue;
|
|
173360
|
+
additions.push({
|
|
173361
|
+
layer: start.layer,
|
|
173362
|
+
netId,
|
|
173363
|
+
segment: {
|
|
173364
|
+
start,
|
|
173365
|
+
end,
|
|
173366
|
+
width: positiveFinite(start.width, "trace width"),
|
|
173367
|
+
current: 0
|
|
173368
|
+
}
|
|
173369
|
+
});
|
|
173370
|
+
}
|
|
173371
|
+
layered.audit.traces++;
|
|
173372
|
+
}
|
|
173373
|
+
for (const addition of additions) {
|
|
173374
|
+
if (!stackup.copperLayers.some((l) => l.name === addition.layer))
|
|
173375
|
+
throw new Error(`Copper on absent layer ${addition.layer}`);
|
|
173376
|
+
const key = `${addition.layer}:${addition.netId}`;
|
|
173377
|
+
let copper = copperByKey.get(key);
|
|
173378
|
+
if (!copper) {
|
|
173379
|
+
copper = {
|
|
173380
|
+
layer: addition.layer,
|
|
173381
|
+
netId: addition.netId,
|
|
173382
|
+
regions: [],
|
|
173383
|
+
segments: []
|
|
173384
|
+
};
|
|
173385
|
+
copperByKey.set(key, copper);
|
|
173386
|
+
layered.copper.push(copper);
|
|
173387
|
+
}
|
|
173388
|
+
if (addition.region)
|
|
173389
|
+
copper.regions.push(addition.region);
|
|
173390
|
+
if (addition.segment)
|
|
173391
|
+
copper.segments.push(addition.segment);
|
|
173392
|
+
}
|
|
173393
|
+
}
|
|
173394
|
+
const bottom = stackup.copperLayers.at(-1), top = stackup.copperLayers[0];
|
|
173395
|
+
for (const element of options.circuitJson) {
|
|
173396
|
+
if (element.type === "pcb_hole") {
|
|
173397
|
+
layered.drills.push({
|
|
173398
|
+
hole: drillOutline(element),
|
|
173399
|
+
zMin: bottom.zMin,
|
|
173400
|
+
zMax: top.zMax
|
|
173401
|
+
});
|
|
173402
|
+
layered.audit.unplatedHoles++;
|
|
173403
|
+
}
|
|
173404
|
+
if (element.type !== "pcb_via" && element.type !== "pcb_plated_hole")
|
|
173405
|
+
continue;
|
|
173406
|
+
const netId = element.type === "pcb_via" ? element.source_net_id ? connectivity.owner(element.source_net_id) : element.pcb_trace_id ? connectivity.pcbTraces.get(element.pcb_trace_id) : undefined : element.pcb_port_id ? connectivity.pcbPorts.get(element.pcb_port_id) : undefined;
|
|
173407
|
+
if (!netId)
|
|
173408
|
+
throw new Error(`Cannot identify copper ownership for ${element.type}; supply source_net_id or pcb_trace_id`);
|
|
173409
|
+
const layers = element.layers.map((name) => stackup.copperLayers.find((l) => l.name === name));
|
|
173410
|
+
if (!layers.length || layers.some((l) => !l))
|
|
173411
|
+
throw new Error("Via/hole references an absent stackup layer");
|
|
173412
|
+
const zMin = Math.min(...layers.map((l) => l.zMin)), zMax = Math.max(...layers.map((l) => l.zMax));
|
|
173413
|
+
const hole = element.type === "pcb_via" ? roundedOutline({
|
|
173414
|
+
...element,
|
|
173415
|
+
width: element.hole_diameter,
|
|
173416
|
+
height: element.hole_diameter
|
|
173417
|
+
}) : drillOutline(element);
|
|
173418
|
+
const pads = element.type === "pcb_via" ? roundedOutline({
|
|
173419
|
+
...element,
|
|
173420
|
+
width: element.outer_diameter,
|
|
173421
|
+
height: element.outer_diameter
|
|
173422
|
+
}) : platedPadOutline(element);
|
|
173423
|
+
layered.barrels.push({
|
|
173424
|
+
netId,
|
|
173425
|
+
hole,
|
|
173426
|
+
outer: hole,
|
|
173427
|
+
pads,
|
|
173428
|
+
layers: element.layers,
|
|
173429
|
+
zMin,
|
|
173430
|
+
zMax,
|
|
173431
|
+
platingThickness: 0.025
|
|
173432
|
+
});
|
|
173433
|
+
layered.drills.push({ hole, zMin, zMax });
|
|
173434
|
+
if (element.type === "pcb_via")
|
|
173435
|
+
layered.audit.vias++;
|
|
173436
|
+
else
|
|
173437
|
+
layered.audit.platedHoles++;
|
|
173438
|
+
}
|
|
173439
|
+
for (const trace of traces.values())
|
|
173440
|
+
for (const route of trace.route) {
|
|
173441
|
+
if (route.route_type !== "via")
|
|
173442
|
+
continue;
|
|
173443
|
+
const barrel = layered.barrels.find((b) => b.netId === connectivity.pcbTraces.get(trace.pcb_trace_id) && pointInPolygon2(route, b.hole) && b.layers.includes(route.from_layer) && b.layers.includes(route.to_layer));
|
|
173444
|
+
if (!barrel)
|
|
173445
|
+
throw new Error(`Trace ${trace.pcb_trace_id} changes layer without a matching physical pcb_via`);
|
|
173446
|
+
}
|
|
173447
|
+
for (const barrel of layered.barrels)
|
|
173448
|
+
barrel.clearance = barrelClearanceOutline(barrel, viaClearance);
|
|
173449
|
+
const groundCopper = copperByKey.get(`${sampleLayer}:${referenceNetId}`);
|
|
173450
|
+
if (!groundCopper.regions.length)
|
|
173451
|
+
throw new Error(`Reference net needs copper regions on sample layer ${sampleLayer}; no plane is invented`);
|
|
173452
|
+
const geometry = {
|
|
173453
|
+
board,
|
|
173454
|
+
boardOutline,
|
|
173455
|
+
groundRegions: groundCopper.regions.map((region) => ({
|
|
173456
|
+
...region,
|
|
173457
|
+
holes: [...region.holes]
|
|
173458
|
+
})),
|
|
173459
|
+
cutouts: [...boardCutouts],
|
|
173460
|
+
signals: [],
|
|
173461
|
+
segments: [],
|
|
173462
|
+
excitations
|
|
173463
|
+
};
|
|
173464
|
+
for (const segment of groundCopper.segments) {
|
|
173465
|
+
geometry.groundRegions.push({
|
|
173466
|
+
outer: traceOutline(segment),
|
|
173467
|
+
holes: []
|
|
173468
|
+
});
|
|
173469
|
+
}
|
|
173470
|
+
for (const barrel of layered.barrels) {
|
|
173471
|
+
if (barrel.zMin > sampleFoil.zMin || barrel.zMax < sampleFoil.zMax)
|
|
173472
|
+
continue;
|
|
173473
|
+
if (barrel.netId === referenceNetId && barrel.layers.includes(sampleLayer))
|
|
173474
|
+
geometry.groundRegions.push({ outer: barrel.pads, holes: [barrel.hole] });
|
|
173475
|
+
else if (barrel.netId !== referenceNetId) {
|
|
173476
|
+
for (const region of geometry.groundRegions.filter((region2) => region2.isPlane))
|
|
173477
|
+
region.maskCutouts = [
|
|
173478
|
+
...region.maskCutouts ?? [],
|
|
173479
|
+
barrelClearanceOutline(barrel, viaClearance)
|
|
173480
|
+
];
|
|
173481
|
+
}
|
|
173482
|
+
}
|
|
173483
|
+
for (const drill of layered.drills)
|
|
173484
|
+
if (drill.zMin <= sampleFoil.zMin && drill.zMax >= sampleFoil.zMax)
|
|
173485
|
+
geometry.cutouts.push(drill.hole);
|
|
173486
|
+
const ports = [];
|
|
173487
|
+
for (const excitation of excitations) {
|
|
173488
|
+
if (!Number.isFinite(excitation.current))
|
|
173489
|
+
throw new Error("Excitation current must be finite");
|
|
173490
|
+
const trace = traces.get(excitation.pcb_trace_id);
|
|
173491
|
+
if (!trace)
|
|
173492
|
+
throw new Error("Missing excited trace");
|
|
173493
|
+
geometry.signals.push(trace);
|
|
173494
|
+
for (let index = 1;index < trace.route.length; index++) {
|
|
173495
|
+
const start = trace.route[index - 1], end = trace.route[index];
|
|
173496
|
+
if (start.route_type === "wire" && end.route_type === "wire" && (start.x !== end.x || start.y !== end.y))
|
|
173497
|
+
geometry.segments.push({
|
|
173498
|
+
start,
|
|
173499
|
+
end,
|
|
173500
|
+
width: start.width,
|
|
173501
|
+
current: excitation.current
|
|
173502
|
+
});
|
|
173503
|
+
}
|
|
173504
|
+
for (const [endpoint, reference, terminal] of [
|
|
173505
|
+
[trace.route[0], excitation.return_sink, excitation.source_port],
|
|
173506
|
+
[trace.route.at(-1), excitation.return_source, excitation.load_port]
|
|
173507
|
+
]) {
|
|
173508
|
+
if (endpoint.route_type !== "wire")
|
|
173509
|
+
throw new Error("Ports require wire endpoints");
|
|
173510
|
+
const signalFoil = stackup.copperLayers.find((l) => l.name === endpoint.layer);
|
|
173511
|
+
const referenceFoil = stackup.copperLayers.find((l) => l.name === (terminal?.reference_layer ?? sampleLayer));
|
|
173512
|
+
if (!referenceFoil)
|
|
173513
|
+
throw new Error("Reference layer is absent");
|
|
173514
|
+
if (terminal?.reference_pcb_port_id && connectivity.pcbPorts.get(terminal.reference_pcb_port_id) !== referenceNetId)
|
|
173515
|
+
throw new Error("Reference pin is not on the reference net");
|
|
173516
|
+
for (const [id, position, layer] of [
|
|
173517
|
+
[terminal?.signal_pcb_port_id, endpoint, endpoint.layer],
|
|
173518
|
+
[terminal?.reference_pcb_port_id, reference, referenceFoil.name]
|
|
173519
|
+
]) {
|
|
173520
|
+
if (!id)
|
|
173521
|
+
continue;
|
|
173522
|
+
const pcb = options.circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === id);
|
|
173523
|
+
if (!pcb || pcb.type !== "pcb_port" || !pcb.layers.includes(layer) || Math.hypot(pcb.x - position.x, pcb.y - position.y) > 0.000001)
|
|
173524
|
+
throw new Error("Terminal does not match its PCB port position/layer");
|
|
173525
|
+
}
|
|
173526
|
+
const below = referenceFoil.zMin < signalFoil.zMin;
|
|
173527
|
+
ports.push({
|
|
173528
|
+
signal: { x: endpoint.x, y: endpoint.y },
|
|
173529
|
+
reference: { x: reference.x, y: reference.y },
|
|
173530
|
+
signalLayer: signalFoil.name,
|
|
173531
|
+
referenceLayer: referenceFoil.name,
|
|
173532
|
+
signalZ: below ? signalFoil.zMin : signalFoil.zMax,
|
|
173533
|
+
referenceZ: below ? referenceFoil.zMax : referenceFoil.zMin === signalFoil.zMin ? signalFoil.zMax : referenceFoil.zMin,
|
|
173534
|
+
resistance: positiveFinite(terminal?.resistance ?? options.portResistance ?? 50, "port resistance"),
|
|
173535
|
+
signalPcbPortId: terminal?.signal_pcb_port_id,
|
|
173536
|
+
referencePcbPortId: terminal?.reference_pcb_port_id
|
|
173537
|
+
});
|
|
173538
|
+
}
|
|
173539
|
+
}
|
|
173540
|
+
if (stackup.nominalBoardThicknessMm && Math.abs(stackup.physicalThicknessMm - stackup.nominalBoardThicknessMm) > 0.0001)
|
|
173541
|
+
layered.audit.warnings.push(`Explicit stack sums to ${stackup.physicalThicknessMm} mm, nominal ${stackup.nominalBoardThicknessMm} mm; explicit dimensions are used without scaling.`);
|
|
173542
|
+
geometry.physicalModelSignature = JSON.stringify({
|
|
173543
|
+
...layered,
|
|
173544
|
+
traceEndCaps: "round_32",
|
|
173545
|
+
audit: undefined
|
|
173546
|
+
});
|
|
173547
|
+
const order = options.order ?? 2;
|
|
173548
|
+
if (order !== 1 && order !== 2)
|
|
173549
|
+
throw new Error("order must be 1 or 2");
|
|
173550
|
+
return {
|
|
173551
|
+
schemaVersion: 1,
|
|
173552
|
+
multilayer: layered,
|
|
173553
|
+
geometry,
|
|
173554
|
+
ports,
|
|
173555
|
+
topPads: [],
|
|
173556
|
+
frequencyHz,
|
|
173557
|
+
layerSeparation: top.zMin - bottom.zMax,
|
|
173558
|
+
copperThickness: sampleFoil.zMax - sampleFoil.zMin,
|
|
173559
|
+
copperConductivity,
|
|
173560
|
+
substratePermittivity: options.substratePermittivity ?? 4.3,
|
|
173561
|
+
substrateLossTangent,
|
|
173562
|
+
portResistance: positiveFinite(options.portResistance ?? 50, "portResistance"),
|
|
173563
|
+
portWidth: positiveFinite(options.portWidth ?? 0.18, "portWidth"),
|
|
173564
|
+
meshSize: positiveFinite(options.meshSize ?? 1, "meshSize"),
|
|
173565
|
+
airPadding: positiveFinite(options.airPadding ?? 10, "airPadding"),
|
|
173566
|
+
order,
|
|
173567
|
+
copperModel: "volumetric_copper"
|
|
173568
|
+
};
|
|
173569
|
+
}
|
|
173570
|
+
function validateLayeredRoute(trace, layers) {
|
|
173571
|
+
for (const [index, route] of trace.route.entries()) {
|
|
173572
|
+
if (route.route_type !== "wire" && route.route_type !== "via")
|
|
173573
|
+
throw new Error("Unsupported route point type");
|
|
173574
|
+
if (![route.x, route.y].every(Number.isFinite))
|
|
173575
|
+
throw new Error("Trace coordinates must be finite");
|
|
173576
|
+
if (route.route_type === "wire") {
|
|
173577
|
+
positiveFinite(route.width, "trace width");
|
|
173578
|
+
if (!layers.includes(route.layer))
|
|
173579
|
+
throw new Error(`Unknown route layer ${route.layer}`);
|
|
173580
|
+
const previous = trace.route[index - 1];
|
|
173581
|
+
if (previous?.route_type === "wire" && previous.layer !== route.layer)
|
|
173582
|
+
throw new Error("Layer transitions require a via route point");
|
|
173583
|
+
} else if (route.route_type === "via") {
|
|
173584
|
+
const previous = trace.route[index - 1], next = trace.route[index + 1];
|
|
173585
|
+
if (previous?.route_type !== "wire" || next?.route_type !== "wire" || previous.layer !== route.from_layer || next.layer !== route.to_layer || Math.hypot(previous.x - route.x, previous.y - route.y) > 0.000001 || Math.hypot(next.x - route.x, next.y - route.y) > 0.000001)
|
|
173586
|
+
throw new Error("Via route must join matching, colocated layer endpoints");
|
|
173587
|
+
} else
|
|
173588
|
+
throw new Error("Unsupported route point type");
|
|
173589
|
+
}
|
|
173590
|
+
}
|
|
173591
|
+
function barrelClearanceOutline(barrel, clearance) {
|
|
173592
|
+
const xs = barrel.pads.map((p) => p.x), ys = barrel.pads.map((p) => p.y);
|
|
173593
|
+
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
173594
|
+
return rectangleOutline2({
|
|
173595
|
+
center: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
|
|
173596
|
+
width: maxX - minX + 2 * clearance,
|
|
173597
|
+
height: maxY - minY + 2 * clearance
|
|
173598
|
+
});
|
|
173599
|
+
}
|
|
173600
|
+
function createPalaceModel(options) {
|
|
173601
|
+
if (options.stackup || options.circuitJson.some((element) => element.type === "pcb_board" && element.num_layers > 2))
|
|
173602
|
+
return createMultilayerPalaceModel(options);
|
|
173603
|
+
if (options.sampleLayer && options.sampleLayer !== "bottom")
|
|
173604
|
+
throw new Error("A non-bottom sample layer requires an explicit stackup");
|
|
173605
|
+
const frequencyHz = positiveFinite(options.frequencyHz, "frequencyHz");
|
|
173606
|
+
const geometry = readGeometry({
|
|
173607
|
+
...options,
|
|
173608
|
+
circuitJson: options.circuitJson.filter((element) => element.type !== "pcb_via")
|
|
173609
|
+
});
|
|
173610
|
+
const layerSeparation = positiveFinite(options.layerSeparation ?? geometry.board.thickness ?? 0.8, "layerSeparation");
|
|
173611
|
+
const copperThickness = positiveFinite(options.copperThickness ?? 0.035, "copperThickness");
|
|
173612
|
+
const copperConductivity = positiveFinite(options.copperConductivity ?? 58000000, "copperConductivity");
|
|
173613
|
+
const skinDepthMm = 1000 / Math.sqrt(Math.PI * frequencyHz * 0.0000004 * Math.PI * copperConductivity);
|
|
173614
|
+
const copperModel = options.copperModel ?? "volumetric_copper";
|
|
173615
|
+
if (copperModel !== "volumetric_copper" && copperModel !== "surface_impedance_copper")
|
|
173616
|
+
throw new Error("Unknown copperModel");
|
|
173617
|
+
if (copperModel === "surface_impedance_copper" && copperThickness < 3 * skinDepthMm)
|
|
173618
|
+
throw new Error("The half-space surface-impedance model requires foil and via-wall thickness >= 3 skin depths; use volumetric copper at lower frequencies");
|
|
173619
|
+
if (copperModel === "volumetric_copper" && copperThickness > skinDepthMm)
|
|
173620
|
+
throw new Error("The current volume mesher requires copper thickness <= skin depth; refine copper through its thickness before using higher frequencies");
|
|
173621
|
+
const ports = [];
|
|
173622
|
+
for (const [index, signal] of geometry.signals.entries()) {
|
|
173623
|
+
const first = signal.route[0];
|
|
173624
|
+
const last = signal.route.at(-1);
|
|
173625
|
+
const excitation = geometry.excitations[index];
|
|
173626
|
+
if (!last || first.route_type !== "wire" || last.route_type !== "wire")
|
|
173627
|
+
throw new Error("Palace requires wire endpoints");
|
|
173628
|
+
for (const [endpoint, contact, terminal] of [
|
|
173629
|
+
[first, excitation.return_sink, excitation.source_port],
|
|
173630
|
+
[last, excitation.return_source, excitation.load_port]
|
|
173631
|
+
]) {
|
|
173632
|
+
if (!terminal && Math.hypot(endpoint.x - contact.x, endpoint.y - contact.y) > 0.000001)
|
|
173633
|
+
throw new Error("Legacy Palace ports require return contacts directly below signal endpoints; specify explicit terminal metadata for offset references");
|
|
173634
|
+
for (const [id, position, role] of [
|
|
173635
|
+
[terminal?.signal_pcb_port_id, endpoint, "signal"],
|
|
173636
|
+
[terminal?.reference_pcb_port_id, contact, "reference"]
|
|
173637
|
+
]) {
|
|
173638
|
+
if (!id)
|
|
173639
|
+
continue;
|
|
173640
|
+
const pcb = options.circuitJson.find((element) => element.type === "pcb_port" && element.pcb_port_id === id);
|
|
173641
|
+
if (!pcb || pcb.type !== "pcb_port" || Math.hypot(pcb.x - position.x, pcb.y - position.y) > 0.000001)
|
|
173642
|
+
throw new Error(`Explicit ${role} terminal does not match its PCB port`);
|
|
173643
|
+
const layer = role === "signal" ? "top" : terminal.reference_layer;
|
|
173644
|
+
if (!pcb.layers.includes(layer))
|
|
173645
|
+
throw new Error(`Explicit ${role} terminal is not on ${layer}`);
|
|
173646
|
+
if (role === "reference" && !portNetIds2(options.circuitJson, pcb.source_port_id).includes(excitation.ground_source_net_id))
|
|
173647
|
+
throw new Error("Explicit reference terminal is not connected to the ground net");
|
|
173648
|
+
}
|
|
173649
|
+
if (terminal?.reference_layer === "top" && !terminal.reference_pcb_port_id)
|
|
173650
|
+
throw new Error("Top reference requires an explicit PCB port");
|
|
173651
|
+
ports.push({
|
|
173652
|
+
signal: { x: endpoint.x, y: endpoint.y },
|
|
173653
|
+
reference: { x: contact.x, y: contact.y },
|
|
173654
|
+
referenceLayer: terminal?.reference_layer ?? "bottom",
|
|
173655
|
+
resistance: positiveFinite(terminal?.resistance ?? options.portResistance ?? 50, "port resistance"),
|
|
173656
|
+
signalPcbPortId: terminal?.signal_pcb_port_id,
|
|
173657
|
+
referencePcbPortId: terminal?.reference_pcb_port_id
|
|
173658
|
+
});
|
|
173659
|
+
}
|
|
173660
|
+
}
|
|
173661
|
+
const referenceIds = new Set(ports.filter((port) => port.referenceLayer === "top").map((port) => port.referencePcbPortId));
|
|
173662
|
+
const topGroundPads = [];
|
|
173663
|
+
const topPads = options.circuitJson.flatMap((element) => {
|
|
173664
|
+
if (element.type !== "pcb_smtpad" || element.layer !== "top")
|
|
173665
|
+
return [];
|
|
173666
|
+
if (element.shape !== "rect")
|
|
173667
|
+
throw new Error("Palace currently supports rectangular top SMT pads");
|
|
173668
|
+
const outline = rectangleOutline2({
|
|
173669
|
+
center: { x: element.x, y: element.y },
|
|
173670
|
+
width: element.width,
|
|
173671
|
+
height: element.height
|
|
173672
|
+
});
|
|
173673
|
+
if (referenceIds.has(element.pcb_port_id)) {
|
|
173674
|
+
topGroundPads.push(outline);
|
|
173675
|
+
return [];
|
|
173676
|
+
}
|
|
173677
|
+
return [outline];
|
|
173678
|
+
});
|
|
173679
|
+
const groundVias = options.circuitJson.flatMap((element) => {
|
|
173680
|
+
if (element.type !== "pcb_via")
|
|
173681
|
+
return [];
|
|
173682
|
+
const pad = options.circuitJson.find((pad2) => pad2.type === "pcb_smtpad" && pad2.shape === "rect" && pad2.layer === "top" && referenceIds.has(pad2.pcb_port_id) && Math.hypot(pad2.x - element.x, pad2.y - element.y) < 0.000001);
|
|
173683
|
+
if (!pad || pad.type !== "pcb_smtpad" || pad.shape !== "rect" || element.source_net_id !== geometry.excitations[0].ground_source_net_id || !element.layers.includes("top") || !element.layers.includes("bottom") || element.layers.length !== 2)
|
|
173684
|
+
throw new Error("Only top-to-bottom ground vias centered in selected rectangular reference pads are supported");
|
|
173685
|
+
const holeDiameter = positiveFinite(element.hole_diameter, "via hole diameter");
|
|
173686
|
+
const outerDiameter = positiveFinite(element.outer_diameter, "via outer diameter");
|
|
173687
|
+
if (holeDiameter + 2 * copperThickness >= outerDiameter || outerDiameter > Math.min(pad.width, pad.height) || !isCopper(element, geometry))
|
|
173688
|
+
throw new Error("Ground via needs a plated barrel inside its pad and selected bottom copper");
|
|
173689
|
+
return [
|
|
173690
|
+
{
|
|
173691
|
+
x: element.x,
|
|
173692
|
+
y: element.y,
|
|
173693
|
+
holeDiameter,
|
|
173694
|
+
outerDiameter,
|
|
173695
|
+
platingThickness: copperThickness
|
|
173696
|
+
}
|
|
173697
|
+
];
|
|
173698
|
+
});
|
|
173699
|
+
for (const port of ports.filter((port2) => port2.referenceLayer === "top")) {
|
|
173700
|
+
if (!groundVias.some((via) => Math.hypot(via.x - port.reference.x, via.y - port.reference.y) < 0.000001))
|
|
173701
|
+
throw new Error("A top reference pad needs a real concentric ground via in circuit-json; ground-pad names alone do not connect copper");
|
|
173702
|
+
}
|
|
173703
|
+
for (const via of groundVias)
|
|
173704
|
+
geometry.cutouts.push(Array.from({ length: 32 }, (_, index) => ({
|
|
173705
|
+
x: via.x + via.holeDiameter / 2 * Math.cos(index * 2 * Math.PI / 32),
|
|
173706
|
+
y: via.y + via.holeDiameter / 2 * Math.sin(index * 2 * Math.PI / 32)
|
|
173707
|
+
})));
|
|
173708
|
+
const substrateLossTangent = options.substrateLossTangent ?? 0.02;
|
|
173709
|
+
if (!Number.isFinite(substrateLossTangent) || substrateLossTangent < 0)
|
|
173710
|
+
throw new Error("substrateLossTangent must be finite and nonnegative");
|
|
173711
|
+
const order = options.order ?? 2;
|
|
173712
|
+
if (order !== 1 && order !== 2)
|
|
173713
|
+
throw new Error("order must be 1 or 2");
|
|
173714
|
+
return {
|
|
173715
|
+
schemaVersion: 1,
|
|
173716
|
+
geometry,
|
|
173717
|
+
topPads,
|
|
173718
|
+
topGroundPads,
|
|
173719
|
+
groundVias,
|
|
173720
|
+
ports,
|
|
173721
|
+
frequencyHz,
|
|
173722
|
+
layerSeparation,
|
|
173723
|
+
copperThickness,
|
|
173724
|
+
copperConductivity,
|
|
173725
|
+
substratePermittivity: positiveFinite(options.substratePermittivity ?? 4.3, "substratePermittivity"),
|
|
173726
|
+
substrateLossTangent,
|
|
173727
|
+
portResistance: positiveFinite(options.portResistance ?? 50, "portResistance"),
|
|
173728
|
+
portWidth: positiveFinite(options.portWidth ?? 0.18, "portWidth"),
|
|
173729
|
+
meshSize: positiveFinite(options.meshSize ?? 1, "meshSize"),
|
|
173730
|
+
airPadding: positiveFinite(options.airPadding ?? 10, "airPadding"),
|
|
173731
|
+
order,
|
|
173732
|
+
copperModel,
|
|
173733
|
+
...copperModel === "surface_impedance_copper" ? {
|
|
173734
|
+
surfaceImpedance: {
|
|
173735
|
+
boundaryModel: "half_space",
|
|
173736
|
+
skinDepthMm,
|
|
173737
|
+
minimumThicknessToSkinDepth: copperThickness / skinDepthMm,
|
|
173738
|
+
currentSampling: "sum_foil_face_surface_currents"
|
|
173739
|
+
}
|
|
173740
|
+
} : {}
|
|
173741
|
+
};
|
|
173742
|
+
}
|
|
173743
|
+
function boundedPolygon(outline) {
|
|
173744
|
+
return {
|
|
173745
|
+
outline,
|
|
173746
|
+
minX: Math.min(...outline.map((p) => p.x)),
|
|
173747
|
+
maxX: Math.max(...outline.map((p) => p.x)),
|
|
173748
|
+
minY: Math.min(...outline.map((p) => p.y)),
|
|
173749
|
+
maxY: Math.max(...outline.map((p) => p.y))
|
|
173750
|
+
};
|
|
173751
|
+
}
|
|
173752
|
+
function contains2(point, polygon) {
|
|
173753
|
+
return point.x >= polygon.minX - 0.000000001 && point.x <= polygon.maxX + 0.000000001 && point.y >= polygon.minY - 0.000000001 && point.y <= polygon.maxY + 0.000000001 && pointInPolygon2(point, polygon.outline);
|
|
173754
|
+
}
|
|
173755
|
+
function createSampleMask(geometry) {
|
|
173756
|
+
const board = boundedPolygon(geometry.boardOutline);
|
|
173757
|
+
const cutouts = geometry.cutouts.map(boundedPolygon);
|
|
173758
|
+
const regions = geometry.groundRegions.map((r) => ({
|
|
173759
|
+
outer: boundedPolygon(r.outer),
|
|
173760
|
+
holes: [...r.holes, ...r.maskCutouts ?? []].map(boundedPolygon)
|
|
173761
|
+
}));
|
|
173762
|
+
return (point) => contains2(point, board) && !cutouts.some((cutout) => contains2(point, cutout)) && regions.some((r) => contains2(point, r.outer) && !r.holes.some((hole) => contains2(point, hole)));
|
|
173763
|
+
}
|
|
173764
|
+
async function writeSampleGrid(options) {
|
|
173765
|
+
const cellSize = positiveFinite(options.cellSize ?? 0.2, "cellSize");
|
|
173766
|
+
const outline = options.geometry.boardOutline;
|
|
173767
|
+
const minX = Math.min(...outline.map((point) => point.x));
|
|
173768
|
+
const maxX = Math.max(...outline.map((point) => point.x));
|
|
173769
|
+
const minY = Math.min(...outline.map((point) => point.y));
|
|
173770
|
+
const maxY = Math.max(...outline.map((point) => point.y));
|
|
173771
|
+
const columns = Math.max(2, Math.ceil((maxX - minX) / cellSize));
|
|
173772
|
+
const rows = Math.max(2, Math.ceil((maxY - minY) / cellSize));
|
|
173773
|
+
if (columns * rows > 1e6)
|
|
173774
|
+
throw new Error("The sample grid exceeds 1,000,000 cells; increase cellSize");
|
|
173775
|
+
const cellWidth = (maxX - minX) / columns;
|
|
173776
|
+
const cellHeight = (maxY - minY) / rows;
|
|
173777
|
+
const containsCopper = createSampleMask(options.geometry);
|
|
173778
|
+
const points = [];
|
|
173779
|
+
for (let row = 0;row < rows; row++)
|
|
173780
|
+
for (let column = 0;column < columns; column++) {
|
|
173781
|
+
const point = {
|
|
173782
|
+
x: minX + (column + 0.5) * cellWidth,
|
|
173783
|
+
y: minY + (row + 0.5) * cellHeight
|
|
173784
|
+
};
|
|
173785
|
+
if (containsCopper(point))
|
|
173786
|
+
points.push(point);
|
|
173787
|
+
}
|
|
173788
|
+
if (!points.length)
|
|
173789
|
+
throw new Error("The sample grid contains no ground copper");
|
|
173790
|
+
await writeFile3(`${options.destination}/sample-grid.json`, JSON.stringify({ columns, rows, cellWidth, cellHeight, points }));
|
|
173791
|
+
return { columns, rows, cellWidth, cellHeight, copperSamples: points.length };
|
|
173792
|
+
}
|
|
173793
|
+
async function preparePalaceCase(options) {
|
|
173794
|
+
const model = createPalaceModel(options);
|
|
173795
|
+
const processes = options.processes ?? Number(process.env.PALACE_PROCESSES ?? 1);
|
|
173796
|
+
if (!Number.isInteger(processes) || processes < 1)
|
|
173797
|
+
throw new Error("processes must be a positive integer");
|
|
173798
|
+
const destination = resolve22(options.destination);
|
|
173799
|
+
await mkdir22(destination, { recursive: true });
|
|
173800
|
+
await writeFile22(`${destination}/circuit.json`, JSON.stringify(options.circuitJson));
|
|
173801
|
+
await writeFile22(`${destination}/model.json`, JSON.stringify(model, null, 2));
|
|
173802
|
+
if (model.multilayer)
|
|
173803
|
+
await writeFile22(`${destination}/model-audit.json`, JSON.stringify(model.multilayer.audit, null, 2));
|
|
173804
|
+
const grid = await writeSampleGrid({
|
|
173805
|
+
destination,
|
|
173806
|
+
geometry: model.geometry,
|
|
173807
|
+
cellSize: options.cellSize
|
|
173808
|
+
});
|
|
173809
|
+
return { model, destination, processes, grid };
|
|
173810
|
+
}
|
|
173811
|
+
async function runPalaceCase(options) {
|
|
173812
|
+
const { model, destination, processes } = await preparePalaceCase(options);
|
|
173813
|
+
const python = palacePython(options.python);
|
|
173814
|
+
const meshScript = palacePythonAsset("mesh.py");
|
|
173815
|
+
const sampleScript = palacePythonAsset("sample.py");
|
|
173816
|
+
console.log(`Meshing ${destination}`);
|
|
173817
|
+
await runCommand2({
|
|
173818
|
+
command: [python, meshScript, `${destination}/model.json`],
|
|
173819
|
+
cwd: destination,
|
|
173820
|
+
logPath: `${destination}/mesh.log`
|
|
173821
|
+
});
|
|
173822
|
+
const palaceBin = options.palaceBin ?? process.env.PALACE_BIN;
|
|
173823
|
+
const command = palaceBin ? [palaceBin, "-np", String(processes), "palace.json"] : [
|
|
173824
|
+
"docker",
|
|
173825
|
+
"run",
|
|
173826
|
+
"--rm",
|
|
173827
|
+
"--network",
|
|
173828
|
+
"none",
|
|
173829
|
+
"--user",
|
|
173830
|
+
`${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`,
|
|
173831
|
+
"--cap-drop",
|
|
173832
|
+
"ALL",
|
|
173833
|
+
"--security-opt",
|
|
173834
|
+
"no-new-privileges",
|
|
173835
|
+
"--mount",
|
|
173836
|
+
`type=bind,src=${destination},dst=/case`,
|
|
173837
|
+
"--workdir",
|
|
173838
|
+
"/case",
|
|
173839
|
+
palaceImage,
|
|
173840
|
+
"palace",
|
|
173841
|
+
"-np",
|
|
173842
|
+
String(processes),
|
|
173843
|
+
"palace.json"
|
|
173844
|
+
];
|
|
173845
|
+
console.log(`Running Palace at ${model.frequencyHz} Hz`);
|
|
173846
|
+
await runCommand2({
|
|
173847
|
+
command,
|
|
173848
|
+
cwd: destination,
|
|
173849
|
+
logPath: `${destination}/palace.log`
|
|
173850
|
+
});
|
|
173851
|
+
console.log("Sampling complex conduction current through ground copper");
|
|
173852
|
+
await runCommand2({
|
|
173853
|
+
command: [python, sampleScript, destination],
|
|
173854
|
+
cwd: destination,
|
|
173855
|
+
logPath: `${destination}/sample.log`
|
|
173856
|
+
});
|
|
173857
|
+
}
|
|
173858
|
+
async function readJson(path) {
|
|
173859
|
+
return JSON.parse(await readFile3(path, "utf8"));
|
|
173860
|
+
}
|
|
173861
|
+
function coordinate(number) {
|
|
173862
|
+
return (number === 0 ? 0 : number).toFixed(9);
|
|
173863
|
+
}
|
|
173864
|
+
function point12(point2) {
|
|
173865
|
+
return [coordinate(point2.x), coordinate(point2.y)];
|
|
173866
|
+
}
|
|
173867
|
+
function palaceGeometrySignature(geometry) {
|
|
173868
|
+
return JSON.stringify([
|
|
173869
|
+
geometry.boardOutline.map(point12),
|
|
173870
|
+
geometry.groundRegions.map((region) => [
|
|
173871
|
+
region.outer.map(point12),
|
|
173872
|
+
[...region.holes, ...region.maskCutouts ?? []].map((hole) => hole.map(point12))
|
|
173873
|
+
]),
|
|
173874
|
+
geometry.cutouts.map((outline) => outline.map(point12)),
|
|
173875
|
+
geometry.signals.map((signal) => signal.route.filter((route) => route.route_type === "wire").map((route) => [
|
|
173876
|
+
...point12(route),
|
|
173877
|
+
coordinate(route.width),
|
|
173878
|
+
route.layer
|
|
173879
|
+
])),
|
|
173880
|
+
...geometry.physicalModelSignature ? [geometry.physicalModelSignature] : [],
|
|
173881
|
+
geometry.excitations.map((excitation) => [
|
|
173882
|
+
coordinate(excitation.current),
|
|
173883
|
+
point12(excitation.return_source),
|
|
173884
|
+
point12(excitation.return_sink),
|
|
173885
|
+
...excitation.source_port || excitation.load_port ? [
|
|
173886
|
+
[
|
|
173887
|
+
excitation.source_port?.reference_layer ?? "bottom",
|
|
173888
|
+
coordinate(excitation.source_port?.resistance ?? 50),
|
|
173889
|
+
excitation.load_port?.reference_layer ?? "bottom",
|
|
173890
|
+
coordinate(excitation.load_port?.resistance ?? 50)
|
|
173891
|
+
]
|
|
173892
|
+
] : []
|
|
173893
|
+
])
|
|
173894
|
+
]);
|
|
173895
|
+
}
|
|
173896
|
+
function currentColor(fraction) {
|
|
173897
|
+
const stops = [
|
|
173898
|
+
[0, 99, 235],
|
|
173899
|
+
[0, 180, 216],
|
|
173900
|
+
[19, 202, 70],
|
|
173901
|
+
[225, 233, 20],
|
|
173902
|
+
[255, 175, 0],
|
|
173903
|
+
[255, 60, 0]
|
|
173904
|
+
];
|
|
173905
|
+
const position = Math.max(0, Math.min(1, fraction)) * (stops.length - 1);
|
|
173906
|
+
const stopIndex = Math.min(stops.length - 2, Math.floor(position));
|
|
173907
|
+
const blend = position - stopIndex;
|
|
173908
|
+
const rgb = stops[stopIndex].map((channel, channelIndex) => Math.round(channel + blend * (stops[stopIndex + 1][channelIndex] - channel)));
|
|
173909
|
+
return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
173910
|
+
}
|
|
173911
|
+
function escapeXml6(text) {
|
|
173912
|
+
return text.replace(/[&<>"']/g, (character) => ({
|
|
173913
|
+
"&": "&",
|
|
173914
|
+
"<": "<",
|
|
173915
|
+
">": ">",
|
|
173916
|
+
'"': """,
|
|
173917
|
+
"'": "'"
|
|
173918
|
+
})[character] ?? character);
|
|
173919
|
+
}
|
|
173920
|
+
function polygonPath(outline) {
|
|
173921
|
+
return `${outline.map((point2, cornerIndex) => `${cornerIndex ? "L" : "M"}${svgNumber(point2.x)},${svgNumber(point2.y)}`).join(" ")} Z`;
|
|
173922
|
+
}
|
|
173923
|
+
function svgNumber(number) {
|
|
173924
|
+
return Number(number.toFixed(5)).toString();
|
|
173925
|
+
}
|
|
173926
|
+
function renderCells(result, maxCurrentDensity) {
|
|
173927
|
+
const pathsByColor = new Map;
|
|
173928
|
+
for (const node of result.nodes) {
|
|
173929
|
+
const color = currentColor(node.currentDensity / maxCurrentDensity);
|
|
173930
|
+
const paths = pathsByColor.get(color) ?? [];
|
|
173931
|
+
paths.push(`M${svgNumber(node.x - result.cellWidth / 2)},${svgNumber(node.y - result.cellHeight / 2)}h${svgNumber(result.cellWidth)}v${svgNumber(result.cellHeight)}h${svgNumber(-result.cellWidth)}z`);
|
|
173932
|
+
pathsByColor.set(color, paths);
|
|
173933
|
+
}
|
|
173934
|
+
return [...pathsByColor].map(([color, paths]) => `<path fill="${color}" d="${paths.join(" ")}"/>`).join("");
|
|
173935
|
+
}
|
|
173936
|
+
function displayNumber(number) {
|
|
173937
|
+
if (number === 0)
|
|
173938
|
+
return "0";
|
|
173939
|
+
return number >= 0.01 && number < 1e4 ? Number(number.toPrecision(3)).toString() : number.toExponential(1);
|
|
173940
|
+
}
|
|
173941
|
+
function renderCurrentFieldSvg(result, options) {
|
|
173942
|
+
if (!result.diagnostics.converged)
|
|
173943
|
+
throw new Error("Only a converged simulation can be rendered");
|
|
173944
|
+
const width = options.width ?? 1100;
|
|
173945
|
+
const height = options.height ?? 1100;
|
|
173946
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 300 || height < 300)
|
|
173947
|
+
throw new Error("Image dimensions must be finite and at least 300 pixels");
|
|
173948
|
+
const maxCurrentDensity = options.maxCurrentDensity ?? (result.diagnostics.maxCurrentDensity || 1);
|
|
173949
|
+
if (!Number.isFinite(maxCurrentDensity) || maxCurrentDensity <= 0)
|
|
173950
|
+
throw new Error("maxCurrentDensity must be finite and positive");
|
|
173951
|
+
const vectorSpacing = options.vectorSpacing ?? 5;
|
|
173952
|
+
if (!Number.isInteger(vectorSpacing) || vectorSpacing < 1)
|
|
173953
|
+
throw new Error("vectorSpacing must be a positive integer");
|
|
173954
|
+
const boardWidth = result.bounds.maxX - result.bounds.minX;
|
|
173955
|
+
const boardHeight = result.bounds.maxY - result.bounds.minY;
|
|
173956
|
+
const pixelsPerMm = Math.min((width - 120) / boardWidth, (height - 250) / boardHeight);
|
|
173957
|
+
const left = (width - boardWidth * pixelsPerMm) / 2;
|
|
173958
|
+
const top = 160;
|
|
173959
|
+
const worldToSvg = compose35(translate35(left, top), scale14(pixelsPerMm, -pixelsPerMm), translate35(-result.bounds.minX, -result.bounds.maxY));
|
|
173960
|
+
const groundPaths = result.geometry.groundRegions.map((region) => `<path d="${[region.outer, ...region.holes, ...region.maskCutouts ?? []].map(polygonPath).join(" ")}" clip-rule="evenodd"/>`).join("");
|
|
173961
|
+
const boardPath = [result.geometry.boardOutline, ...result.geometry.cutouts].map(polygonPath).join(" ");
|
|
173962
|
+
const cells = renderCells(result, maxCurrentDensity);
|
|
173963
|
+
const arrows = [];
|
|
173964
|
+
if (!options.hideVectors) {
|
|
173965
|
+
const arrowLength = Math.min(result.cellWidth, result.cellHeight) * vectorSpacing * 0.58;
|
|
173966
|
+
for (const node of result.nodes) {
|
|
173967
|
+
if (node.column % vectorSpacing !== Math.floor(vectorSpacing / 2) || node.row % vectorSpacing !== Math.floor(vectorSpacing / 2) || node.currentDensity < result.diagnostics.maxCurrentDensity * 0.003)
|
|
173968
|
+
continue;
|
|
173969
|
+
const magnitude = Math.hypot(node.sheetCurrentX, node.sheetCurrentY);
|
|
173970
|
+
if (magnitude === 0)
|
|
173971
|
+
continue;
|
|
173972
|
+
const dx = node.sheetCurrentX / magnitude * arrowLength / 2;
|
|
173973
|
+
const dy = node.sheetCurrentY / magnitude * arrowLength / 2;
|
|
173974
|
+
arrows.push(`<path d="M${svgNumber(node.x - dx)},${svgNumber(node.y - dy)} L${svgNumber(node.x + dx)},${svgNumber(node.y + dy)} M${svgNumber(node.x + dx * 0.25 + dy * 0.4)},${svgNumber(node.y + dy * 0.25 - dx * 0.4)} L${svgNumber(node.x + dx)},${svgNumber(node.y + dy)} L${svgNumber(node.x + dx * 0.25 - dy * 0.4)},${svgNumber(node.y + dy * 0.25 + dx * 0.4)}"/>`);
|
|
173975
|
+
}
|
|
173976
|
+
}
|
|
173977
|
+
const traces = options.hideTraces ? "" : result.geometry.signals.map((signal) => {
|
|
173978
|
+
const wires = signal.route.flatMap((routePoint) => routePoint.route_type === "wire" ? [routePoint] : []);
|
|
173979
|
+
return `<polyline points="${wires.map((point2) => `${point2.x},${point2.y}`).join(" ")}" fill="none" stroke="#16383b" stroke-width="${wires[0].width}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
173980
|
+
}).join("");
|
|
173981
|
+
const contacts = result.geometry.excitations.map((excitation, excitationIndex) => {
|
|
173982
|
+
const route = result.geometry.signals[excitationIndex].route;
|
|
173983
|
+
const explicit = [
|
|
173984
|
+
{
|
|
173985
|
+
terminal: excitation.source_port,
|
|
173986
|
+
endpoint: route[0],
|
|
173987
|
+
reference: excitation.return_sink,
|
|
173988
|
+
label: `S${excitationIndex + 1}`,
|
|
173989
|
+
color: "#19394c"
|
|
173990
|
+
},
|
|
173991
|
+
{
|
|
173992
|
+
terminal: excitation.load_port,
|
|
173993
|
+
endpoint: route.at(-1),
|
|
173994
|
+
reference: excitation.return_source,
|
|
173995
|
+
label: `L${excitationIndex + 1}`,
|
|
173996
|
+
color: "#ae300f"
|
|
173997
|
+
}
|
|
173998
|
+
].filter((port) => port.terminal?.reference_pcb_port_id && port.endpoint.route_type === "wire");
|
|
173999
|
+
const links = explicit.map((port) => {
|
|
174000
|
+
if (port.endpoint.route_type !== "wire")
|
|
174001
|
+
return "";
|
|
174002
|
+
const a = applyToPoint311(worldToSvg, port.endpoint), b = applyToPoint311(worldToSvg, port.reference);
|
|
174003
|
+
return `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#64748b" stroke-width="2" stroke-dasharray="4 3"/>`;
|
|
174004
|
+
}).join("");
|
|
174005
|
+
return links + [
|
|
174006
|
+
{
|
|
174007
|
+
point: excitation.return_sink,
|
|
174008
|
+
label: `S${excitationIndex + 1}${excitation.source_port?.reference_pcb_port_id ? "−" : ""}`,
|
|
174009
|
+
color: "#19394c"
|
|
174010
|
+
},
|
|
174011
|
+
{
|
|
174012
|
+
point: excitation.return_source,
|
|
174013
|
+
label: `L${excitationIndex + 1}${excitation.load_port?.reference_pcb_port_id ? "−" : ""}`,
|
|
174014
|
+
color: "#ae300f"
|
|
174015
|
+
},
|
|
174016
|
+
...explicit.map((port) => ({
|
|
174017
|
+
point: port.endpoint,
|
|
174018
|
+
label: `${port.label}+`,
|
|
174019
|
+
color: port.color
|
|
174020
|
+
}))
|
|
174021
|
+
].map((contact) => {
|
|
174022
|
+
const scenePoint = applyToPoint311(worldToSvg, contact.point);
|
|
174023
|
+
return `<circle cx="${scenePoint.x}" cy="${scenePoint.y}" r="5" fill="#fff" stroke="${contact.color}" stroke-width="2"/><text x="${scenePoint.x + 10}" y="${scenePoint.y - 8}" font-size="13" font-weight="600" fill="#122735" stroke="white" stroke-width="3" paint-order="stroke">${contact.label}</text>`;
|
|
174024
|
+
}).join("");
|
|
174025
|
+
}).join("");
|
|
174026
|
+
const ticks = [0, 0.25, 0.5, 0.75, 1].map((fraction) => `<text x="${60 + fraction * (width - 120)}" y="136" text-anchor="middle" font-size="14" fill="#475569">${displayNumber(fraction * maxCurrentDensity)}</text>`).join("");
|
|
174027
|
+
const boardBottom = top + boardHeight * pixelsPerMm;
|
|
174028
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">
|
|
174029
|
+
<title id="title">${escapeXml6(options.title ?? "Ground-plane return current")}</title>
|
|
174030
|
+
<desc id="description">${escapeXml6(options.description)}</desc>
|
|
174031
|
+
<defs><linearGradient id="current-scale">${Array.from({ length: 6 }, (_, stopIndex) => `<stop offset="${stopIndex * 20}%" stop-color="${currentColor(stopIndex / 5)}"/>`).join("")}</linearGradient><clipPath id="board-clip"><path d="${boardPath}" clip-rule="evenodd"/></clipPath><clipPath id="ground-clip">${groundPaths}</clipPath></defs>
|
|
174032
|
+
<rect width="100%" height="100%" fill="white"/>
|
|
174033
|
+
<g font-family="Arial, sans-serif"><text x="60" y="42" font-size="26" font-weight="600" fill="#172b3a">${escapeXml6(options.title ?? "Ground-plane return current")}</text>
|
|
174034
|
+
<text x="60" y="72" font-size="14" fill="#475569">${escapeXml6(options.subtitle)}</text>
|
|
174035
|
+
<rect x="60" y="90" width="${width - 120}" height="26" rx="3" fill="url(#current-scale)"/>${ticks}
|
|
174036
|
+
<g transform="matrix(${worldToSvg.a},${worldToSvg.b},${worldToSvg.c},${worldToSvg.d},${worldToSvg.e},${worldToSvg.f})">
|
|
174037
|
+
<g clip-path="url(#board-clip)"><g clip-path="url(#ground-clip)"><g shape-rendering="crispEdges">${cells}</g><g fill="none" stroke="white" stroke-width="${1 / pixelsPerMm}" opacity="0.75">${arrows.join("")}</g></g></g>
|
|
174038
|
+
<path d="${polygonPath(result.geometry.boardOutline)}" fill="none" stroke="#94a3b8" stroke-width="${1 / pixelsPerMm}"/>${traces}</g>
|
|
174039
|
+
${contacts}
|
|
174040
|
+
<text x="60" y="${boardBottom + 30}" font-size="14" fill="#334155">${result.geometry.excitations.map((excitation, excitationIndex) => `S${excitationIndex + 1} → L${excitationIndex + 1}: ${displayNumber(excitation.current)} A`).join(" · ")}</text>
|
|
174041
|
+
<text x="60" y="${boardBottom + 53}" font-size="13" fill="#64748b">${escapeXml6(options.separationLabel ?? "h")} = ${displayNumber(result.layerSeparation)} mm · copper = ${displayNumber(result.copperThickness)} mm · ${escapeXml6(options.gridLabel ?? "mesh")} = ${result.columns} × ${result.rows}</text>
|
|
174042
|
+
<text x="60" y="${boardBottom + 76}" font-size="12" fill="#64748b">${escapeXml6(options.footer)}</text></g>
|
|
174043
|
+
</svg>`;
|
|
174044
|
+
}
|
|
174045
|
+
function validatePalaceReference(reference) {
|
|
174046
|
+
if (reference.femOrder !== 1 && reference.femOrder !== 2 || reference.schemaVersion !== 1 || reference.solver !== "palace" || !["volumetric_copper", "surface_impedance_copper"].includes(reference.copperModel) || reference.sampleLayer !== undefined && !/^(top|bottom|inner[1-8])$/.test(reference.sampleLayer))
|
|
174047
|
+
throw new Error("Unsupported Palace reference");
|
|
174048
|
+
if (reference.copperModel === "surface_impedance_copper" && (reference.samplingMethod !== "sum_foil_face_surface_currents" || !Number.isFinite(reference.surfaceCurrentScaleAmpsPerMm) || reference.surfaceCurrentScaleAmpsPerMm <= 0))
|
|
174049
|
+
throw new Error("Surface impedance reference must identify its face-current sampling and finite positive A/mm scale");
|
|
174050
|
+
if (![
|
|
174051
|
+
reference.frequencyHz,
|
|
174052
|
+
reference.copperThickness,
|
|
174053
|
+
reference.layerSeparation,
|
|
174054
|
+
reference.cellWidth,
|
|
174055
|
+
reference.cellHeight,
|
|
174056
|
+
reference.normalizationConditionNumber,
|
|
174057
|
+
reference.electricFieldScaleVoltsPerMeter
|
|
174058
|
+
].every((number) => Number.isFinite(number) && number > 0))
|
|
174059
|
+
throw new Error("Palace reference needs finite positive frequency, thickness, cell sizes and normalization condition");
|
|
174060
|
+
if (![reference.columns, reference.rows].every((number) => Number.isInteger(number) && number > 0) || !reference.samples.length)
|
|
174061
|
+
throw new Error("Palace reference needs a nonempty grid");
|
|
174062
|
+
if (typeof reference.provenance.geometrySignature !== "string" || !reference.provenance.geometrySignature.length)
|
|
174063
|
+
throw new Error("Palace reference needs a physical geometry signature");
|
|
174064
|
+
const locations = new Set;
|
|
174065
|
+
for (const sample of reference.samples) {
|
|
174066
|
+
if (![
|
|
174067
|
+
sample.x,
|
|
174068
|
+
sample.y,
|
|
174069
|
+
sample.sheetCurrentXReal,
|
|
174070
|
+
sample.sheetCurrentYReal,
|
|
174071
|
+
sample.sheetCurrentXImag,
|
|
174072
|
+
sample.sheetCurrentYImag
|
|
174073
|
+
].every(Number.isFinite))
|
|
174074
|
+
throw new Error("Non-finite Palace sample");
|
|
174075
|
+
const location2 = `${sample.x},${sample.y}`;
|
|
174076
|
+
if (locations.has(location2))
|
|
174077
|
+
throw new Error("Duplicate Palace sample position");
|
|
174078
|
+
locations.add(location2);
|
|
174079
|
+
}
|
|
174080
|
+
for (const current of [
|
|
174081
|
+
...reference.sourceCurrents,
|
|
174082
|
+
...reference.loadCurrents
|
|
174083
|
+
])
|
|
174084
|
+
if (![current.real, current.imag].every(Number.isFinite))
|
|
174085
|
+
throw new Error("Non-finite Palace port current");
|
|
174086
|
+
}
|
|
174087
|
+
function renderPalaceModelSvg(model, options) {
|
|
174088
|
+
const { reference } = options;
|
|
174089
|
+
validatePalaceReference(reference);
|
|
174090
|
+
if (reference.frequencyHz !== model.frequencyHz || reference.femOrder !== model.order || reference.copperModel !== model.copperModel || reference.sampleLayer !== model.multilayer?.sampleLayer)
|
|
174091
|
+
throw new Error("Palace model frequency, FEM order, copper model or sample layer differs from reference");
|
|
174092
|
+
const outline = model.geometry.boardOutline;
|
|
174093
|
+
const bounds = {
|
|
174094
|
+
minX: Math.min(...outline.map((point2) => point2.x)),
|
|
174095
|
+
maxX: Math.max(...outline.map((point2) => point2.x)),
|
|
174096
|
+
minY: Math.min(...outline.map((point2) => point2.y)),
|
|
174097
|
+
maxY: Math.max(...outline.map((point2) => point2.y))
|
|
174098
|
+
};
|
|
174099
|
+
if (Math.abs(reference.columns * reference.cellWidth - (bounds.maxX - bounds.minX)) > 0.00000001 || Math.abs(reference.rows * reference.cellHeight - (bounds.maxY - bounds.minY)) > 0.00000001)
|
|
174100
|
+
throw new Error("Palace sample grid does not fit the model bounds");
|
|
174101
|
+
const nodes = reference.samples.map((sample) => {
|
|
174102
|
+
const column = Math.round((sample.x - bounds.minX) / reference.cellWidth - 0.5);
|
|
174103
|
+
const row = Math.round((sample.y - bounds.minY) / reference.cellHeight - 0.5);
|
|
174104
|
+
if (column < 0 || column >= reference.columns || row < 0 || row >= reference.rows || Math.abs(sample.x - (bounds.minX + (column + 0.5) * reference.cellWidth)) > 0.00000001 || Math.abs(sample.y - (bounds.minY + (row + 0.5) * reference.cellHeight)) > 0.00000001)
|
|
174105
|
+
throw new Error("Palace sample is not at a grid cell center");
|
|
174106
|
+
return {
|
|
174107
|
+
x: sample.x,
|
|
174108
|
+
y: sample.y,
|
|
174109
|
+
column,
|
|
174110
|
+
row,
|
|
174111
|
+
injection: 0,
|
|
174112
|
+
sheetCurrentX: 0,
|
|
174113
|
+
sheetCurrentY: 0,
|
|
174114
|
+
currentDensity: 0
|
|
174115
|
+
};
|
|
174116
|
+
});
|
|
174117
|
+
return renderPalaceReferenceSvg({
|
|
174118
|
+
geometry: model.geometry,
|
|
174119
|
+
bounds,
|
|
174120
|
+
nodes,
|
|
174121
|
+
columns: reference.columns,
|
|
174122
|
+
rows: reference.rows,
|
|
174123
|
+
cellWidth: reference.cellWidth,
|
|
174124
|
+
cellHeight: reference.cellHeight,
|
|
174125
|
+
copperThickness: model.copperThickness,
|
|
174126
|
+
layerSeparation: model.layerSeparation
|
|
174127
|
+
}, options);
|
|
174128
|
+
}
|
|
174129
|
+
function renderPalaceReferenceSvg(result, options) {
|
|
174130
|
+
const { reference } = options;
|
|
174131
|
+
validatePalaceReference(reference);
|
|
174132
|
+
if (palaceGeometrySignature(result.geometry) !== reference.provenance.geometrySignature)
|
|
174133
|
+
throw new Error("Palace reference geometry differs from the simulation geometry");
|
|
174134
|
+
if (reference.layerSeparation !== result.layerSeparation || reference.samples.length !== result.nodes.length || reference.columns !== result.columns || reference.rows !== result.rows || reference.cellWidth !== result.cellWidth || reference.cellHeight !== result.cellHeight || reference.copperThickness !== result.copperThickness)
|
|
174135
|
+
throw new Error("Palace rendering requires the matching simulation grid");
|
|
174136
|
+
if (reference.sourceCurrents.length !== result.geometry.excitations.length || reference.sourceCurrents.some((current, index) => Math.abs(current.real - result.geometry.excitations[index].current) > 0.00000001 || Math.abs(current.imag) > 0.00000001))
|
|
174137
|
+
throw new Error("Palace rendering requires matching source currents");
|
|
174138
|
+
const phaseDegrees = options.phaseDegrees ?? 0;
|
|
174139
|
+
if (!Number.isFinite(phaseDegrees))
|
|
174140
|
+
throw new Error("phaseDegrees must be finite");
|
|
174141
|
+
const phase = phaseDegrees * Math.PI / 180;
|
|
174142
|
+
let maxCurrentDensity = 0;
|
|
174143
|
+
const nodes = reference.samples.map((sample, index) => {
|
|
174144
|
+
const node = result.nodes[index];
|
|
174145
|
+
if (Math.hypot(node.x - sample.x, node.y - sample.y) > 0.00000001)
|
|
174146
|
+
throw new Error("Palace sample coordinates differ from the rendering grid");
|
|
174147
|
+
const currentDensity = Math.hypot(sample.sheetCurrentXReal, sample.sheetCurrentYReal, sample.sheetCurrentXImag, sample.sheetCurrentYImag) / reference.copperThickness;
|
|
174148
|
+
maxCurrentDensity = Math.max(maxCurrentDensity, currentDensity);
|
|
174149
|
+
return {
|
|
174150
|
+
...node,
|
|
174151
|
+
sheetCurrentX: sample.sheetCurrentXReal * Math.cos(phase) - sample.sheetCurrentXImag * Math.sin(phase),
|
|
174152
|
+
sheetCurrentY: sample.sheetCurrentYReal * Math.cos(phase) - sample.sheetCurrentYImag * Math.sin(phase),
|
|
174153
|
+
currentDensity
|
|
174154
|
+
};
|
|
174155
|
+
});
|
|
174156
|
+
return renderCurrentFieldSvg({
|
|
174157
|
+
...result,
|
|
174158
|
+
nodes,
|
|
174159
|
+
diagnostics: {
|
|
174160
|
+
converged: true,
|
|
174161
|
+
maxCurrentDensity
|
|
174162
|
+
}
|
|
174163
|
+
}, {
|
|
174164
|
+
...options,
|
|
174165
|
+
title: options.title ?? "Palace ground-plane return current",
|
|
174166
|
+
description: reference.copperModel === "surface_impedance_copper" ? "Palace driven Maxwell reference with finite-conductivity surface impedance. Colors show the complex sheet current summed over exposed foil faces, divided by physical foil thickness for display. Arrows show the real instantaneous field at the selected phase. Copper uses a half-space surface-impedance boundary." : "Palace driven Maxwell reference. Colors show magnitude of the complex conduction-current vector averaged through copper thickness. Arrows show the real instantaneous field at the selected phase. Copper is an explicitly meshed conductive volume.",
|
|
174167
|
+
subtitle: `Palace ${reference.solverVersion}${reference.sampleLayer ? ` · layer = ${reference.sampleLayer}` : ""} · f = ${reference.frequencyHz / 1e6} MHz · |K|/t (A/mm²) · peak phasors`,
|
|
174168
|
+
gridLabel: "sample grid",
|
|
174169
|
+
separationLabel: reference.sampleLayer ? "outer foil gap" : undefined,
|
|
174170
|
+
footer: `FEM order ${reference.femOrder} · ${reference.copperModel === "surface_impedance_copper" ? "finite-conductivity surface impedance" : "conductive copper volumes"} · arrows at ${phaseDegrees}° · air/substrate domain · source currents normalized`
|
|
174171
|
+
});
|
|
174172
|
+
}
|
|
174173
|
+
async function writePalaceImage(destination, imageSize = 1100) {
|
|
174174
|
+
const readStarted = performance.now();
|
|
174175
|
+
const model = await readJson(`${destination}/model.json`);
|
|
174176
|
+
const reference = await readJson(`${destination}/reference.json`);
|
|
174177
|
+
const readSeconds = (performance.now() - readStarted) / 1000;
|
|
174178
|
+
const svgStarted = performance.now();
|
|
174179
|
+
const svg = renderPalaceModelSvg(model, {
|
|
174180
|
+
reference,
|
|
174181
|
+
title: "Palace: ground-plane return current",
|
|
174182
|
+
maxCurrentDensity: 50,
|
|
174183
|
+
vectorSpacing: Math.max(1, Math.round(1 / reference.cellWidth)),
|
|
174184
|
+
width: imageSize,
|
|
174185
|
+
height: imageSize
|
|
174186
|
+
});
|
|
174187
|
+
await writeFile32(`${destination}/palace.svg`, svg);
|
|
174188
|
+
const svgSeconds = (performance.now() - svgStarted) / 1000;
|
|
174189
|
+
const pngStarted = performance.now();
|
|
174190
|
+
await writeFile32(`${destination}/palace.png`, new Resvg3(svg).render().asPng());
|
|
174191
|
+
const pngSeconds = (performance.now() - pngStarted) / 1000;
|
|
174192
|
+
return {
|
|
174193
|
+
readSeconds,
|
|
174194
|
+
svgSeconds,
|
|
174195
|
+
pngSeconds,
|
|
174196
|
+
imageSize,
|
|
174197
|
+
provenance: reference.provenance,
|
|
174198
|
+
solverVersion: reference.solverVersion,
|
|
174199
|
+
femOrder: reference.femOrder,
|
|
174200
|
+
sourceCurrents: reference.sourceCurrents
|
|
174201
|
+
};
|
|
174202
|
+
}
|
|
174203
|
+
function imageDimension(size = 1100) {
|
|
174204
|
+
if (!Number.isInteger(size) || size < 300 || size > 8192)
|
|
174205
|
+
throw new Error("imageSize must be an integer between 300 and 8192 pixels");
|
|
174206
|
+
return size;
|
|
174207
|
+
}
|
|
174208
|
+
function caseOptions(options) {
|
|
174209
|
+
imageDimension(options.imageSize);
|
|
174210
|
+
if (options.ports && !options.groundNet)
|
|
174211
|
+
throw new Error("Named ports require groundNet");
|
|
174212
|
+
if (options.groundNet && !options.ports)
|
|
174213
|
+
throw new Error("groundNet requires named ports");
|
|
174214
|
+
if (options.ports && options.excitations)
|
|
174215
|
+
throw new Error("Choose named ports or explicit excitation records");
|
|
174216
|
+
const circuitJson = options.ports ? withNamedExcitations({
|
|
174217
|
+
circuitJson: options.circuitJson,
|
|
174218
|
+
ports: options.ports.map((port) => ({
|
|
174219
|
+
...port,
|
|
174220
|
+
sourceImpedance: port.sourceImpedance ?? options.portResistance ?? 50,
|
|
174221
|
+
loadImpedance: port.loadImpedance ?? options.portResistance ?? 50
|
|
174222
|
+
})),
|
|
174223
|
+
groundNet: options.groundNet,
|
|
174224
|
+
referenceLayer: options.sampleLayer
|
|
174225
|
+
}) : options.excitations ? [
|
|
174226
|
+
...options.circuitJson.filter((element) => element.type !== "simulation_return_current_excitation"),
|
|
174227
|
+
...options.excitations
|
|
174228
|
+
] : options.circuitJson;
|
|
174229
|
+
return {
|
|
174230
|
+
...options,
|
|
174231
|
+
circuitJson,
|
|
174232
|
+
excitations: undefined,
|
|
174233
|
+
destination: resolve32(options.outputDirectory)
|
|
174234
|
+
};
|
|
174235
|
+
}
|
|
174236
|
+
async function writePortSpecification(options, destination) {
|
|
174237
|
+
const model = await readJson(join22(destination, "model.json"));
|
|
174238
|
+
await writeFile4(join22(destination, "excitation-ports.json"), JSON.stringify({
|
|
174239
|
+
frequencyHz: options.frequencyHz,
|
|
174240
|
+
copperModel: model.copperModel,
|
|
174241
|
+
...model.surfaceImpedance ? { surfaceImpedance: model.surfaceImpedance } : {},
|
|
174242
|
+
ports: options.ports?.map((port) => ({
|
|
174243
|
+
...port,
|
|
174244
|
+
current: parseCurrentAmps(port.current),
|
|
174245
|
+
...port.sourceImpedance === undefined ? {} : { sourceImpedance: parseResistanceOhms(port.sourceImpedance) },
|
|
174246
|
+
...port.loadImpedance === undefined ? {} : { loadImpedance: parseResistanceOhms(port.loadImpedance) }
|
|
174247
|
+
})) ?? null,
|
|
174248
|
+
groundNet: options.groundNet ?? null,
|
|
174249
|
+
resolvedTerminals: model.ports,
|
|
174250
|
+
metadataSource: options.ports ? "named_ports" : "circuit_json",
|
|
174251
|
+
reference: model.ports?.some((port) => port.referencePcbPortId) ? "explicit reference pins; omitted references use plane beneath signal" : "ground plane directly beneath each signal endpoint",
|
|
174252
|
+
currentConvention: "signed in-phase peak amperes"
|
|
174253
|
+
}, null, 2));
|
|
174254
|
+
}
|
|
174255
|
+
async function runPalaceSimulation(options) {
|
|
174256
|
+
const startedAt = new Date().toISOString();
|
|
174257
|
+
const started = performance.now();
|
|
174258
|
+
const resolved = caseOptions(options);
|
|
174259
|
+
await runPalaceCase(resolved);
|
|
174260
|
+
await writePortSpecification(options, resolved.destination);
|
|
174261
|
+
const image = await writePalaceImage(resolved.destination, imageDimension(options.imageSize));
|
|
174262
|
+
const timing = {
|
|
174263
|
+
startedAt,
|
|
174264
|
+
completedAt: new Date().toISOString(),
|
|
174265
|
+
frequencyHz: options.frequencyHz,
|
|
174266
|
+
copperModel: options.copperModel ?? "volumetric_copper",
|
|
174267
|
+
totalSeconds: (performance.now() - started) / 1000,
|
|
174268
|
+
reusedCompletedFemSolve: false,
|
|
174269
|
+
image
|
|
174270
|
+
};
|
|
174271
|
+
await writeFile4(join22(resolved.destination, "run-timing.json"), JSON.stringify(timing, null, 2));
|
|
174272
|
+
return {
|
|
174273
|
+
outputDirectory: resolved.destination,
|
|
174274
|
+
referencePath: join22(resolved.destination, "reference.json"),
|
|
174275
|
+
svgPath: join22(resolved.destination, "palace.svg"),
|
|
174276
|
+
pngPath: join22(resolved.destination, "palace.png"),
|
|
174277
|
+
timing
|
|
174278
|
+
};
|
|
174279
|
+
}
|
|
174280
|
+
var copperLayer2, fabricationStackupSchema2, palaceImage = "benvial/palace@sha256:f0f3a3cbbdf1ee2d8f856ddbe1f5bf4628a92ee8ab87d1e27dc33402ad9c8dda";
|
|
174281
|
+
var init_palace = __esm(() => {
|
|
174282
|
+
copperLayer2 = z228.string().regex(/^(top|bottom|inner[1-8])$/);
|
|
174283
|
+
fabricationStackupSchema2 = z228.object({
|
|
174284
|
+
nominalBoardThicknessMm: z228.number().finite().positive().optional(),
|
|
174285
|
+
layers: z228.array(z228.union([
|
|
174286
|
+
z228.object({
|
|
174287
|
+
name: copperLayer2,
|
|
174288
|
+
copperThicknessMm: z228.number().finite().positive()
|
|
174289
|
+
}),
|
|
174290
|
+
z228.object({
|
|
174291
|
+
material: z228.string().min(1),
|
|
174292
|
+
dielectricThicknessMm: z228.number().finite().positive(),
|
|
174293
|
+
dielectricConstant: z228.number().finite().positive(),
|
|
174294
|
+
lossTangent: z228.number().finite().nonnegative().optional()
|
|
174295
|
+
})
|
|
174296
|
+
]))
|
|
174297
|
+
});
|
|
174298
|
+
});
|
|
174299
|
+
|
|
174300
|
+
// node_modules/simulate-return-current/dist/circuit-json.js
|
|
174301
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
174302
|
+
import { gzipSync as gzipSync2 } from "node:zlib";
|
|
174303
|
+
import { Resvg as Resvg4 } from "@resvg/resvg-js";
|
|
174304
|
+
import {
|
|
174305
|
+
simulation_return_current_excitation as simulation_return_current_excitation22,
|
|
174306
|
+
simulation_pcb_return_current_result,
|
|
174307
|
+
simulation_pcb_return_current_field,
|
|
174308
|
+
simulation_pcb_return_current_heatmap,
|
|
174309
|
+
simulation_pcb_return_current_marker,
|
|
174310
|
+
getSimulationReturnCurrentGridJsonSchema
|
|
174311
|
+
} from "circuit-json";
|
|
174312
|
+
import {
|
|
174313
|
+
applyToPoint as applyToPoint51,
|
|
174314
|
+
compose as compose21,
|
|
174315
|
+
rotateDEG as rotateDEG6,
|
|
174316
|
+
translate as translate21
|
|
174317
|
+
} from "transformation-matrix";
|
|
174318
|
+
import {
|
|
174319
|
+
applyToPoint as applyToPoint216,
|
|
174320
|
+
compose as compose26,
|
|
174321
|
+
rotateDEG as rotateDEG24,
|
|
174322
|
+
translate as translate26
|
|
174323
|
+
} from "transformation-matrix";
|
|
174324
|
+
import { z as z229 } from "zod";
|
|
174325
|
+
import {
|
|
174326
|
+
simulation_experiment as simulation_experiment4,
|
|
174327
|
+
simulation_return_current_excitation as simulation_return_current_excitation3
|
|
174328
|
+
} from "circuit-json";
|
|
174329
|
+
function rectangleOutline3(rect) {
|
|
174330
|
+
if (![
|
|
174331
|
+
rect.center.x,
|
|
174332
|
+
rect.center.y,
|
|
174333
|
+
rect.rotation ?? 0,
|
|
174334
|
+
rect.width,
|
|
174335
|
+
rect.height
|
|
174336
|
+
].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0)
|
|
174337
|
+
throw new Error("Rectangles need finite coordinates, rotation, and positive dimensions");
|
|
174338
|
+
const localToWorld = compose21(translate21(rect.center.x, rect.center.y), rotateDEG6(rect.rotation ?? 0));
|
|
174339
|
+
return [
|
|
174340
|
+
{ x: -rect.width / 2, y: -rect.height / 2 },
|
|
174341
|
+
{ x: rect.width / 2, y: -rect.height / 2 },
|
|
174342
|
+
{ x: rect.width / 2, y: rect.height / 2 },
|
|
174343
|
+
{ x: -rect.width / 2, y: rect.height / 2 }
|
|
174344
|
+
].map((corner) => applyToPoint51(localToWorld, corner));
|
|
174345
|
+
}
|
|
174346
|
+
function flattenRing3(vertices) {
|
|
174347
|
+
const outline = [];
|
|
174348
|
+
for (let vertexIndex = 0;vertexIndex < vertices.length; vertexIndex++) {
|
|
174349
|
+
const start = vertices[vertexIndex];
|
|
174350
|
+
const end = vertices[(vertexIndex + 1) % vertices.length];
|
|
174351
|
+
outline.push({ x: start.x, y: start.y });
|
|
174352
|
+
const bulge = start.bulge ?? 0;
|
|
174353
|
+
if (Math.abs(bulge) < 0.000000000001)
|
|
174354
|
+
continue;
|
|
174355
|
+
const dx = end.x - start.x;
|
|
174356
|
+
const dy = end.y - start.y;
|
|
174357
|
+
const center = {
|
|
174358
|
+
x: (start.x + end.x) / 2 - dy * (1 - bulge * bulge) / (4 * bulge),
|
|
174359
|
+
y: (start.y + end.y) / 2 + dx * (1 - bulge * bulge) / (4 * bulge)
|
|
174360
|
+
};
|
|
174361
|
+
const sweep = 4 * Math.atan(bulge);
|
|
174362
|
+
const radius = Math.hypot(start.x - center.x, start.y - center.y);
|
|
174363
|
+
const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
|
|
174364
|
+
const steps = Math.ceil(Math.abs(sweep) / (Math.PI / 60));
|
|
174365
|
+
for (let arcIndex = 1;arcIndex < steps; arcIndex++) {
|
|
174366
|
+
const angle = startAngle + sweep * arcIndex / steps;
|
|
174367
|
+
outline.push({
|
|
174368
|
+
x: center.x + radius * Math.cos(angle),
|
|
174369
|
+
y: center.y + radius * Math.sin(angle)
|
|
174370
|
+
});
|
|
174371
|
+
}
|
|
174372
|
+
}
|
|
174373
|
+
return outline;
|
|
174374
|
+
}
|
|
174375
|
+
function cutoutOutline2(cutout) {
|
|
174376
|
+
if (cutout.shape === "polygon")
|
|
174377
|
+
return cutout.points;
|
|
174378
|
+
if (cutout.shape === "circle") {
|
|
174379
|
+
if (!Number.isFinite(cutout.radius) || cutout.radius <= 0)
|
|
174380
|
+
throw new Error("Circular cutouts need a finite positive radius");
|
|
174381
|
+
return Array.from({ length: 120 }, (_, circleIndex) => ({
|
|
174382
|
+
x: cutout.center.x + cutout.radius * Math.cos(circleIndex * Math.PI / 60),
|
|
174383
|
+
y: cutout.center.y + cutout.radius * Math.sin(circleIndex * Math.PI / 60)
|
|
174384
|
+
}));
|
|
174385
|
+
}
|
|
174386
|
+
if (cutout.shape === "rect" && !cutout.corner_radius)
|
|
174387
|
+
return rectangleOutline3(cutout);
|
|
174388
|
+
throw new Error("Path and rounded rectangular PCB cutouts are not supported; use a polygon cutout");
|
|
174389
|
+
}
|
|
174390
|
+
function pointInPolygon3(point, outline) {
|
|
174391
|
+
let inside = false;
|
|
174392
|
+
for (let cornerIndex = 0;cornerIndex < outline.length; cornerIndex++) {
|
|
174393
|
+
const start = outline[cornerIndex];
|
|
174394
|
+
const end = outline[(cornerIndex + 1) % outline.length];
|
|
174395
|
+
const cross = (point.x - start.x) * (end.y - start.y) - (point.y - start.y) * (end.x - start.x);
|
|
174396
|
+
if (Math.abs(cross) < 0.000000001 && point.x >= Math.min(start.x, end.x) - 0.000000001 && point.x <= Math.max(start.x, end.x) + 0.000000001 && point.y >= Math.min(start.y, end.y) - 0.000000001 && point.y <= Math.max(start.y, end.y) + 0.000000001)
|
|
174397
|
+
return true;
|
|
174398
|
+
if (start.y > point.y !== end.y > point.y && point.x < start.x + (point.y - start.y) * (end.x - start.x) / (end.y - start.y))
|
|
174399
|
+
inside = !inside;
|
|
174400
|
+
}
|
|
174401
|
+
return inside;
|
|
174402
|
+
}
|
|
174403
|
+
function pointInRegion2(point, region) {
|
|
174404
|
+
return pointInPolygon3(point, region.outer) && !region.holes.some((hole) => pointInPolygon3(point, hole)) && !(region.maskCutouts ?? []).some((hole) => pointInPolygon3(point, hole));
|
|
174405
|
+
}
|
|
174406
|
+
function isCopper2(point, geometry) {
|
|
174407
|
+
return pointInPolygon3(point, geometry.boardOutline) && !geometry.cutouts.some((cutout) => pointInPolygon3(point, cutout)) && geometry.groundRegions.some((region) => pointInRegion2(point, region));
|
|
174408
|
+
}
|
|
174409
|
+
function segmentInCopper2(segment, geometry) {
|
|
174410
|
+
const outlines = [
|
|
174411
|
+
geometry.boardOutline,
|
|
174412
|
+
...geometry.cutouts,
|
|
174413
|
+
...geometry.groundRegions.flatMap((region) => [
|
|
174414
|
+
region.outer,
|
|
174415
|
+
...region.holes
|
|
174416
|
+
])
|
|
174417
|
+
];
|
|
174418
|
+
const dx = segment.end.x - segment.start.x;
|
|
174419
|
+
const dy = segment.end.y - segment.start.y;
|
|
174420
|
+
const crossings = [0, 1];
|
|
174421
|
+
for (const outline of outlines) {
|
|
174422
|
+
for (let cornerIndex = 0;cornerIndex < outline.length; cornerIndex++) {
|
|
174423
|
+
const start = outline[cornerIndex];
|
|
174424
|
+
const end = outline[(cornerIndex + 1) % outline.length];
|
|
174425
|
+
const ex = end.x - start.x;
|
|
174426
|
+
const ey = end.y - start.y;
|
|
174427
|
+
const determinant = dx * ey - dy * ex;
|
|
174428
|
+
if (Math.abs(determinant) < 0.000000000001)
|
|
174429
|
+
continue;
|
|
174430
|
+
const ax = start.x - segment.start.x;
|
|
174431
|
+
const ay = start.y - segment.start.y;
|
|
174432
|
+
const alongSegment = (ax * ey - ay * ex) / determinant;
|
|
174433
|
+
const alongBoundary = (ax * dy - ay * dx) / determinant;
|
|
174434
|
+
if (alongSegment > 0 && alongSegment < 1 && alongBoundary >= 0 && alongBoundary <= 1)
|
|
174435
|
+
crossings.push(alongSegment);
|
|
174436
|
+
}
|
|
174437
|
+
}
|
|
174438
|
+
crossings.sort((a, b) => a - b);
|
|
174439
|
+
for (let crossingIndex = 1;crossingIndex < crossings.length; crossingIndex++) {
|
|
174440
|
+
const fraction = (crossings[crossingIndex - 1] + crossings[crossingIndex]) / 2;
|
|
174441
|
+
if (!isCopper2({
|
|
174442
|
+
x: segment.start.x + fraction * dx,
|
|
174443
|
+
y: segment.start.y + fraction * dy
|
|
174444
|
+
}, geometry))
|
|
174445
|
+
return false;
|
|
174446
|
+
}
|
|
174447
|
+
return true;
|
|
174448
|
+
}
|
|
174449
|
+
function pourRegion3(pour) {
|
|
174450
|
+
if (pour.shape === "rect")
|
|
174451
|
+
return { outer: rectangleOutline3(pour), holes: [] };
|
|
174452
|
+
if (pour.shape === "polygon")
|
|
174453
|
+
return { outer: pour.points, holes: [] };
|
|
174454
|
+
return {
|
|
174455
|
+
outer: flattenRing3(pour.brep_shape.outer_ring.vertices),
|
|
174456
|
+
holes: pour.brep_shape.inner_rings.map((ring) => flattenRing3(ring.vertices))
|
|
174457
|
+
};
|
|
174458
|
+
}
|
|
174459
|
+
function positiveFinite2(number, label) {
|
|
174460
|
+
if (!Number.isFinite(number) || number <= 0)
|
|
174461
|
+
throw new Error(`${label} must be finite and greater than zero`);
|
|
174462
|
+
return number;
|
|
174463
|
+
}
|
|
174464
|
+
function validateOutline2(outline) {
|
|
174465
|
+
if (outline.length < 3 || outline.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y)))
|
|
174466
|
+
throw new Error("Copper and board polygons need at least three finite points");
|
|
174467
|
+
}
|
|
174468
|
+
function readGeometry2(options) {
|
|
174469
|
+
const boards = options.circuitJson.filter((element) => element.type === "pcb_board");
|
|
174470
|
+
if (boards.length !== 1)
|
|
174471
|
+
throw new Error("Exactly one PCB board is required");
|
|
174472
|
+
const board = boards[0];
|
|
174473
|
+
if (board.num_layers !== 2)
|
|
174474
|
+
throw new Error("Return-current simulation currently requires a two-layer board");
|
|
174475
|
+
if (options.circuitJson.some((element) => element.type === "pcb_hole" || element.type === "pcb_plated_hole" || element.type === "pcb_via"))
|
|
174476
|
+
throw new Error("Drilled holes and vias are not supported yet; represent voids with PCB cutouts and model return contacts explicitly");
|
|
174477
|
+
const boardOutline = board.outline?.length ? board.outline : rectangleOutline3({
|
|
174478
|
+
center: board.center,
|
|
174479
|
+
width: positiveFinite2(board.width ?? 0, "Board width"),
|
|
174480
|
+
height: positiveFinite2(board.height ?? 0, "Board height")
|
|
174481
|
+
});
|
|
174482
|
+
const excitations = options.excitations ?? options.circuitJson.filter((element) => element.type === "simulation_return_current_excitation");
|
|
174483
|
+
if (!excitations.length)
|
|
174484
|
+
throw new Error("Specify return-current excitations with signal current and return contacts");
|
|
174485
|
+
const groundNetIds = new Set(excitations.map((excitation) => excitation.ground_source_net_id));
|
|
174486
|
+
if (groundNetIds.size !== 1)
|
|
174487
|
+
throw new Error("All excitations must share one ground net");
|
|
174488
|
+
const pours = options.circuitJson.filter((element) => element.type === "pcb_copper_pour" && element.layer === "bottom" && groundNetIds.has(element.source_net_id ?? ""));
|
|
174489
|
+
const groundPlanes = options.circuitJson.filter((element) => element.type === "pcb_ground_plane" && groundNetIds.has(element.source_net_id));
|
|
174490
|
+
const groundPlaneIds = new Set(groundPlanes.flatMap((element) => element.type === "pcb_ground_plane" ? [element.pcb_ground_plane_id] : []));
|
|
174491
|
+
const planeRegions = options.circuitJson.filter((element) => element.type === "pcb_ground_plane_region" && element.layer === "bottom" && groundPlaneIds.has(element.pcb_ground_plane_id));
|
|
174492
|
+
const groundRegions = [
|
|
174493
|
+
...pours.map(pourRegion3),
|
|
174494
|
+
...planeRegions.flatMap((region) => region.type === "pcb_ground_plane_region" ? [{ outer: region.points, holes: [] }] : [])
|
|
174495
|
+
];
|
|
174496
|
+
if (!groundRegions.length)
|
|
174497
|
+
throw new Error("The selected ground net needs a bottom-layer copper pour or ground-plane region");
|
|
174498
|
+
const cutouts = options.circuitJson.filter((element) => element.type === "pcb_cutout").map(cutoutOutline2);
|
|
174499
|
+
for (const outline of [
|
|
174500
|
+
boardOutline,
|
|
174501
|
+
...cutouts,
|
|
174502
|
+
...groundRegions.flatMap((region) => [region.outer, ...region.holes])
|
|
174503
|
+
])
|
|
174504
|
+
validateOutline2(outline);
|
|
174505
|
+
const geometry = {
|
|
174506
|
+
board,
|
|
174507
|
+
boardOutline,
|
|
174508
|
+
groundRegions,
|
|
174509
|
+
cutouts,
|
|
174510
|
+
signals: [],
|
|
174511
|
+
segments: [],
|
|
174512
|
+
excitations
|
|
174513
|
+
};
|
|
174514
|
+
for (const excitation of excitations) {
|
|
174515
|
+
if (!Number.isFinite(excitation.current))
|
|
174516
|
+
throw new Error("Excitation current must be finite (amperes)");
|
|
174517
|
+
for (const contact of [excitation.return_source, excitation.return_sink]) {
|
|
174518
|
+
if (!Number.isFinite(contact.x) || !Number.isFinite(contact.y) || !isCopper2(contact, geometry))
|
|
174519
|
+
throw new Error("Each return contact must lie on the selected ground copper");
|
|
174520
|
+
}
|
|
174521
|
+
const signal = options.circuitJson.find((element) => element.type === "pcb_trace" && element.pcb_trace_id === excitation.pcb_trace_id);
|
|
174522
|
+
if (!signal || signal.type !== "pcb_trace")
|
|
174523
|
+
throw new Error("An excitation references a missing signal trace");
|
|
174524
|
+
if (signal.route.length < 2)
|
|
174525
|
+
throw new Error("Each signal trace needs at least two route points");
|
|
174526
|
+
const wires = signal.route.map((routePoint) => {
|
|
174527
|
+
if (routePoint.route_type !== "wire" || routePoint.layer !== "top")
|
|
174528
|
+
throw new Error("Only continuous top-layer wire routes are supported; split routes at vias or pads");
|
|
174529
|
+
if (!Number.isFinite(routePoint.x) || !Number.isFinite(routePoint.y))
|
|
174530
|
+
throw new Error("Signal coordinates must be finite");
|
|
174531
|
+
positiveFinite2(routePoint.width, "Signal width");
|
|
174532
|
+
return routePoint;
|
|
174533
|
+
});
|
|
174534
|
+
geometry.signals.push(signal);
|
|
174535
|
+
for (let routeIndex = 1;routeIndex < wires.length; routeIndex++) {
|
|
174536
|
+
const start = wires[routeIndex - 1];
|
|
174537
|
+
const end = wires[routeIndex];
|
|
174538
|
+
if (start.x === end.x && start.y === end.y)
|
|
174539
|
+
continue;
|
|
174540
|
+
const boardMaterial = {
|
|
174541
|
+
...geometry,
|
|
174542
|
+
groundRegions: [{ outer: boardOutline, holes: [] }]
|
|
174543
|
+
};
|
|
174544
|
+
if (!segmentInCopper2({ start, end }, boardMaterial))
|
|
174545
|
+
throw new Error("A top-layer signal leaves the board or crosses a physical PCB cutout");
|
|
174546
|
+
geometry.segments.push({
|
|
174547
|
+
start,
|
|
174548
|
+
end,
|
|
174549
|
+
width: start.width,
|
|
174550
|
+
current: excitation.current
|
|
174551
|
+
});
|
|
174552
|
+
}
|
|
174553
|
+
}
|
|
174554
|
+
if (!geometry.segments.length)
|
|
174555
|
+
throw new Error("Signal traces must contain a segment of nonzero length");
|
|
174556
|
+
return geometry;
|
|
174557
|
+
}
|
|
174558
|
+
function validatePalaceReference2(reference) {
|
|
174559
|
+
if (reference.femOrder !== 1 && reference.femOrder !== 2 || reference.schemaVersion !== 1 || reference.solver !== "palace" || !["volumetric_copper", "surface_impedance_copper"].includes(reference.copperModel) || reference.sampleLayer !== undefined && !/^(top|bottom|inner[1-8])$/.test(reference.sampleLayer))
|
|
174560
|
+
throw new Error("Unsupported Palace reference");
|
|
174561
|
+
if (reference.copperModel === "surface_impedance_copper" && (reference.samplingMethod !== "sum_foil_face_surface_currents" || !Number.isFinite(reference.surfaceCurrentScaleAmpsPerMm) || reference.surfaceCurrentScaleAmpsPerMm <= 0))
|
|
174562
|
+
throw new Error("Surface impedance reference must identify its face-current sampling and finite positive A/mm scale");
|
|
174563
|
+
if (![
|
|
174564
|
+
reference.frequencyHz,
|
|
174565
|
+
reference.copperThickness,
|
|
174566
|
+
reference.layerSeparation,
|
|
174567
|
+
reference.cellWidth,
|
|
174568
|
+
reference.cellHeight,
|
|
174569
|
+
reference.normalizationConditionNumber,
|
|
174570
|
+
reference.electricFieldScaleVoltsPerMeter
|
|
174571
|
+
].every((number) => Number.isFinite(number) && number > 0))
|
|
174572
|
+
throw new Error("Palace reference needs finite positive frequency, thickness, cell sizes and normalization condition");
|
|
174573
|
+
if (![reference.columns, reference.rows].every((number) => Number.isInteger(number) && number > 0) || !reference.samples.length)
|
|
174574
|
+
throw new Error("Palace reference needs a nonempty grid");
|
|
174575
|
+
if (typeof reference.provenance.geometrySignature !== "string" || !reference.provenance.geometrySignature.length)
|
|
174576
|
+
throw new Error("Palace reference needs a physical geometry signature");
|
|
174577
|
+
const locations = new Set;
|
|
174578
|
+
for (const sample of reference.samples) {
|
|
174579
|
+
if (![
|
|
174580
|
+
sample.x,
|
|
174581
|
+
sample.y,
|
|
174582
|
+
sample.sheetCurrentXReal,
|
|
174583
|
+
sample.sheetCurrentYReal,
|
|
174584
|
+
sample.sheetCurrentXImag,
|
|
174585
|
+
sample.sheetCurrentYImag
|
|
174586
|
+
].every(Number.isFinite))
|
|
174587
|
+
throw new Error("Non-finite Palace sample");
|
|
174588
|
+
const location2 = `${sample.x},${sample.y}`;
|
|
174589
|
+
if (locations.has(location2))
|
|
174590
|
+
throw new Error("Duplicate Palace sample position");
|
|
174591
|
+
locations.add(location2);
|
|
174592
|
+
}
|
|
174593
|
+
for (const current of [
|
|
174594
|
+
...reference.sourceCurrents,
|
|
174595
|
+
...reference.loadCurrents
|
|
174596
|
+
])
|
|
174597
|
+
if (![current.real, current.imag].every(Number.isFinite))
|
|
174598
|
+
throw new Error("Non-finite Palace port current");
|
|
174599
|
+
}
|
|
174600
|
+
function currentColor2(fraction) {
|
|
174601
|
+
const stops = [
|
|
174602
|
+
[0, 99, 235],
|
|
174603
|
+
[0, 180, 216],
|
|
174604
|
+
[19, 202, 70],
|
|
174605
|
+
[225, 233, 20],
|
|
174606
|
+
[255, 175, 0],
|
|
174607
|
+
[255, 60, 0]
|
|
174608
|
+
];
|
|
174609
|
+
const position = Math.max(0, Math.min(1, fraction)) * (stops.length - 1);
|
|
174610
|
+
const stopIndex = Math.min(stops.length - 2, Math.floor(position));
|
|
174611
|
+
const blend = position - stopIndex;
|
|
174612
|
+
const rgb = stops[stopIndex].map((channel, channelIndex) => Math.round(channel + blend * (stops[stopIndex + 1][channelIndex] - channel)));
|
|
174613
|
+
return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
174614
|
+
}
|
|
174615
|
+
function coordinate2(number) {
|
|
174616
|
+
return (number === 0 ? 0 : number).toFixed(9);
|
|
174617
|
+
}
|
|
174618
|
+
function point13(point2) {
|
|
174619
|
+
return [coordinate2(point2.x), coordinate2(point2.y)];
|
|
174620
|
+
}
|
|
174621
|
+
function palaceGeometrySignature2(geometry) {
|
|
174622
|
+
return JSON.stringify([
|
|
174623
|
+
geometry.boardOutline.map(point13),
|
|
174624
|
+
geometry.groundRegions.map((region) => [
|
|
174625
|
+
region.outer.map(point13),
|
|
174626
|
+
[...region.holes, ...region.maskCutouts ?? []].map((hole) => hole.map(point13))
|
|
174627
|
+
]),
|
|
174628
|
+
geometry.cutouts.map((outline) => outline.map(point13)),
|
|
174629
|
+
geometry.signals.map((signal) => signal.route.filter((route) => route.route_type === "wire").map((route) => [
|
|
174630
|
+
...point13(route),
|
|
174631
|
+
coordinate2(route.width),
|
|
174632
|
+
route.layer
|
|
174633
|
+
])),
|
|
174634
|
+
...geometry.physicalModelSignature ? [geometry.physicalModelSignature] : [],
|
|
174635
|
+
geometry.excitations.map((excitation) => [
|
|
174636
|
+
coordinate2(excitation.current),
|
|
174637
|
+
point13(excitation.return_source),
|
|
174638
|
+
point13(excitation.return_sink),
|
|
174639
|
+
...excitation.source_port || excitation.load_port ? [
|
|
174640
|
+
[
|
|
174641
|
+
excitation.source_port?.reference_layer ?? "bottom",
|
|
174642
|
+
coordinate2(excitation.source_port?.resistance ?? 50),
|
|
174643
|
+
excitation.load_port?.reference_layer ?? "bottom",
|
|
174644
|
+
coordinate2(excitation.load_port?.resistance ?? 50)
|
|
174645
|
+
]
|
|
174646
|
+
] : []
|
|
174647
|
+
])
|
|
174648
|
+
]);
|
|
174649
|
+
}
|
|
174650
|
+
function normalizeLayeredRoute3(trace) {
|
|
174651
|
+
const route = [];
|
|
174652
|
+
for (const [index, point2] of trace.route.entries()) {
|
|
174653
|
+
if (point2.route_type === "wire") {
|
|
174654
|
+
route.push(point2);
|
|
174655
|
+
continue;
|
|
174656
|
+
}
|
|
174657
|
+
if (point2.route_type !== "via")
|
|
174658
|
+
throw new Error("Through-pad route points are not supported");
|
|
174659
|
+
const previous = route.at(-1);
|
|
174660
|
+
const next = trace.route[index + 1];
|
|
174661
|
+
if (!previous || previous.route_type !== "wire" || !next)
|
|
174662
|
+
throw new Error("Via needs signal route endpoints on both sides");
|
|
174663
|
+
if (previous.layer !== point2.from_layer)
|
|
174664
|
+
throw new Error("Via from_layer does not match preceding wire layer");
|
|
174665
|
+
if (previous.x !== point2.x || previous.y !== point2.y)
|
|
174666
|
+
route.push({
|
|
174667
|
+
route_type: "wire",
|
|
174668
|
+
x: point2.x,
|
|
174669
|
+
y: point2.y,
|
|
174670
|
+
width: previous.width,
|
|
174671
|
+
layer: point2.from_layer
|
|
174672
|
+
});
|
|
174673
|
+
route.push(point2);
|
|
174674
|
+
if (next.route_type !== "wire" || next.x !== point2.x || next.y !== point2.y)
|
|
174675
|
+
route.push({
|
|
174676
|
+
route_type: "wire",
|
|
174677
|
+
x: point2.x,
|
|
174678
|
+
y: point2.y,
|
|
174679
|
+
width: next.route_type === "wire" ? next.width : previous.width,
|
|
174680
|
+
layer: point2.to_layer
|
|
174681
|
+
});
|
|
174682
|
+
}
|
|
174683
|
+
return { ...trace, route };
|
|
174684
|
+
}
|
|
174685
|
+
function copperConnectivity2(circuitJson) {
|
|
174686
|
+
const parents = new Map;
|
|
174687
|
+
const find = (id) => {
|
|
174688
|
+
const parent = parents.get(id);
|
|
174689
|
+
if (!parent) {
|
|
174690
|
+
parents.set(id, id);
|
|
174691
|
+
return id;
|
|
174692
|
+
}
|
|
174693
|
+
if (parent === id)
|
|
174694
|
+
return id;
|
|
174695
|
+
const root = find(parent);
|
|
174696
|
+
parents.set(id, root);
|
|
174697
|
+
return root;
|
|
174698
|
+
};
|
|
174699
|
+
for (const element of circuitJson) {
|
|
174700
|
+
if (element.type !== "source_trace")
|
|
174701
|
+
continue;
|
|
174702
|
+
const ids = [
|
|
174703
|
+
element.source_trace_id,
|
|
174704
|
+
...element.connected_source_net_ids,
|
|
174705
|
+
...element.connected_source_port_ids
|
|
174706
|
+
];
|
|
174707
|
+
for (const id of ids)
|
|
174708
|
+
parents.set(find(id), find(ids[0]));
|
|
174709
|
+
}
|
|
174710
|
+
const netIds = new Map;
|
|
174711
|
+
for (const element of circuitJson)
|
|
174712
|
+
if (element.type === "source_net") {
|
|
174713
|
+
const root = find(element.source_net_id);
|
|
174714
|
+
const existing = netIds.get(root);
|
|
174715
|
+
if (existing && existing !== element.source_net_id)
|
|
174716
|
+
throw new Error(`Source connectivity joins distinct nets ${existing} and ${element.source_net_id}`);
|
|
174717
|
+
netIds.set(root, element.source_net_id);
|
|
174718
|
+
}
|
|
174719
|
+
const owner = (id) => netIds.get(find(id)) ?? find(id);
|
|
174720
|
+
const pcbPorts = new Map;
|
|
174721
|
+
const pcbTraces = new Map;
|
|
174722
|
+
for (const element of circuitJson) {
|
|
174723
|
+
if (element.type === "pcb_port")
|
|
174724
|
+
pcbPorts.set(element.pcb_port_id, owner(element.source_port_id));
|
|
174725
|
+
if (element.type === "pcb_trace")
|
|
174726
|
+
pcbTraces.set(element.pcb_trace_id, owner(element.source_trace_id ?? element.pcb_trace_id));
|
|
174727
|
+
}
|
|
174728
|
+
return { owner, pcbPorts, pcbTraces };
|
|
174729
|
+
}
|
|
174730
|
+
function traceOutline2(segment) {
|
|
174731
|
+
const dx = segment.end.x - segment.start.x;
|
|
174732
|
+
const dy = segment.end.y - segment.start.y;
|
|
174733
|
+
const length = Math.hypot(dx, dy);
|
|
174734
|
+
const transform = compose26(translate26((segment.start.x + segment.end.x) / 2, (segment.start.y + segment.end.y) / 2), rotateDEG24(Math.atan2(dy, dx) * 180 / Math.PI));
|
|
174735
|
+
return [-1, 1].flatMap((end) => Array.from({ length: 17 }, (_, index) => {
|
|
174736
|
+
const angle = index * Math.PI / 16 + (end === 1 ? -Math.PI / 2 : Math.PI / 2);
|
|
174737
|
+
return applyToPoint216(transform, {
|
|
174738
|
+
x: end * length / 2 + segment.width / 2 * Math.cos(angle),
|
|
174739
|
+
y: segment.width / 2 * Math.sin(angle)
|
|
174740
|
+
});
|
|
174741
|
+
}));
|
|
174742
|
+
}
|
|
174743
|
+
function roundedOutline2(options) {
|
|
174744
|
+
positiveFinite2(options.width, "outline width");
|
|
174745
|
+
positiveFinite2(options.height, "outline height");
|
|
174746
|
+
const radius = options.radius ?? Math.min(options.width, options.height) / 2;
|
|
174747
|
+
if (!Number.isFinite(radius) || radius <= 0 || radius > Math.min(options.width, options.height) / 2 + 0.0000001)
|
|
174748
|
+
throw new Error("Invalid rounded copper radius");
|
|
174749
|
+
const transform = compose26(translate26(options.x, options.y), rotateDEG24(options.ccwRotationDegrees ?? 0));
|
|
174750
|
+
return Array.from({ length: 32 }, (_, index) => {
|
|
174751
|
+
const angle = index * 2 * Math.PI / 32;
|
|
174752
|
+
const x = Math.cos(angle), y = Math.sin(angle);
|
|
174753
|
+
return applyToPoint216(transform, {
|
|
174754
|
+
x: Math.sign(x) * (options.width / 2 - radius) + radius * x,
|
|
174755
|
+
y: Math.sign(y) * (options.height / 2 - radius) + radius * y
|
|
174756
|
+
});
|
|
174757
|
+
});
|
|
174758
|
+
}
|
|
174759
|
+
function smtPadOutline2(pad) {
|
|
174760
|
+
if (pad.shape === "polygon")
|
|
174761
|
+
return pad.points;
|
|
174762
|
+
if (pad.shape === "circle")
|
|
174763
|
+
return roundedOutline2({
|
|
174764
|
+
...pad,
|
|
174765
|
+
width: 2 * pad.radius,
|
|
174766
|
+
height: 2 * pad.radius
|
|
174767
|
+
});
|
|
174768
|
+
if (pad.shape === "rect" || pad.shape === "rotated_rect")
|
|
174769
|
+
return rectangleOutline3({
|
|
174770
|
+
center: pad,
|
|
174771
|
+
width: pad.width,
|
|
174772
|
+
height: pad.height,
|
|
174773
|
+
rotation: pad.shape === "rotated_rect" ? pad.ccw_rotation : 0
|
|
174774
|
+
});
|
|
174775
|
+
if (pad.shape === "pill" || pad.shape === "rotated_pill")
|
|
174776
|
+
return roundedOutline2({
|
|
174777
|
+
...pad,
|
|
174778
|
+
radius: pad.radius,
|
|
174779
|
+
ccwRotationDegrees: pad.shape === "rotated_pill" ? pad.ccw_rotation : 0
|
|
174780
|
+
});
|
|
174781
|
+
throw new Error("Unsupported SMT pad shape");
|
|
174782
|
+
}
|
|
174783
|
+
function drillOutline2(hole) {
|
|
174784
|
+
if ("hole_diameter" in hole)
|
|
174785
|
+
return roundedOutline2({
|
|
174786
|
+
x: hole.x + ("hole_offset_x" in hole ? hole.hole_offset_x ?? 0 : 0),
|
|
174787
|
+
y: hole.y + ("hole_offset_y" in hole ? hole.hole_offset_y ?? 0 : 0),
|
|
174788
|
+
width: positiveFinite2(hole.hole_diameter ?? 0, "hole diameter"),
|
|
174789
|
+
height: positiveFinite2(hole.hole_diameter ?? 0, "hole diameter")
|
|
174790
|
+
});
|
|
174791
|
+
if ("hole_width" in hole)
|
|
174792
|
+
return roundedOutline2({
|
|
174793
|
+
...hole,
|
|
174794
|
+
width: positiveFinite2(hole.hole_width ?? 0, "hole width"),
|
|
174795
|
+
height: positiveFinite2(hole.hole_height ?? 0, "hole height"),
|
|
174796
|
+
ccwRotationDegrees: "ccw_rotation" in hole ? hole.ccw_rotation : 0
|
|
174797
|
+
});
|
|
174798
|
+
throw new Error("Unsupported drilled hole shape");
|
|
174799
|
+
}
|
|
174800
|
+
function platedPadOutline2(hole) {
|
|
174801
|
+
if ("outer_diameter" in hole)
|
|
174802
|
+
return roundedOutline2({
|
|
174803
|
+
...hole,
|
|
174804
|
+
width: hole.outer_diameter,
|
|
174805
|
+
height: hole.outer_diameter
|
|
174806
|
+
});
|
|
174807
|
+
if ("outer_width" in hole)
|
|
174808
|
+
return roundedOutline2({
|
|
174809
|
+
...hole,
|
|
174810
|
+
width: hole.outer_width,
|
|
174811
|
+
height: hole.outer_height,
|
|
174812
|
+
ccwRotationDegrees: "ccw_rotation" in hole ? hole.ccw_rotation : 0
|
|
174813
|
+
});
|
|
174814
|
+
if (hole.shape === "circular_hole_with_rect_pad")
|
|
174815
|
+
return rectangleOutline3({
|
|
174816
|
+
center: hole,
|
|
174817
|
+
width: hole.rect_pad_width,
|
|
174818
|
+
height: hole.rect_pad_height,
|
|
174819
|
+
rotation: hole.rect_ccw_rotation
|
|
174820
|
+
});
|
|
174821
|
+
throw new Error(`Unsupported plated hole shape: ${hole.shape}`);
|
|
174822
|
+
}
|
|
174823
|
+
function parseFabricationStackup2(input) {
|
|
174824
|
+
return fabricationStackupSchema3.parse(input);
|
|
174825
|
+
}
|
|
174826
|
+
function physicalStackup2(options) {
|
|
174827
|
+
const stackup = parseFabricationStackup2(options.stackup);
|
|
174828
|
+
const expected = [
|
|
174829
|
+
"top",
|
|
174830
|
+
...Array.from({ length: options.numLayers - 2 }, (_, i) => `inner${i + 1}`),
|
|
174831
|
+
"bottom"
|
|
174832
|
+
];
|
|
174833
|
+
if (stackup.layers.length !== 2 * options.numLayers - 1)
|
|
174834
|
+
throw new Error("Stackup must alternate copper and dielectric, top to bottom");
|
|
174835
|
+
const copperLayers = [];
|
|
174836
|
+
const dielectrics = [];
|
|
174837
|
+
const bottom = stackup.layers.at(-1);
|
|
174838
|
+
if (!bottom || !("name" in bottom) || bottom.name !== "bottom")
|
|
174839
|
+
throw new Error("Stackup must end with bottom copper");
|
|
174840
|
+
let z2 = -bottom.copperThicknessMm;
|
|
174841
|
+
for (let index = stackup.layers.length - 1;index >= 0; index--) {
|
|
174842
|
+
const layer = stackup.layers[index];
|
|
174843
|
+
if (index % 2 === 0) {
|
|
174844
|
+
if (!("name" in layer) || layer.name !== expected[index / 2])
|
|
174845
|
+
throw new Error(`Stackup copper order must be ${expected.join(", ")}`);
|
|
174846
|
+
const thickness = positiveFinite2(layer.copperThicknessMm, "copper thickness");
|
|
174847
|
+
copperLayers.unshift({ name: layer.name, zMin: z2, zMax: z2 + thickness });
|
|
174848
|
+
z2 += thickness;
|
|
174849
|
+
} else {
|
|
174850
|
+
if (!("material" in layer))
|
|
174851
|
+
throw new Error("A dielectric is required between copper layers");
|
|
174852
|
+
dielectrics.push({
|
|
174853
|
+
material: layer.material,
|
|
174854
|
+
zMin: z2,
|
|
174855
|
+
zMax: z2 + layer.dielectricThicknessMm,
|
|
174856
|
+
dielectricConstant: layer.dielectricConstant,
|
|
174857
|
+
lossTangent: layer.lossTangent ?? options.lossTangent,
|
|
174858
|
+
attribute: 100 + index
|
|
174859
|
+
});
|
|
174860
|
+
z2 += layer.dielectricThicknessMm;
|
|
174861
|
+
}
|
|
174862
|
+
}
|
|
174863
|
+
return {
|
|
174864
|
+
copperLayers,
|
|
174865
|
+
dielectrics,
|
|
174866
|
+
physicalThicknessMm: z2 + bottom.copperThicknessMm,
|
|
174867
|
+
nominalBoardThicknessMm: stackup.nominalBoardThicknessMm
|
|
174868
|
+
};
|
|
174869
|
+
}
|
|
174870
|
+
function createMultilayerPalaceModel2(options) {
|
|
174871
|
+
options = {
|
|
174872
|
+
...options,
|
|
174873
|
+
circuitJson: options.circuitJson.map((e) => e.type === "pcb_trace" ? normalizeLayeredRoute3(e) : e)
|
|
174874
|
+
};
|
|
174875
|
+
const boards = options.circuitJson.filter((e) => e.type === "pcb_board");
|
|
174876
|
+
if (boards.length !== 1)
|
|
174877
|
+
throw new Error("Exactly one PCB board is required");
|
|
174878
|
+
const board = boards[0];
|
|
174879
|
+
if (options.copperModel === "surface_impedance_copper")
|
|
174880
|
+
throw new Error("surface_impedance_copper currently supports the two-layer mesher only");
|
|
174881
|
+
if (!options.stackup)
|
|
174882
|
+
throw new Error("Multilayer boards require an explicit fabrication stackup (--stackup-file); layer spacing is not inferred");
|
|
174883
|
+
if (!Number.isInteger(board.num_layers) || board.num_layers < 2)
|
|
174884
|
+
throw new Error("Board must have at least two copper layers");
|
|
174885
|
+
if (options.layerSeparation !== undefined || options.copperThickness !== undefined)
|
|
174886
|
+
throw new Error("Use stackup layer thicknesses instead of layerSeparation/copperThickness");
|
|
174887
|
+
const substrateLossTangent = options.substrateLossTangent ?? 0.02;
|
|
174888
|
+
if (!Number.isFinite(substrateLossTangent) || substrateLossTangent < 0)
|
|
174889
|
+
throw new Error("substrateLossTangent must be finite and nonnegative");
|
|
174890
|
+
const stackup = physicalStackup2({
|
|
174891
|
+
stackup: options.stackup,
|
|
174892
|
+
numLayers: board.num_layers,
|
|
174893
|
+
lossTangent: substrateLossTangent
|
|
174894
|
+
});
|
|
174895
|
+
const sampleLayer = options.sampleLayer ?? "bottom";
|
|
174896
|
+
const sampleFoil = stackup.copperLayers.find((layer) => layer.name === sampleLayer);
|
|
174897
|
+
if (!sampleFoil)
|
|
174898
|
+
throw new Error(`Sample layer ${sampleLayer} is absent from the stackup`);
|
|
174899
|
+
const frequencyHz = positiveFinite2(options.frequencyHz, "frequencyHz");
|
|
174900
|
+
const copperConductivity = positiveFinite2(options.copperConductivity ?? 58000000, "copperConductivity");
|
|
174901
|
+
const skinDepthMm = 1000 / Math.sqrt(Math.PI * frequencyHz * 0.0000004 * Math.PI * copperConductivity);
|
|
174902
|
+
if (stackup.copperLayers.some((layer) => layer.zMax - layer.zMin > skinDepthMm))
|
|
174903
|
+
throw new Error("The current volume mesher requires copper thickness <= skin depth; refine copper through its thickness before using higher frequencies");
|
|
174904
|
+
const excitations = options.excitations ?? options.circuitJson.filter((e) => e.type === "simulation_return_current_excitation");
|
|
174905
|
+
const groundIds = new Set(excitations.map((e) => e.ground_source_net_id));
|
|
174906
|
+
if (!excitations.length || groundIds.size !== 1)
|
|
174907
|
+
throw new Error("Specify excitations sharing one reference net");
|
|
174908
|
+
const referenceNetId = excitations[0].ground_source_net_id;
|
|
174909
|
+
const connectivity = copperConnectivity2(options.circuitJson);
|
|
174910
|
+
const boardOutline = board.outline?.length ? board.outline : rectangleOutline3({
|
|
174911
|
+
center: board.center,
|
|
174912
|
+
width: board.width ?? 0,
|
|
174913
|
+
height: board.height ?? 0
|
|
174914
|
+
});
|
|
174915
|
+
const boardCutouts = options.circuitJson.filter((e) => e.type === "pcb_cutout").map(cutoutOutline2);
|
|
174916
|
+
const viaClearance = positiveFinite2(options.viaClearance ?? board.min_trace_to_pad_edge_clearance ?? 0.2, "viaClearance");
|
|
174917
|
+
const layered = {
|
|
174918
|
+
stackup,
|
|
174919
|
+
sampleLayer,
|
|
174920
|
+
referenceNetId,
|
|
174921
|
+
viaClearance,
|
|
174922
|
+
copper: [],
|
|
174923
|
+
drills: [],
|
|
174924
|
+
barrels: [],
|
|
174925
|
+
boardCutouts,
|
|
174926
|
+
audit: {
|
|
174927
|
+
traces: 0,
|
|
174928
|
+
vias: 0,
|
|
174929
|
+
pads: 0,
|
|
174930
|
+
platedHoles: 0,
|
|
174931
|
+
unplatedHoles: 0,
|
|
174932
|
+
pours: 0,
|
|
174933
|
+
omittedCopperElements: 0,
|
|
174934
|
+
warnings: [
|
|
174935
|
+
"Component bodies, package/decoupling impedances, solder mask and silkscreen are not modeled.",
|
|
174936
|
+
"Circular copper/drills use 32-sided polygons. Via plating uses 0.025 mm; override support is not yet provided."
|
|
174937
|
+
]
|
|
174938
|
+
}
|
|
174939
|
+
};
|
|
174940
|
+
const copperByKey = new Map;
|
|
174941
|
+
for (const layer of stackup.copperLayers)
|
|
174942
|
+
for (const netId of [referenceNetId]) {
|
|
174943
|
+
const copper = { layer: layer.name, netId, regions: [], segments: [] };
|
|
174944
|
+
copperByKey.set(`${layer.name}:${netId}`, copper);
|
|
174945
|
+
layered.copper.push(copper);
|
|
174946
|
+
}
|
|
174947
|
+
const traces = new Map;
|
|
174948
|
+
for (const element of options.circuitJson) {
|
|
174949
|
+
if (element.type === "pcb_trace")
|
|
174950
|
+
traces.set(element.pcb_trace_id, element);
|
|
174951
|
+
const additions = [];
|
|
174952
|
+
if (element.type === "pcb_copper_pour") {
|
|
174953
|
+
if (!element.source_net_id)
|
|
174954
|
+
throw new Error("Copper pours require a source_net_id");
|
|
174955
|
+
additions.push({
|
|
174956
|
+
layer: element.layer,
|
|
174957
|
+
netId: connectivity.owner(element.source_net_id),
|
|
174958
|
+
region: { ...pourRegion3(element), isPlane: true }
|
|
174959
|
+
});
|
|
174960
|
+
layered.audit.pours++;
|
|
174961
|
+
}
|
|
174962
|
+
if (element.type === "pcb_ground_plane_region") {
|
|
174963
|
+
const plane = options.circuitJson.find((e) => e.type === "pcb_ground_plane" && e.pcb_ground_plane_id === element.pcb_ground_plane_id);
|
|
174964
|
+
if (!plane || plane.type !== "pcb_ground_plane")
|
|
174965
|
+
throw new Error("Missing ground-plane owner");
|
|
174966
|
+
additions.push({
|
|
174967
|
+
layer: element.layer,
|
|
174968
|
+
netId: connectivity.owner(plane.source_net_id),
|
|
174969
|
+
region: { outer: element.points, holes: [], isPlane: true }
|
|
174970
|
+
});
|
|
174971
|
+
}
|
|
174972
|
+
if (element.type === "pcb_smtpad") {
|
|
174973
|
+
const netId = element.pcb_port_id ? connectivity.pcbPorts.get(element.pcb_port_id) : undefined;
|
|
174974
|
+
if (!netId)
|
|
174975
|
+
throw new Error(`Pad ${element.pcb_smtpad_id} has no PCB port ownership`);
|
|
174976
|
+
additions.push({
|
|
174977
|
+
layer: element.layer,
|
|
174978
|
+
netId,
|
|
174979
|
+
region: { outer: smtPadOutline2(element), holes: [] }
|
|
174980
|
+
});
|
|
174981
|
+
layered.audit.pads++;
|
|
174982
|
+
}
|
|
174983
|
+
if (element.type === "pcb_trace") {
|
|
174984
|
+
const netId = connectivity.pcbTraces.get(element.pcb_trace_id);
|
|
174985
|
+
validateLayeredRoute2(element, stackup.copperLayers.map((l) => l.name));
|
|
174986
|
+
for (let index = 1;index < element.route.length; index++) {
|
|
174987
|
+
const start = element.route[index - 1], end = element.route[index];
|
|
174988
|
+
if (start.route_type !== "wire" || end.route_type !== "wire")
|
|
174989
|
+
continue;
|
|
174990
|
+
if (start.x === end.x && start.y === end.y)
|
|
174991
|
+
continue;
|
|
174992
|
+
additions.push({
|
|
174993
|
+
layer: start.layer,
|
|
174994
|
+
netId,
|
|
174995
|
+
segment: {
|
|
174996
|
+
start,
|
|
174997
|
+
end,
|
|
174998
|
+
width: positiveFinite2(start.width, "trace width"),
|
|
174999
|
+
current: 0
|
|
175000
|
+
}
|
|
175001
|
+
});
|
|
175002
|
+
}
|
|
175003
|
+
layered.audit.traces++;
|
|
175004
|
+
}
|
|
175005
|
+
for (const addition of additions) {
|
|
175006
|
+
if (!stackup.copperLayers.some((l) => l.name === addition.layer))
|
|
175007
|
+
throw new Error(`Copper on absent layer ${addition.layer}`);
|
|
175008
|
+
const key = `${addition.layer}:${addition.netId}`;
|
|
175009
|
+
let copper = copperByKey.get(key);
|
|
175010
|
+
if (!copper) {
|
|
175011
|
+
copper = {
|
|
175012
|
+
layer: addition.layer,
|
|
175013
|
+
netId: addition.netId,
|
|
175014
|
+
regions: [],
|
|
175015
|
+
segments: []
|
|
175016
|
+
};
|
|
175017
|
+
copperByKey.set(key, copper);
|
|
175018
|
+
layered.copper.push(copper);
|
|
175019
|
+
}
|
|
175020
|
+
if (addition.region)
|
|
175021
|
+
copper.regions.push(addition.region);
|
|
175022
|
+
if (addition.segment)
|
|
175023
|
+
copper.segments.push(addition.segment);
|
|
175024
|
+
}
|
|
175025
|
+
}
|
|
175026
|
+
const bottom = stackup.copperLayers.at(-1), top = stackup.copperLayers[0];
|
|
175027
|
+
for (const element of options.circuitJson) {
|
|
175028
|
+
if (element.type === "pcb_hole") {
|
|
175029
|
+
layered.drills.push({
|
|
175030
|
+
hole: drillOutline2(element),
|
|
175031
|
+
zMin: bottom.zMin,
|
|
175032
|
+
zMax: top.zMax
|
|
175033
|
+
});
|
|
175034
|
+
layered.audit.unplatedHoles++;
|
|
175035
|
+
}
|
|
175036
|
+
if (element.type !== "pcb_via" && element.type !== "pcb_plated_hole")
|
|
175037
|
+
continue;
|
|
175038
|
+
const netId = element.type === "pcb_via" ? element.source_net_id ? connectivity.owner(element.source_net_id) : element.pcb_trace_id ? connectivity.pcbTraces.get(element.pcb_trace_id) : undefined : element.pcb_port_id ? connectivity.pcbPorts.get(element.pcb_port_id) : undefined;
|
|
175039
|
+
if (!netId)
|
|
175040
|
+
throw new Error(`Cannot identify copper ownership for ${element.type}; supply source_net_id or pcb_trace_id`);
|
|
175041
|
+
const layers = element.layers.map((name) => stackup.copperLayers.find((l) => l.name === name));
|
|
175042
|
+
if (!layers.length || layers.some((l) => !l))
|
|
175043
|
+
throw new Error("Via/hole references an absent stackup layer");
|
|
175044
|
+
const zMin = Math.min(...layers.map((l) => l.zMin)), zMax = Math.max(...layers.map((l) => l.zMax));
|
|
175045
|
+
const hole = element.type === "pcb_via" ? roundedOutline2({
|
|
175046
|
+
...element,
|
|
175047
|
+
width: element.hole_diameter,
|
|
175048
|
+
height: element.hole_diameter
|
|
175049
|
+
}) : drillOutline2(element);
|
|
175050
|
+
const pads = element.type === "pcb_via" ? roundedOutline2({
|
|
175051
|
+
...element,
|
|
175052
|
+
width: element.outer_diameter,
|
|
175053
|
+
height: element.outer_diameter
|
|
175054
|
+
}) : platedPadOutline2(element);
|
|
175055
|
+
layered.barrels.push({
|
|
175056
|
+
netId,
|
|
175057
|
+
hole,
|
|
175058
|
+
outer: hole,
|
|
175059
|
+
pads,
|
|
175060
|
+
layers: element.layers,
|
|
175061
|
+
zMin,
|
|
175062
|
+
zMax,
|
|
175063
|
+
platingThickness: 0.025
|
|
175064
|
+
});
|
|
175065
|
+
layered.drills.push({ hole, zMin, zMax });
|
|
175066
|
+
if (element.type === "pcb_via")
|
|
175067
|
+
layered.audit.vias++;
|
|
175068
|
+
else
|
|
175069
|
+
layered.audit.platedHoles++;
|
|
175070
|
+
}
|
|
175071
|
+
for (const trace of traces.values())
|
|
175072
|
+
for (const route of trace.route) {
|
|
175073
|
+
if (route.route_type !== "via")
|
|
175074
|
+
continue;
|
|
175075
|
+
const barrel = layered.barrels.find((b) => b.netId === connectivity.pcbTraces.get(trace.pcb_trace_id) && pointInPolygon3(route, b.hole) && b.layers.includes(route.from_layer) && b.layers.includes(route.to_layer));
|
|
175076
|
+
if (!barrel)
|
|
175077
|
+
throw new Error(`Trace ${trace.pcb_trace_id} changes layer without a matching physical pcb_via`);
|
|
175078
|
+
}
|
|
175079
|
+
for (const barrel of layered.barrels)
|
|
175080
|
+
barrel.clearance = barrelClearanceOutline2(barrel, viaClearance);
|
|
175081
|
+
const groundCopper = copperByKey.get(`${sampleLayer}:${referenceNetId}`);
|
|
175082
|
+
if (!groundCopper.regions.length)
|
|
175083
|
+
throw new Error(`Reference net needs copper regions on sample layer ${sampleLayer}; no plane is invented`);
|
|
175084
|
+
const geometry = {
|
|
175085
|
+
board,
|
|
175086
|
+
boardOutline,
|
|
175087
|
+
groundRegions: groundCopper.regions.map((region) => ({
|
|
175088
|
+
...region,
|
|
175089
|
+
holes: [...region.holes]
|
|
175090
|
+
})),
|
|
175091
|
+
cutouts: [...boardCutouts],
|
|
175092
|
+
signals: [],
|
|
175093
|
+
segments: [],
|
|
175094
|
+
excitations
|
|
175095
|
+
};
|
|
175096
|
+
for (const segment of groundCopper.segments) {
|
|
175097
|
+
geometry.groundRegions.push({
|
|
175098
|
+
outer: traceOutline2(segment),
|
|
175099
|
+
holes: []
|
|
175100
|
+
});
|
|
175101
|
+
}
|
|
175102
|
+
for (const barrel of layered.barrels) {
|
|
175103
|
+
if (barrel.zMin > sampleFoil.zMin || barrel.zMax < sampleFoil.zMax)
|
|
175104
|
+
continue;
|
|
175105
|
+
if (barrel.netId === referenceNetId && barrel.layers.includes(sampleLayer))
|
|
175106
|
+
geometry.groundRegions.push({ outer: barrel.pads, holes: [barrel.hole] });
|
|
175107
|
+
else if (barrel.netId !== referenceNetId) {
|
|
175108
|
+
for (const region of geometry.groundRegions.filter((region2) => region2.isPlane))
|
|
175109
|
+
region.maskCutouts = [
|
|
175110
|
+
...region.maskCutouts ?? [],
|
|
175111
|
+
barrelClearanceOutline2(barrel, viaClearance)
|
|
175112
|
+
];
|
|
175113
|
+
}
|
|
175114
|
+
}
|
|
175115
|
+
for (const drill of layered.drills)
|
|
175116
|
+
if (drill.zMin <= sampleFoil.zMin && drill.zMax >= sampleFoil.zMax)
|
|
175117
|
+
geometry.cutouts.push(drill.hole);
|
|
175118
|
+
const ports = [];
|
|
175119
|
+
for (const excitation of excitations) {
|
|
175120
|
+
if (!Number.isFinite(excitation.current))
|
|
175121
|
+
throw new Error("Excitation current must be finite");
|
|
175122
|
+
const trace = traces.get(excitation.pcb_trace_id);
|
|
175123
|
+
if (!trace)
|
|
175124
|
+
throw new Error("Missing excited trace");
|
|
175125
|
+
geometry.signals.push(trace);
|
|
175126
|
+
for (let index = 1;index < trace.route.length; index++) {
|
|
175127
|
+
const start = trace.route[index - 1], end = trace.route[index];
|
|
175128
|
+
if (start.route_type === "wire" && end.route_type === "wire" && (start.x !== end.x || start.y !== end.y))
|
|
175129
|
+
geometry.segments.push({
|
|
175130
|
+
start,
|
|
175131
|
+
end,
|
|
175132
|
+
width: start.width,
|
|
175133
|
+
current: excitation.current
|
|
175134
|
+
});
|
|
175135
|
+
}
|
|
175136
|
+
for (const [endpoint, reference, terminal] of [
|
|
175137
|
+
[trace.route[0], excitation.return_sink, excitation.source_port],
|
|
175138
|
+
[trace.route.at(-1), excitation.return_source, excitation.load_port]
|
|
175139
|
+
]) {
|
|
175140
|
+
if (endpoint.route_type !== "wire")
|
|
175141
|
+
throw new Error("Ports require wire endpoints");
|
|
175142
|
+
const signalFoil = stackup.copperLayers.find((l) => l.name === endpoint.layer);
|
|
175143
|
+
const referenceFoil = stackup.copperLayers.find((l) => l.name === (terminal?.reference_layer ?? sampleLayer));
|
|
175144
|
+
if (!referenceFoil)
|
|
175145
|
+
throw new Error("Reference layer is absent");
|
|
175146
|
+
if (terminal?.reference_pcb_port_id && connectivity.pcbPorts.get(terminal.reference_pcb_port_id) !== referenceNetId)
|
|
175147
|
+
throw new Error("Reference pin is not on the reference net");
|
|
175148
|
+
for (const [id, position, layer] of [
|
|
175149
|
+
[terminal?.signal_pcb_port_id, endpoint, endpoint.layer],
|
|
175150
|
+
[terminal?.reference_pcb_port_id, reference, referenceFoil.name]
|
|
175151
|
+
]) {
|
|
175152
|
+
if (!id)
|
|
175153
|
+
continue;
|
|
175154
|
+
const pcb = options.circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === id);
|
|
175155
|
+
if (!pcb || pcb.type !== "pcb_port" || !pcb.layers.includes(layer) || Math.hypot(pcb.x - position.x, pcb.y - position.y) > 0.000001)
|
|
175156
|
+
throw new Error("Terminal does not match its PCB port position/layer");
|
|
175157
|
+
}
|
|
175158
|
+
const below = referenceFoil.zMin < signalFoil.zMin;
|
|
175159
|
+
ports.push({
|
|
175160
|
+
signal: { x: endpoint.x, y: endpoint.y },
|
|
175161
|
+
reference: { x: reference.x, y: reference.y },
|
|
175162
|
+
signalLayer: signalFoil.name,
|
|
175163
|
+
referenceLayer: referenceFoil.name,
|
|
175164
|
+
signalZ: below ? signalFoil.zMin : signalFoil.zMax,
|
|
175165
|
+
referenceZ: below ? referenceFoil.zMax : referenceFoil.zMin === signalFoil.zMin ? signalFoil.zMax : referenceFoil.zMin,
|
|
175166
|
+
resistance: positiveFinite2(terminal?.resistance ?? options.portResistance ?? 50, "port resistance"),
|
|
175167
|
+
signalPcbPortId: terminal?.signal_pcb_port_id,
|
|
175168
|
+
referencePcbPortId: terminal?.reference_pcb_port_id
|
|
175169
|
+
});
|
|
175170
|
+
}
|
|
175171
|
+
}
|
|
175172
|
+
if (stackup.nominalBoardThicknessMm && Math.abs(stackup.physicalThicknessMm - stackup.nominalBoardThicknessMm) > 0.0001)
|
|
175173
|
+
layered.audit.warnings.push(`Explicit stack sums to ${stackup.physicalThicknessMm} mm, nominal ${stackup.nominalBoardThicknessMm} mm; explicit dimensions are used without scaling.`);
|
|
175174
|
+
geometry.physicalModelSignature = JSON.stringify({
|
|
175175
|
+
...layered,
|
|
175176
|
+
traceEndCaps: "round_32",
|
|
175177
|
+
audit: undefined
|
|
175178
|
+
});
|
|
175179
|
+
const order = options.order ?? 2;
|
|
175180
|
+
if (order !== 1 && order !== 2)
|
|
175181
|
+
throw new Error("order must be 1 or 2");
|
|
175182
|
+
return {
|
|
175183
|
+
schemaVersion: 1,
|
|
175184
|
+
multilayer: layered,
|
|
175185
|
+
geometry,
|
|
175186
|
+
ports,
|
|
175187
|
+
topPads: [],
|
|
175188
|
+
frequencyHz,
|
|
175189
|
+
layerSeparation: top.zMin - bottom.zMax,
|
|
175190
|
+
copperThickness: sampleFoil.zMax - sampleFoil.zMin,
|
|
175191
|
+
copperConductivity,
|
|
175192
|
+
substratePermittivity: options.substratePermittivity ?? 4.3,
|
|
175193
|
+
substrateLossTangent,
|
|
175194
|
+
portResistance: positiveFinite2(options.portResistance ?? 50, "portResistance"),
|
|
175195
|
+
portWidth: positiveFinite2(options.portWidth ?? 0.18, "portWidth"),
|
|
175196
|
+
meshSize: positiveFinite2(options.meshSize ?? 1, "meshSize"),
|
|
175197
|
+
airPadding: positiveFinite2(options.airPadding ?? 10, "airPadding"),
|
|
175198
|
+
order,
|
|
175199
|
+
copperModel: "volumetric_copper"
|
|
175200
|
+
};
|
|
175201
|
+
}
|
|
175202
|
+
function validateLayeredRoute2(trace, layers) {
|
|
175203
|
+
for (const [index, route] of trace.route.entries()) {
|
|
175204
|
+
if (route.route_type !== "wire" && route.route_type !== "via")
|
|
175205
|
+
throw new Error("Unsupported route point type");
|
|
175206
|
+
if (![route.x, route.y].every(Number.isFinite))
|
|
175207
|
+
throw new Error("Trace coordinates must be finite");
|
|
175208
|
+
if (route.route_type === "wire") {
|
|
175209
|
+
positiveFinite2(route.width, "trace width");
|
|
175210
|
+
if (!layers.includes(route.layer))
|
|
175211
|
+
throw new Error(`Unknown route layer ${route.layer}`);
|
|
175212
|
+
const previous = trace.route[index - 1];
|
|
175213
|
+
if (previous?.route_type === "wire" && previous.layer !== route.layer)
|
|
175214
|
+
throw new Error("Layer transitions require a via route point");
|
|
175215
|
+
} else if (route.route_type === "via") {
|
|
175216
|
+
const previous = trace.route[index - 1], next = trace.route[index + 1];
|
|
175217
|
+
if (previous?.route_type !== "wire" || next?.route_type !== "wire" || previous.layer !== route.from_layer || next.layer !== route.to_layer || Math.hypot(previous.x - route.x, previous.y - route.y) > 0.000001 || Math.hypot(next.x - route.x, next.y - route.y) > 0.000001)
|
|
175218
|
+
throw new Error("Via route must join matching, colocated layer endpoints");
|
|
175219
|
+
} else
|
|
175220
|
+
throw new Error("Unsupported route point type");
|
|
175221
|
+
}
|
|
175222
|
+
}
|
|
175223
|
+
function barrelClearanceOutline2(barrel, clearance) {
|
|
175224
|
+
const xs = barrel.pads.map((p) => p.x), ys = barrel.pads.map((p) => p.y);
|
|
175225
|
+
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
175226
|
+
return rectangleOutline3({
|
|
175227
|
+
center: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
|
|
175228
|
+
width: maxX - minX + 2 * clearance,
|
|
175229
|
+
height: maxY - minY + 2 * clearance
|
|
175230
|
+
});
|
|
175231
|
+
}
|
|
175232
|
+
function portNetIds3(circuitJson, sourcePortId) {
|
|
175233
|
+
const ports = new Set([sourcePortId]);
|
|
175234
|
+
const nets = new Set;
|
|
175235
|
+
const pending = circuitJson.filter((element) => element.type === "source_trace");
|
|
175236
|
+
let changed = true;
|
|
175237
|
+
while (changed) {
|
|
175238
|
+
changed = false;
|
|
175239
|
+
for (const trace of pending) {
|
|
175240
|
+
if (!trace.connected_source_port_ids.some((id) => ports.has(id)) && !trace.connected_source_net_ids.some((id) => nets.has(id)))
|
|
175241
|
+
continue;
|
|
175242
|
+
for (const id of trace.connected_source_port_ids)
|
|
175243
|
+
if (!ports.has(id)) {
|
|
175244
|
+
ports.add(id);
|
|
175245
|
+
changed = true;
|
|
175246
|
+
}
|
|
175247
|
+
for (const id of trace.connected_source_net_ids)
|
|
175248
|
+
if (!nets.has(id)) {
|
|
175249
|
+
nets.add(id);
|
|
175250
|
+
changed = true;
|
|
175251
|
+
}
|
|
175252
|
+
}
|
|
175253
|
+
}
|
|
175254
|
+
return [...nets];
|
|
175255
|
+
}
|
|
175256
|
+
function createPalaceModel2(options) {
|
|
175257
|
+
if (options.stackup || options.circuitJson.some((element) => element.type === "pcb_board" && element.num_layers > 2))
|
|
175258
|
+
return createMultilayerPalaceModel2(options);
|
|
175259
|
+
if (options.sampleLayer && options.sampleLayer !== "bottom")
|
|
175260
|
+
throw new Error("A non-bottom sample layer requires an explicit stackup");
|
|
175261
|
+
const frequencyHz = positiveFinite2(options.frequencyHz, "frequencyHz");
|
|
175262
|
+
const geometry = readGeometry2({
|
|
175263
|
+
...options,
|
|
175264
|
+
circuitJson: options.circuitJson.filter((element) => element.type !== "pcb_via")
|
|
175265
|
+
});
|
|
175266
|
+
const layerSeparation = positiveFinite2(options.layerSeparation ?? geometry.board.thickness ?? 0.8, "layerSeparation");
|
|
175267
|
+
const copperThickness = positiveFinite2(options.copperThickness ?? 0.035, "copperThickness");
|
|
175268
|
+
const copperConductivity = positiveFinite2(options.copperConductivity ?? 58000000, "copperConductivity");
|
|
175269
|
+
const skinDepthMm = 1000 / Math.sqrt(Math.PI * frequencyHz * 0.0000004 * Math.PI * copperConductivity);
|
|
175270
|
+
const copperModel = options.copperModel ?? "volumetric_copper";
|
|
175271
|
+
if (copperModel !== "volumetric_copper" && copperModel !== "surface_impedance_copper")
|
|
175272
|
+
throw new Error("Unknown copperModel");
|
|
175273
|
+
if (copperModel === "surface_impedance_copper" && copperThickness < 3 * skinDepthMm)
|
|
175274
|
+
throw new Error("The half-space surface-impedance model requires foil and via-wall thickness >= 3 skin depths; use volumetric copper at lower frequencies");
|
|
175275
|
+
if (copperModel === "volumetric_copper" && copperThickness > skinDepthMm)
|
|
175276
|
+
throw new Error("The current volume mesher requires copper thickness <= skin depth; refine copper through its thickness before using higher frequencies");
|
|
175277
|
+
const ports = [];
|
|
175278
|
+
for (const [index, signal] of geometry.signals.entries()) {
|
|
175279
|
+
const first = signal.route[0];
|
|
175280
|
+
const last = signal.route.at(-1);
|
|
175281
|
+
const excitation = geometry.excitations[index];
|
|
175282
|
+
if (!last || first.route_type !== "wire" || last.route_type !== "wire")
|
|
175283
|
+
throw new Error("Palace requires wire endpoints");
|
|
175284
|
+
for (const [endpoint, contact, terminal] of [
|
|
175285
|
+
[first, excitation.return_sink, excitation.source_port],
|
|
175286
|
+
[last, excitation.return_source, excitation.load_port]
|
|
175287
|
+
]) {
|
|
175288
|
+
if (!terminal && Math.hypot(endpoint.x - contact.x, endpoint.y - contact.y) > 0.000001)
|
|
175289
|
+
throw new Error("Legacy Palace ports require return contacts directly below signal endpoints; specify explicit terminal metadata for offset references");
|
|
175290
|
+
for (const [id, position, role] of [
|
|
175291
|
+
[terminal?.signal_pcb_port_id, endpoint, "signal"],
|
|
175292
|
+
[terminal?.reference_pcb_port_id, contact, "reference"]
|
|
175293
|
+
]) {
|
|
175294
|
+
if (!id)
|
|
175295
|
+
continue;
|
|
175296
|
+
const pcb = options.circuitJson.find((element) => element.type === "pcb_port" && element.pcb_port_id === id);
|
|
175297
|
+
if (!pcb || pcb.type !== "pcb_port" || Math.hypot(pcb.x - position.x, pcb.y - position.y) > 0.000001)
|
|
175298
|
+
throw new Error(`Explicit ${role} terminal does not match its PCB port`);
|
|
175299
|
+
const layer = role === "signal" ? "top" : terminal.reference_layer;
|
|
175300
|
+
if (!pcb.layers.includes(layer))
|
|
175301
|
+
throw new Error(`Explicit ${role} terminal is not on ${layer}`);
|
|
175302
|
+
if (role === "reference" && !portNetIds3(options.circuitJson, pcb.source_port_id).includes(excitation.ground_source_net_id))
|
|
175303
|
+
throw new Error("Explicit reference terminal is not connected to the ground net");
|
|
175304
|
+
}
|
|
175305
|
+
if (terminal?.reference_layer === "top" && !terminal.reference_pcb_port_id)
|
|
175306
|
+
throw new Error("Top reference requires an explicit PCB port");
|
|
175307
|
+
ports.push({
|
|
175308
|
+
signal: { x: endpoint.x, y: endpoint.y },
|
|
175309
|
+
reference: { x: contact.x, y: contact.y },
|
|
175310
|
+
referenceLayer: terminal?.reference_layer ?? "bottom",
|
|
175311
|
+
resistance: positiveFinite2(terminal?.resistance ?? options.portResistance ?? 50, "port resistance"),
|
|
175312
|
+
signalPcbPortId: terminal?.signal_pcb_port_id,
|
|
175313
|
+
referencePcbPortId: terminal?.reference_pcb_port_id
|
|
175314
|
+
});
|
|
175315
|
+
}
|
|
175316
|
+
}
|
|
175317
|
+
const referenceIds = new Set(ports.filter((port) => port.referenceLayer === "top").map((port) => port.referencePcbPortId));
|
|
175318
|
+
const topGroundPads = [];
|
|
175319
|
+
const topPads = options.circuitJson.flatMap((element) => {
|
|
175320
|
+
if (element.type !== "pcb_smtpad" || element.layer !== "top")
|
|
175321
|
+
return [];
|
|
175322
|
+
if (element.shape !== "rect")
|
|
175323
|
+
throw new Error("Palace currently supports rectangular top SMT pads");
|
|
175324
|
+
const outline = rectangleOutline3({
|
|
175325
|
+
center: { x: element.x, y: element.y },
|
|
175326
|
+
width: element.width,
|
|
175327
|
+
height: element.height
|
|
175328
|
+
});
|
|
175329
|
+
if (referenceIds.has(element.pcb_port_id)) {
|
|
175330
|
+
topGroundPads.push(outline);
|
|
175331
|
+
return [];
|
|
175332
|
+
}
|
|
175333
|
+
return [outline];
|
|
175334
|
+
});
|
|
175335
|
+
const groundVias = options.circuitJson.flatMap((element) => {
|
|
175336
|
+
if (element.type !== "pcb_via")
|
|
175337
|
+
return [];
|
|
175338
|
+
const pad = options.circuitJson.find((pad2) => pad2.type === "pcb_smtpad" && pad2.shape === "rect" && pad2.layer === "top" && referenceIds.has(pad2.pcb_port_id) && Math.hypot(pad2.x - element.x, pad2.y - element.y) < 0.000001);
|
|
175339
|
+
if (!pad || pad.type !== "pcb_smtpad" || pad.shape !== "rect" || element.source_net_id !== geometry.excitations[0].ground_source_net_id || !element.layers.includes("top") || !element.layers.includes("bottom") || element.layers.length !== 2)
|
|
175340
|
+
throw new Error("Only top-to-bottom ground vias centered in selected rectangular reference pads are supported");
|
|
175341
|
+
const holeDiameter = positiveFinite2(element.hole_diameter, "via hole diameter");
|
|
175342
|
+
const outerDiameter = positiveFinite2(element.outer_diameter, "via outer diameter");
|
|
175343
|
+
if (holeDiameter + 2 * copperThickness >= outerDiameter || outerDiameter > Math.min(pad.width, pad.height) || !isCopper2(element, geometry))
|
|
175344
|
+
throw new Error("Ground via needs a plated barrel inside its pad and selected bottom copper");
|
|
175345
|
+
return [
|
|
175346
|
+
{
|
|
175347
|
+
x: element.x,
|
|
175348
|
+
y: element.y,
|
|
175349
|
+
holeDiameter,
|
|
175350
|
+
outerDiameter,
|
|
175351
|
+
platingThickness: copperThickness
|
|
175352
|
+
}
|
|
175353
|
+
];
|
|
175354
|
+
});
|
|
175355
|
+
for (const port of ports.filter((port2) => port2.referenceLayer === "top")) {
|
|
175356
|
+
if (!groundVias.some((via) => Math.hypot(via.x - port.reference.x, via.y - port.reference.y) < 0.000001))
|
|
175357
|
+
throw new Error("A top reference pad needs a real concentric ground via in circuit-json; ground-pad names alone do not connect copper");
|
|
175358
|
+
}
|
|
175359
|
+
for (const via of groundVias)
|
|
175360
|
+
geometry.cutouts.push(Array.from({ length: 32 }, (_, index) => ({
|
|
175361
|
+
x: via.x + via.holeDiameter / 2 * Math.cos(index * 2 * Math.PI / 32),
|
|
175362
|
+
y: via.y + via.holeDiameter / 2 * Math.sin(index * 2 * Math.PI / 32)
|
|
175363
|
+
})));
|
|
175364
|
+
const substrateLossTangent = options.substrateLossTangent ?? 0.02;
|
|
175365
|
+
if (!Number.isFinite(substrateLossTangent) || substrateLossTangent < 0)
|
|
175366
|
+
throw new Error("substrateLossTangent must be finite and nonnegative");
|
|
175367
|
+
const order = options.order ?? 2;
|
|
175368
|
+
if (order !== 1 && order !== 2)
|
|
175369
|
+
throw new Error("order must be 1 or 2");
|
|
175370
|
+
return {
|
|
175371
|
+
schemaVersion: 1,
|
|
175372
|
+
geometry,
|
|
175373
|
+
topPads,
|
|
175374
|
+
topGroundPads,
|
|
175375
|
+
groundVias,
|
|
175376
|
+
ports,
|
|
175377
|
+
frequencyHz,
|
|
175378
|
+
layerSeparation,
|
|
175379
|
+
copperThickness,
|
|
175380
|
+
copperConductivity,
|
|
175381
|
+
substratePermittivity: positiveFinite2(options.substratePermittivity ?? 4.3, "substratePermittivity"),
|
|
175382
|
+
substrateLossTangent,
|
|
175383
|
+
portResistance: positiveFinite2(options.portResistance ?? 50, "portResistance"),
|
|
175384
|
+
portWidth: positiveFinite2(options.portWidth ?? 0.18, "portWidth"),
|
|
175385
|
+
meshSize: positiveFinite2(options.meshSize ?? 1, "meshSize"),
|
|
175386
|
+
airPadding: positiveFinite2(options.airPadding ?? 10, "airPadding"),
|
|
175387
|
+
order,
|
|
175388
|
+
copperModel,
|
|
175389
|
+
...copperModel === "surface_impedance_copper" ? {
|
|
175390
|
+
surfaceImpedance: {
|
|
175391
|
+
boundaryModel: "half_space",
|
|
175392
|
+
skinDepthMm,
|
|
175393
|
+
minimumThicknessToSkinDepth: copperThickness / skinDepthMm,
|
|
175394
|
+
currentSampling: "sum_foil_face_surface_currents"
|
|
175395
|
+
}
|
|
175396
|
+
} : {}
|
|
175397
|
+
};
|
|
175398
|
+
}
|
|
175399
|
+
function boundedPolygon2(outline) {
|
|
175400
|
+
return {
|
|
175401
|
+
outline,
|
|
175402
|
+
minX: Math.min(...outline.map((p) => p.x)),
|
|
175403
|
+
maxX: Math.max(...outline.map((p) => p.x)),
|
|
175404
|
+
minY: Math.min(...outline.map((p) => p.y)),
|
|
175405
|
+
maxY: Math.max(...outline.map((p) => p.y))
|
|
175406
|
+
};
|
|
175407
|
+
}
|
|
175408
|
+
function contains3(point2, polygon) {
|
|
175409
|
+
return point2.x >= polygon.minX - 0.000000001 && point2.x <= polygon.maxX + 0.000000001 && point2.y >= polygon.minY - 0.000000001 && point2.y <= polygon.maxY + 0.000000001 && pointInPolygon3(point2, polygon.outline);
|
|
175410
|
+
}
|
|
175411
|
+
function createSampleMask2(geometry) {
|
|
175412
|
+
const board = boundedPolygon2(geometry.boardOutline);
|
|
175413
|
+
const cutouts = geometry.cutouts.map(boundedPolygon2);
|
|
175414
|
+
const regions = geometry.groundRegions.map((r) => ({
|
|
175415
|
+
outer: boundedPolygon2(r.outer),
|
|
175416
|
+
holes: [...r.holes, ...r.maskCutouts ?? []].map(boundedPolygon2)
|
|
175417
|
+
}));
|
|
175418
|
+
return (point2) => contains3(point2, board) && !cutouts.some((cutout) => contains3(point2, cutout)) && regions.some((r) => contains3(point2, r.outer) && !r.holes.some((hole) => contains3(point2, hole)));
|
|
175419
|
+
}
|
|
175420
|
+
function assertUniqueIds2(circuitJson) {
|
|
175421
|
+
const ids = new Set;
|
|
175422
|
+
for (const element of circuitJson) {
|
|
175423
|
+
const key = `${element.type}_id`;
|
|
175424
|
+
const value = element[key];
|
|
175425
|
+
if (typeof value !== "string")
|
|
175426
|
+
continue;
|
|
175427
|
+
if (ids.has(value))
|
|
175428
|
+
throw new Error(`Duplicate circuit-json ID: ${value}`);
|
|
175429
|
+
ids.add(value);
|
|
175430
|
+
}
|
|
175431
|
+
}
|
|
175432
|
+
function validateContact2(circuitJson, contact, groundId) {
|
|
175433
|
+
if (contact.contact_type === "pcb_port") {
|
|
175434
|
+
const port = circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === contact.pcb_port_id);
|
|
175435
|
+
if (!port || port.type !== "pcb_port" || !port.layers.includes(contact.layer) || Math.hypot(port.x - contact.x, port.y - contact.y) > 0.000001)
|
|
175436
|
+
throw new Error(`Return PCB port ${contact.pcb_port_id} does not match contact location/layer`);
|
|
175437
|
+
if (!portNetIds3(circuitJson, port.source_port_id).includes(groundId))
|
|
175438
|
+
throw new Error(`Return PCB port ${contact.pcb_port_id} is not on the selected ground net`);
|
|
175439
|
+
} else if (contact.contact_type === "pcb_via") {
|
|
175440
|
+
const via = circuitJson.find((e) => e.type === "pcb_via" && e.pcb_via_id === contact.pcb_via_id);
|
|
175441
|
+
if (!via || via.type !== "pcb_via" || !via.layers.includes(contact.layer) || Math.hypot(via.x - contact.x, via.y - contact.y) > 0.000001)
|
|
175442
|
+
throw new Error(`Return PCB via ${contact.pcb_via_id} does not match contact location/layer`);
|
|
175443
|
+
if (via.source_net_id !== groundId)
|
|
175444
|
+
throw new Error(`Return PCB via ${contact.pcb_via_id} needs explicit selected-ground ownership`);
|
|
175445
|
+
} else {
|
|
175446
|
+
const pour = circuitJson.find((e) => e.type === "pcb_copper_pour" && e.pcb_copper_pour_id === contact.pcb_copper_pour_id);
|
|
175447
|
+
if (!pour || pour.type !== "pcb_copper_pour" || pour.layer !== contact.layer || pour.source_net_id !== groundId)
|
|
175448
|
+
throw new Error(`Return copper pour ${contact.pcb_copper_pour_id} is missing or not on the selected ground/layer`);
|
|
175449
|
+
const region = pourRegion3(pour);
|
|
175450
|
+
if (!pointInPolygon3(contact, region.outer) || region.holes.some((h) => pointInPolygon3(contact, h)))
|
|
175451
|
+
throw new Error(`Return contact is outside copper pour ${contact.pcb_copper_pour_id}`);
|
|
175452
|
+
}
|
|
175453
|
+
}
|
|
175454
|
+
function selectReturnCurrentExperiment2(options) {
|
|
175455
|
+
const { circuitJson } = options;
|
|
175456
|
+
assertUniqueIds2(circuitJson);
|
|
175457
|
+
if (circuitJson.filter((e) => e.type === "pcb_board").length !== 1)
|
|
175458
|
+
throw new Error("Exactly one PCB board is required");
|
|
175459
|
+
const experiments = circuitJson.filter((e) => e.type === "simulation_experiment" && e.experiment_type === "pcb_return_current" && (!options.experimentId || e.simulation_experiment_id === options.experimentId));
|
|
175460
|
+
if (experiments.length !== 1 || experiments[0].type !== "simulation_experiment")
|
|
175461
|
+
throw new Error(`Select exactly one pcb_return_current experiment with --experiment-id; found ${experiments.length}`);
|
|
175462
|
+
const experiment = simulation_experiment4.parse(experiments[0]);
|
|
175463
|
+
const excitations = circuitJson.filter((e) => e.type === "simulation_return_current_excitation" && e.simulation_experiment_id === experiment.simulation_experiment_id).map((e) => simulation_return_current_excitation3.parse(e));
|
|
175464
|
+
if (!excitations.length)
|
|
175465
|
+
throw new Error(`Experiment ${experiment.simulation_experiment_id} has no excitations`);
|
|
175466
|
+
if (new Set(excitations.map((e) => e.ground_source_net_id)).size !== 1)
|
|
175467
|
+
throw new Error("Selected excitations must share one ground net");
|
|
175468
|
+
if (new Set(excitations.map((e) => e.pcb_trace_id)).size !== excitations.length)
|
|
175469
|
+
throw new Error("Selected excitations must reference distinct signal traces");
|
|
175470
|
+
const oriented = new Map;
|
|
175471
|
+
for (const excitation of excitations) {
|
|
175472
|
+
const originalTrace = circuitJson.find((e) => e.type === "pcb_trace" && e.pcb_trace_id === excitation.pcb_trace_id);
|
|
175473
|
+
if (!originalTrace || originalTrace.type !== "pcb_trace")
|
|
175474
|
+
throw new Error(`Missing signal trace ${excitation.pcb_trace_id}`);
|
|
175475
|
+
let trace = normalizeLayeredRoute3(originalTrace);
|
|
175476
|
+
if (excitation.source_port) {
|
|
175477
|
+
const sourcePort = circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === excitation.source_port.signal_pcb_port_id);
|
|
175478
|
+
const first = trace.route[0], last = trace.route.at(-1);
|
|
175479
|
+
if (sourcePort?.type === "pcb_port" && last?.route_type === "wire" && Math.hypot(sourcePort.x - last.x, sourcePort.y - last.y) < 0.000001 && !(first?.route_type === "wire" && Math.hypot(sourcePort.x - first.x, sourcePort.y - first.y) < 0.000001))
|
|
175480
|
+
trace = {
|
|
175481
|
+
...trace,
|
|
175482
|
+
route: trace.route.map((point2, index) => {
|
|
175483
|
+
if (point2.route_type === "via")
|
|
175484
|
+
return {
|
|
175485
|
+
...point2,
|
|
175486
|
+
from_layer: point2.to_layer,
|
|
175487
|
+
to_layer: point2.from_layer
|
|
175488
|
+
};
|
|
175489
|
+
if (point2.route_type !== "wire")
|
|
175490
|
+
return point2;
|
|
175491
|
+
const previous = trace.route[Math.max(0, index - 1)];
|
|
175492
|
+
return {
|
|
175493
|
+
...point2,
|
|
175494
|
+
width: previous.route_type === "wire" ? previous.width : point2.width,
|
|
175495
|
+
start_pcb_port_id: point2.end_pcb_port_id,
|
|
175496
|
+
end_pcb_port_id: point2.start_pcb_port_id
|
|
175497
|
+
};
|
|
175498
|
+
}).reverse()
|
|
175499
|
+
};
|
|
175500
|
+
}
|
|
175501
|
+
oriented.set(trace.pcb_trace_id, trace);
|
|
175502
|
+
if (!circuitJson.some((e) => e.type === "source_net" && e.source_net_id === excitation.ground_source_net_id))
|
|
175503
|
+
throw new Error(`Missing ground net ${excitation.ground_source_net_id}`);
|
|
175504
|
+
validateContact2(circuitJson, excitation.return_source, excitation.ground_source_net_id);
|
|
175505
|
+
validateContact2(circuitJson, excitation.return_sink, excitation.ground_source_net_id);
|
|
175506
|
+
for (const [terminal, contact, endpoint] of [
|
|
175507
|
+
[excitation.source_port, excitation.return_sink, trace.route[0]],
|
|
175508
|
+
[excitation.load_port, excitation.return_source, trace.route.at(-1)]
|
|
175509
|
+
]) {
|
|
175510
|
+
if (!terminal) {
|
|
175511
|
+
if (contact.contact_type === "pcb_port" || contact.layer !== "bottom")
|
|
175512
|
+
throw new Error("An identified return PCB port or non-bottom reference requires explicit source_port/load_port terminal metadata");
|
|
175513
|
+
continue;
|
|
175514
|
+
}
|
|
175515
|
+
if (contact.contact_type === "pcb_port" && terminal.reference_pcb_port_id !== contact.pcb_port_id)
|
|
175516
|
+
throw new Error("A PCB-port return contact requires its matching reference_pcb_port_id");
|
|
175517
|
+
if (terminal.reference_layer !== contact.layer || terminal.reference_pcb_port_id !== undefined && (contact.contact_type !== "pcb_port" || terminal.reference_pcb_port_id !== contact.pcb_port_id))
|
|
175518
|
+
throw new Error("Terminal reference must match the identified return contact");
|
|
175519
|
+
const port = circuitJson.find((e) => e.type === "pcb_port" && e.pcb_port_id === terminal.signal_pcb_port_id);
|
|
175520
|
+
if (!port || port.type !== "pcb_port" || endpoint?.route_type !== "wire" || !port.layers.includes(endpoint.layer) || Math.hypot(port.x - endpoint.x, port.y - endpoint.y) > 0.000001)
|
|
175521
|
+
throw new Error("Signal terminal must match the source/load trace endpoint");
|
|
175522
|
+
if (terminal.signal_pcb_port_id === terminal.reference_pcb_port_id)
|
|
175523
|
+
throw new Error("Signal and reference terminals must be different ports");
|
|
175524
|
+
}
|
|
175525
|
+
if ([excitation.return_source, excitation.return_sink].some((c) => c.contact_type === "pcb_via"))
|
|
175526
|
+
throw new Error("Via return contacts are not supported by the current solver adapter; use a PCB port or copper-pour contact");
|
|
175527
|
+
}
|
|
175528
|
+
return {
|
|
175529
|
+
experiment,
|
|
175530
|
+
excitations,
|
|
175531
|
+
solverCircuitJson: circuitJson.map((e) => e.type === "pcb_trace" ? oriented.get(e.pcb_trace_id) ?? e : e)
|
|
175532
|
+
};
|
|
175533
|
+
}
|
|
175534
|
+
function fabricationStackup(model) {
|
|
175535
|
+
if (!model.multilayer)
|
|
175536
|
+
return;
|
|
175537
|
+
const stackup = model.multilayer.stackup;
|
|
175538
|
+
return {
|
|
175539
|
+
nominalBoardThicknessMm: stackup.nominalBoardThicknessMm,
|
|
175540
|
+
layers: [
|
|
175541
|
+
...stackup.copperLayers.map((foil) => ({
|
|
175542
|
+
z: foil.zMax,
|
|
175543
|
+
value: { name: foil.name, copperThicknessMm: foil.zMax - foil.zMin }
|
|
175544
|
+
})),
|
|
175545
|
+
...stackup.dielectrics.map((d) => ({
|
|
175546
|
+
z: d.zMax,
|
|
175547
|
+
value: {
|
|
175548
|
+
material: d.material,
|
|
175549
|
+
dielectricThicknessMm: d.zMax - d.zMin,
|
|
175550
|
+
dielectricConstant: d.dielectricConstant,
|
|
175551
|
+
lossTangent: d.lossTangent
|
|
175552
|
+
}
|
|
175553
|
+
}))
|
|
175554
|
+
].sort((a, b) => b.z - a.z).map((l) => l.value)
|
|
175555
|
+
};
|
|
175556
|
+
}
|
|
175557
|
+
function markersFor(circuitJson, excitations, resultId) {
|
|
175558
|
+
const markers = [];
|
|
175559
|
+
const portMarker = (role, portId, layer) => {
|
|
175560
|
+
markers.push(simulation_pcb_return_current_marker.parse({
|
|
175561
|
+
type: "simulation_pcb_return_current_marker",
|
|
175562
|
+
simulation_pcb_return_current_marker_id: id2("simulation_pcb_return_current_marker"),
|
|
175563
|
+
simulation_pcb_return_current_result_id: resultId,
|
|
175564
|
+
role,
|
|
175565
|
+
target_type: "pcb_port",
|
|
175566
|
+
pcb_port_id: portId,
|
|
175567
|
+
layer,
|
|
175568
|
+
label: role.replaceAll("_", " ")
|
|
175569
|
+
}));
|
|
175570
|
+
};
|
|
175571
|
+
for (const excitation of excitations) {
|
|
175572
|
+
const trace = circuitJson.find((e) => e.type === "pcb_trace" && e.pcb_trace_id === excitation.pcb_trace_id);
|
|
175573
|
+
for (const [role, terminal, endpoint] of [
|
|
175574
|
+
["signal_source", excitation.source_port, trace.route[0]],
|
|
175575
|
+
["signal_load", excitation.load_port, trace.route.at(-1)]
|
|
175576
|
+
])
|
|
175577
|
+
if (terminal && endpoint?.route_type === "wire")
|
|
175578
|
+
portMarker(role, terminal.signal_pcb_port_id, endpoint.layer);
|
|
175579
|
+
for (const [role, contact] of [
|
|
175580
|
+
["return_source", excitation.return_source],
|
|
175581
|
+
["return_sink", excitation.return_sink]
|
|
175582
|
+
])
|
|
175583
|
+
if (contact.contact_type === "pcb_port")
|
|
175584
|
+
portMarker(role, contact.pcb_port_id, contact.layer);
|
|
175585
|
+
for (const point2 of trace.route) {
|
|
175586
|
+
if (point2.route_type !== "via")
|
|
175587
|
+
continue;
|
|
175588
|
+
const via = circuitJson.find((e) => e.type === "pcb_via" && Math.hypot(e.x - point2.x, e.y - point2.y) < 0.000001 && e.pcb_trace_id === trace.pcb_trace_id && e.layers.includes(point2.from_layer) && e.layers.includes(point2.to_layer));
|
|
175589
|
+
if (!via || via.type !== "pcb_via")
|
|
175590
|
+
continue;
|
|
175591
|
+
markers.push(simulation_pcb_return_current_marker.parse({
|
|
175592
|
+
type: "simulation_pcb_return_current_marker",
|
|
175593
|
+
simulation_pcb_return_current_marker_id: id2("simulation_pcb_return_current_marker"),
|
|
175594
|
+
simulation_pcb_return_current_result_id: resultId,
|
|
175595
|
+
role: "signal_transition",
|
|
175596
|
+
target_type: "pcb_via",
|
|
175597
|
+
pcb_via_id: via.pcb_via_id,
|
|
175598
|
+
from_layer: point2.from_layer,
|
|
175599
|
+
to_layer: point2.to_layer,
|
|
175600
|
+
label: `signal via: ${point2.from_layer} ↔ ${point2.to_layer}`
|
|
175601
|
+
}));
|
|
175602
|
+
}
|
|
175603
|
+
}
|
|
175604
|
+
return markers;
|
|
175605
|
+
}
|
|
175606
|
+
function exportReturnCurrentCircuitJson(options) {
|
|
175607
|
+
const selected = selectReturnCurrentExperiment2(options);
|
|
175608
|
+
const backendExcitations = "reference" in options.simulation ? options.simulation.model.geometry.excitations : options.simulation.geometry.excitations;
|
|
175609
|
+
if (backendExcitations.length !== selected.excitations.length || backendExcitations.some((e, i) => JSON.stringify(simulation_return_current_excitation22.parse(e)) !== JSON.stringify(selected.excitations[i])))
|
|
175610
|
+
throw new Error("Result is stale: selected excitation current, contacts, or terminal metadata changed");
|
|
175611
|
+
const resultId = options.resultId ?? id2("simulation_pcb_return_current_result");
|
|
175612
|
+
const frequencyHz = "reference" in options.simulation ? options.simulation.reference.frequencyHz : undefined;
|
|
175613
|
+
const oldIds = new Set(options.circuitJson.flatMap((e) => e.type === "simulation_pcb_return_current_result" && e.simulation_experiment_id === options.experimentId && e.frequency_hz === frequencyHz ? [e.simulation_pcb_return_current_result_id] : []));
|
|
175614
|
+
if (options.circuitJson.some((e) => e.type === "simulation_pcb_return_current_result" && e.simulation_pcb_return_current_result_id === resultId && !oldIds.has(resultId)))
|
|
175615
|
+
throw new Error(`Result ID ${resultId} belongs to another experiment/frequency`);
|
|
175616
|
+
const circuitJson = options.circuitJson.filter((e) => e.type === "simulation_pcb_return_current_result" ? !oldIds.has(e.simulation_pcb_return_current_result_id) : ("simulation_pcb_return_current_result_id" in e) ? !oldIds.has(String(e.simulation_pcb_return_current_result_id)) : true);
|
|
175617
|
+
const board = circuitJson.find((e) => e.type === "pcb_board");
|
|
175618
|
+
if (board.type !== "pcb_board")
|
|
175619
|
+
throw new Error("Missing PCB board");
|
|
175620
|
+
const fieldId = id2("simulation_pcb_return_current_field");
|
|
175621
|
+
let grid;
|
|
175622
|
+
let metadata;
|
|
175623
|
+
if ("reference" in options.simulation) {
|
|
175624
|
+
const { model, reference } = options.simulation;
|
|
175625
|
+
validatePalaceReference2(reference);
|
|
175626
|
+
if (reference.columns * reference.rows > 1e6)
|
|
175627
|
+
throw new Error("The result grid exceeds 1,000,000 cells");
|
|
175628
|
+
if (reference.frequencyHz !== model.frequencyHz)
|
|
175629
|
+
throw new Error("Palace model/reference frequencies differ");
|
|
175630
|
+
if (reference.copperModel !== model.copperModel)
|
|
175631
|
+
throw new Error("Palace model/reference copper models differ");
|
|
175632
|
+
if (reference.copperThickness !== model.copperThickness)
|
|
175633
|
+
throw new Error("Palace model/reference copper thicknesses differ");
|
|
175634
|
+
if (model.geometry.excitations.map((e) => e.simulation_return_current_excitation_id).join() !== selected.excitations.map((e) => e.simulation_return_current_excitation_id).join())
|
|
175635
|
+
throw new Error("Palace model does not contain exactly the selected experiment excitations");
|
|
175636
|
+
if (reference.provenance.geometrySignature !== palaceGeometrySignature2(model.geometry))
|
|
175637
|
+
throw new Error("Palace reference provenance does not match its model geometry");
|
|
175638
|
+
const expected = createPalaceModel2({
|
|
175639
|
+
circuitJson: selected.solverCircuitJson,
|
|
175640
|
+
excitations: selected.excitations,
|
|
175641
|
+
frequencyHz: model.frequencyHz,
|
|
175642
|
+
stackup: fabricationStackup(model),
|
|
175643
|
+
sampleLayer: reference.sampleLayer ?? "bottom",
|
|
175644
|
+
viaClearance: model.multilayer?.viaClearance,
|
|
175645
|
+
...model.multilayer ? {} : {
|
|
175646
|
+
layerSeparation: model.layerSeparation,
|
|
175647
|
+
copperThickness: model.copperThickness
|
|
175648
|
+
},
|
|
175649
|
+
copperConductivity: model.copperConductivity,
|
|
175650
|
+
substratePermittivity: model.substratePermittivity,
|
|
175651
|
+
substrateLossTangent: model.substrateLossTangent,
|
|
175652
|
+
portResistance: model.portResistance,
|
|
175653
|
+
portWidth: model.portWidth,
|
|
175654
|
+
meshSize: model.meshSize,
|
|
175655
|
+
copperModel: model.copperModel,
|
|
175656
|
+
airPadding: model.airPadding,
|
|
175657
|
+
order: model.order
|
|
175658
|
+
});
|
|
175659
|
+
if (palaceGeometrySignature2(expected.geometry) !== palaceGeometrySignature2(model.geometry))
|
|
175660
|
+
throw new Error("Palace result is stale: input geometry, current, contacts, or termination changed");
|
|
175661
|
+
const minX = Math.min(...model.geometry.boardOutline.map((p) => p.x));
|
|
175662
|
+
const minY = Math.min(...model.geometry.boardOutline.map((p) => p.y));
|
|
175663
|
+
metadata = {
|
|
175664
|
+
layer: reference.sampleLayer ?? "bottom",
|
|
175665
|
+
source_net_id: selected.excitations[0].ground_source_net_id,
|
|
175666
|
+
field_type: "complex_phasor",
|
|
175667
|
+
min_x: minX,
|
|
175668
|
+
min_y: minY,
|
|
175669
|
+
columns: reference.columns,
|
|
175670
|
+
rows: reference.rows,
|
|
175671
|
+
cell_width: reference.cellWidth,
|
|
175672
|
+
cell_height: reference.cellHeight,
|
|
175673
|
+
copper_thickness: reference.copperThickness
|
|
175674
|
+
};
|
|
175675
|
+
const blank = () => Array(reference.columns * reference.rows).fill(null);
|
|
175676
|
+
grid = {
|
|
175677
|
+
field_type: "complex_phasor",
|
|
175678
|
+
sheet_current_x_real: blank(),
|
|
175679
|
+
sheet_current_x_imag: blank(),
|
|
175680
|
+
sheet_current_y_real: blank(),
|
|
175681
|
+
sheet_current_y_imag: blank()
|
|
175682
|
+
};
|
|
175683
|
+
const seen = new Set;
|
|
175684
|
+
const containsCopper = createSampleMask2(model.geometry);
|
|
175685
|
+
for (const sample of reference.samples) {
|
|
175686
|
+
const column = Math.round((sample.x - minX) / reference.cellWidth - 0.5);
|
|
175687
|
+
const row = Math.round((sample.y - minY) / reference.cellHeight - 0.5);
|
|
175688
|
+
if (column < 0 || column >= reference.columns || row < 0 || row >= reference.rows || Math.abs(sample.x - (minX + (column + 0.5) * reference.cellWidth)) > 0.000001 || Math.abs(sample.y - (minY + (row + 0.5) * reference.cellHeight)) > 0.000001)
|
|
175689
|
+
throw new Error("Palace sample is not aligned with its declared grid");
|
|
175690
|
+
const index = row * reference.columns + column;
|
|
175691
|
+
if (!containsCopper(sample))
|
|
175692
|
+
throw new Error("Palace sample lies outside the reference conductor mask");
|
|
175693
|
+
if (seen.has(index))
|
|
175694
|
+
throw new Error("Duplicate Palace grid cell");
|
|
175695
|
+
seen.add(index);
|
|
175696
|
+
grid.sheet_current_x_real[index] = sample.sheetCurrentXReal;
|
|
175697
|
+
grid.sheet_current_x_imag[index] = sample.sheetCurrentXImag;
|
|
175698
|
+
grid.sheet_current_y_real[index] = sample.sheetCurrentYReal;
|
|
175699
|
+
grid.sheet_current_y_imag[index] = sample.sheetCurrentYImag;
|
|
175700
|
+
}
|
|
175701
|
+
for (let row = 0;row < reference.rows; row++)
|
|
175702
|
+
for (let column = 0;column < reference.columns; column++)
|
|
175703
|
+
if (containsCopper({
|
|
175704
|
+
x: minX + (column + 0.5) * reference.cellWidth,
|
|
175705
|
+
y: minY + (row + 0.5) * reference.cellHeight
|
|
175706
|
+
}) !== seen.has(row * reference.columns + column))
|
|
175707
|
+
throw new Error("Palace samples do not cover exactly the declared conductor mask");
|
|
175708
|
+
} else {
|
|
175709
|
+
const result2 = options.simulation;
|
|
175710
|
+
if (!result2.diagnostics.converged)
|
|
175711
|
+
throw new Error("Cannot export an unconverged approximation");
|
|
175712
|
+
if (result2.columns * result2.rows > 1e6)
|
|
175713
|
+
throw new Error("The result grid exceeds 1,000,000 cells");
|
|
175714
|
+
if (result2.geometry.excitations.map((e) => e.simulation_return_current_excitation_id).join() !== selected.excitations.map((e) => e.simulation_return_current_excitation_id).join())
|
|
175715
|
+
throw new Error("Approximation does not contain exactly the selected experiment excitations");
|
|
175716
|
+
const expected = readGeometry2({
|
|
175717
|
+
circuitJson: selected.solverCircuitJson,
|
|
175718
|
+
excitations: selected.excitations
|
|
175719
|
+
});
|
|
175720
|
+
if (palaceGeometrySignature2(expected) !== palaceGeometrySignature2(result2.geometry))
|
|
175721
|
+
throw new Error("Approximation result is stale: input geometry, current, contacts, or termination changed");
|
|
175722
|
+
metadata = {
|
|
175723
|
+
layer: "bottom",
|
|
175724
|
+
source_net_id: selected.excitations[0].ground_source_net_id,
|
|
175725
|
+
field_type: "real",
|
|
175726
|
+
min_x: result2.bounds.minX,
|
|
175727
|
+
min_y: result2.bounds.minY,
|
|
175728
|
+
columns: result2.columns,
|
|
175729
|
+
rows: result2.rows,
|
|
175730
|
+
cell_width: result2.cellWidth,
|
|
175731
|
+
cell_height: result2.cellHeight,
|
|
175732
|
+
copper_thickness: result2.copperThickness
|
|
175733
|
+
};
|
|
175734
|
+
grid = {
|
|
175735
|
+
field_type: "real",
|
|
175736
|
+
sheet_current_x: Array(result2.columns * result2.rows).fill(null),
|
|
175737
|
+
sheet_current_y: Array(result2.columns * result2.rows).fill(null)
|
|
175738
|
+
};
|
|
175739
|
+
const seen = new Set;
|
|
175740
|
+
for (const node of result2.nodes) {
|
|
175741
|
+
const index = node.row * result2.columns + node.column;
|
|
175742
|
+
if (!Number.isInteger(node.column) || !Number.isInteger(node.row) || node.column < 0 || node.column >= result2.columns || node.row < 0 || node.row >= result2.rows || seen.has(index))
|
|
175743
|
+
throw new Error("Invalid approximation grid node");
|
|
175744
|
+
seen.add(index);
|
|
175745
|
+
grid.sheet_current_x[index] = node.sheetCurrentX;
|
|
175746
|
+
grid.sheet_current_y[index] = node.sheetCurrentY;
|
|
175747
|
+
}
|
|
175748
|
+
}
|
|
175749
|
+
getSimulationReturnCurrentGridJsonSchema(metadata).parse(grid);
|
|
175750
|
+
const format = options.fieldFormat ?? "gzip";
|
|
175751
|
+
if (format !== "gzip" && format !== "json")
|
|
175752
|
+
throw new Error("fieldFormat must be gzip or json");
|
|
175753
|
+
const mime = format === "gzip" ? "application/gzip" : "application/json";
|
|
175754
|
+
const bytes = Buffer.from(JSON.stringify(grid));
|
|
175755
|
+
const assetBytes = format === "gzip" ? gzipSync2(bytes) : bytes;
|
|
175756
|
+
const field = simulation_pcb_return_current_field.parse({
|
|
175757
|
+
...metadata,
|
|
175758
|
+
type: "simulation_pcb_return_current_field",
|
|
175759
|
+
simulation_pcb_return_current_field_id: fieldId,
|
|
175760
|
+
simulation_pcb_return_current_result_id: resultId,
|
|
175761
|
+
data_format: "simulation_return_current_grid_json_v1",
|
|
175762
|
+
field_asset: {
|
|
175763
|
+
project_relative_path: `simulation/${fieldId}.json${format === "gzip" ? ".gz" : ""}`,
|
|
175764
|
+
mimetype: mime,
|
|
175765
|
+
url: `data:${mime};base64,${assetBytes.toString("base64")}`
|
|
175766
|
+
}
|
|
175767
|
+
});
|
|
175768
|
+
const maxX = metadata.min_x + metadata.columns * metadata.cell_width;
|
|
175769
|
+
const maxY = metadata.min_y + metadata.rows * metadata.cell_height;
|
|
175770
|
+
const magnitudes = Array.from({ length: metadata.columns * metadata.rows }, (_, index) => {
|
|
175771
|
+
if (grid.field_type === "real")
|
|
175772
|
+
return grid.sheet_current_x[index] === null ? null : Math.hypot(grid.sheet_current_x[index], grid.sheet_current_y[index]) / metadata.copper_thickness;
|
|
175773
|
+
return grid.sheet_current_x_real[index] === null ? null : Math.hypot(grid.sheet_current_x_real[index], grid.sheet_current_x_imag[index], grid.sheet_current_y_real[index], grid.sheet_current_y_imag[index]) / metadata.copper_thickness;
|
|
175774
|
+
});
|
|
175775
|
+
const maximum = magnitudes.reduce((max, value) => Math.max(max, value ?? 0), 0);
|
|
175776
|
+
const rectangles = magnitudes.flatMap((magnitude, index) => magnitude === null ? [] : [
|
|
175777
|
+
`<rect x="${index % metadata.columns}" y="${metadata.rows - 1 - Math.floor(index / metadata.columns)}" width="1" height="1" fill="${currentColor2(maximum > 0 ? magnitude / maximum : 0)}"/>`
|
|
175778
|
+
]).join("");
|
|
175779
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${metadata.columns}" height="${metadata.rows}" viewBox="0 0 ${metadata.columns} ${metadata.rows}">${rectangles}</svg>`;
|
|
175780
|
+
const png = new Resvg4(svg).render().asPng();
|
|
175781
|
+
const heatmapId = id2("simulation_pcb_return_current_heatmap");
|
|
175782
|
+
const heatmap = simulation_pcb_return_current_heatmap.parse({
|
|
175783
|
+
type: "simulation_pcb_return_current_heatmap",
|
|
175784
|
+
simulation_pcb_return_current_heatmap_id: heatmapId,
|
|
175785
|
+
simulation_pcb_return_current_result_id: resultId,
|
|
175786
|
+
layer: metadata.layer,
|
|
175787
|
+
source_net_id: metadata.source_net_id,
|
|
175788
|
+
min_x: metadata.min_x,
|
|
175789
|
+
min_y: metadata.min_y,
|
|
175790
|
+
max_x: maxX,
|
|
175791
|
+
max_y: maxY,
|
|
175792
|
+
image_asset: {
|
|
175793
|
+
project_relative_path: `simulation/${heatmapId}.png`,
|
|
175794
|
+
mimetype: "image/png",
|
|
175795
|
+
url: `data:image/png;base64,${Buffer.from(png).toString("base64")}`
|
|
175796
|
+
}
|
|
175797
|
+
});
|
|
175798
|
+
const result = simulation_pcb_return_current_result.parse({
|
|
175799
|
+
type: "simulation_pcb_return_current_result",
|
|
175800
|
+
simulation_pcb_return_current_result_id: resultId,
|
|
175801
|
+
simulation_experiment_id: options.experimentId,
|
|
175802
|
+
pcb_board_id: board.pcb_board_id,
|
|
175803
|
+
simulation_return_current_excitation_ids: selected.excitations.map((e) => e.simulation_return_current_excitation_id),
|
|
175804
|
+
...frequencyHz === undefined ? {} : { frequency_hz: frequencyHz }
|
|
175805
|
+
});
|
|
175806
|
+
return [
|
|
175807
|
+
...circuitJson,
|
|
175808
|
+
result,
|
|
175809
|
+
field,
|
|
175810
|
+
heatmap,
|
|
175811
|
+
...markersFor(selected.solverCircuitJson, selected.excitations, resultId)
|
|
175812
|
+
];
|
|
175813
|
+
}
|
|
175814
|
+
var copperLayer3, fabricationStackupSchema3, id2 = (prefix) => `${prefix}_${randomUUID2()}`;
|
|
175815
|
+
var init_circuit_json = __esm(() => {
|
|
175816
|
+
copperLayer3 = z229.string().regex(/^(top|bottom|inner[1-8])$/);
|
|
175817
|
+
fabricationStackupSchema3 = z229.object({
|
|
175818
|
+
nominalBoardThicknessMm: z229.number().finite().positive().optional(),
|
|
175819
|
+
layers: z229.array(z229.union([
|
|
175820
|
+
z229.object({
|
|
175821
|
+
name: copperLayer3,
|
|
175822
|
+
copperThicknessMm: z229.number().finite().positive()
|
|
175823
|
+
}),
|
|
175824
|
+
z229.object({
|
|
175825
|
+
material: z229.string().min(1),
|
|
175826
|
+
dielectricThicknessMm: z229.number().finite().positive(),
|
|
175827
|
+
dielectricConstant: z229.number().finite().positive(),
|
|
175828
|
+
lossTangent: z229.number().finite().nonnegative().optional()
|
|
175829
|
+
})
|
|
175830
|
+
]))
|
|
175831
|
+
});
|
|
175832
|
+
});
|
|
175833
|
+
|
|
172195
175834
|
// node_modules/commander/index.js
|
|
172196
175835
|
var exports_commander = {};
|
|
172197
175836
|
__exportCjs(exports_commander, {
|
|
@@ -172386,7 +176025,7 @@ var package_default = {
|
|
|
172386
176025
|
"@tscircuit/krt-wasm": "^0.1.6",
|
|
172387
176026
|
"@tscircuit/math-utils": "0.0.36",
|
|
172388
176027
|
"@tscircuit/modelprinter": "^0.0.11",
|
|
172389
|
-
"@tscircuit/props": "^0.0.
|
|
176028
|
+
"@tscircuit/props": "^0.0.698",
|
|
172390
176029
|
"@tscircuit/runframe": "^0.0.2953",
|
|
172391
176030
|
"@tscircuit/schematic-match-adapt": "^0.0.22",
|
|
172392
176031
|
"@tscircuit/schematic-trace-solver": "^0.0.204",
|
|
@@ -172402,7 +176041,7 @@ var package_default = {
|
|
|
172402
176041
|
"@types/semver": "^7.5.8",
|
|
172403
176042
|
"bun-match-svg": "^0.0.12",
|
|
172404
176043
|
chokidar: "4.0.1",
|
|
172405
|
-
"circuit-json": "^0.0.
|
|
176044
|
+
"circuit-json": "^0.0.525",
|
|
172406
176045
|
"circuit-json-to-3d-png": "^0.0.6",
|
|
172407
176046
|
"circuit-json-to-altium": "github:tscircuit/circuit-json-to-altium#1a50d0dd2c0737cf940d92c0315852dc0111dd87",
|
|
172408
176047
|
"circuit-json-to-bom-csv": "^0.0.19",
|
|
@@ -172418,7 +176057,7 @@ var package_default = {
|
|
|
172418
176057
|
"circuit-json-to-tscircuit": "^0.0.50",
|
|
172419
176058
|
"circuit-json-trace-length-analysis": "github:tscircuit/circuit-json-trace-length-analysis#2b44792a40df0ca83b6bfb6ac95ed5e35e7168b8",
|
|
172420
176059
|
"circuit-to-canvas": "^0.0.123",
|
|
172421
|
-
"circuit-to-svg": "^0.0.
|
|
176060
|
+
"circuit-to-svg": "^0.0.447",
|
|
172422
176061
|
commander: "^14.0.0",
|
|
172423
176062
|
conf: "^13.1.0",
|
|
172424
176063
|
configstore: "^7.0.0",
|
|
@@ -172450,6 +176089,7 @@ var package_default = {
|
|
|
172450
176089
|
redaxios: "^0.5.1",
|
|
172451
176090
|
"schematic-symbols": "^0.0.246",
|
|
172452
176091
|
semver: "^7.6.3",
|
|
176092
|
+
"simulate-return-current": "https://pkg.pr.new/tscircuit/simulate-return-current@6a7c84cb4f9f66c2cb54199aee50d3c7453748e8",
|
|
172453
176093
|
spicets: "^0.0.4",
|
|
172454
176094
|
stepts: "^0.0.3",
|
|
172455
176095
|
tempy: "^3.1.0",
|
|
@@ -172459,7 +176099,9 @@ var package_default = {
|
|
|
172459
176099
|
zod: "^3.23.8"
|
|
172460
176100
|
},
|
|
172461
176101
|
peerDependencies: {
|
|
172462
|
-
"
|
|
176102
|
+
"@tscircuit/math-utils": "0.0.36",
|
|
176103
|
+
"circuit-json": "^0.0.525",
|
|
176104
|
+
"circuit-to-svg": "^0.0.447",
|
|
172463
176105
|
tscircuit: "*"
|
|
172464
176106
|
},
|
|
172465
176107
|
bin: {
|
|
@@ -172476,10 +176118,11 @@ var package_default = {
|
|
|
172476
176118
|
}
|
|
172477
176119
|
},
|
|
172478
176120
|
overrides: {
|
|
172479
|
-
"circuit-json": "^0.0.
|
|
176121
|
+
"circuit-json": "^0.0.525",
|
|
172480
176122
|
altiumts: "0.0.59",
|
|
172481
176123
|
poppygl: "0.0.34",
|
|
172482
|
-
"@tscircuit/core": "^0.0.
|
|
176124
|
+
"@tscircuit/core": "^0.0.2121",
|
|
176125
|
+
"jscad-planner": "0.0.18"
|
|
172483
176126
|
},
|
|
172484
176127
|
scripts: {
|
|
172485
176128
|
start: "bun run dev",
|
|
@@ -344390,8 +348033,230 @@ function getTraceName(circuitJson, trace) {
|
|
|
344390
348033
|
return sourceTrace?.type === "source_trace" && sourceTrace.display_name || from || to || "trace";
|
|
344391
348034
|
}
|
|
344392
348035
|
|
|
348036
|
+
// node_modules/calculate-elbow/lib/calculateElbowBends.ts
|
|
348037
|
+
var calculateElbowBends2 = (p1, p2, overshootAmount) => {
|
|
348038
|
+
const result = [{ x: p1.x, y: p1.y }];
|
|
348039
|
+
const midX = (p1.x + p2.x) / 2;
|
|
348040
|
+
const midY = (p1.y + p2.y) / 2;
|
|
348041
|
+
const p2Target = { x: p2.x, y: p2.y };
|
|
348042
|
+
switch (p2.facingDirection) {
|
|
348043
|
+
case "x+":
|
|
348044
|
+
p2Target.x += overshootAmount;
|
|
348045
|
+
break;
|
|
348046
|
+
case "x-":
|
|
348047
|
+
p2Target.x -= overshootAmount;
|
|
348048
|
+
break;
|
|
348049
|
+
case "y+":
|
|
348050
|
+
p2Target.y += overshootAmount;
|
|
348051
|
+
break;
|
|
348052
|
+
case "y-":
|
|
348053
|
+
p2Target.y -= overshootAmount;
|
|
348054
|
+
break;
|
|
348055
|
+
}
|
|
348056
|
+
const startDir = p1.facingDirection ?? "none";
|
|
348057
|
+
const endDir = p2.facingDirection ?? "none";
|
|
348058
|
+
const push = (pt) => {
|
|
348059
|
+
const last = result[result.length - 1];
|
|
348060
|
+
if (last.x !== pt.x || last.y !== pt.y)
|
|
348061
|
+
result.push(pt);
|
|
348062
|
+
};
|
|
348063
|
+
const yAligned = Math.abs(p1.y - p2.y) <= Math.max(0.000001, overshootAmount * 0.1);
|
|
348064
|
+
const xAligned = Math.abs(p1.x - p2.x) <= Math.max(0.000001, overshootAmount * 0.1);
|
|
348065
|
+
if (startDir === "none" && endDir === "none") {
|
|
348066
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 1;
|
|
348067
|
+
push({ x: midX, y: p1.y });
|
|
348068
|
+
push({ x: midX, y: p2.y });
|
|
348069
|
+
} else if (startDir === "x+" && endDir === "y+") {
|
|
348070
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2;
|
|
348071
|
+
if (p1.x > p2.x && p1.y < p2.y) {
|
|
348072
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2.1;
|
|
348073
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348074
|
+
push({ x: p1.x + overshootAmount, y: p2.y + overshootAmount });
|
|
348075
|
+
push({ x: p2.x, y: p2.y + overshootAmount });
|
|
348076
|
+
} else if (!xAligned && !yAligned && p1.x < p2.x && p1.y > p2.y) {
|
|
348077
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2.2;
|
|
348078
|
+
push({ x: p2.x, y: p1.y });
|
|
348079
|
+
} else if (xAligned) {
|
|
348080
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2.3;
|
|
348081
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348082
|
+
push({ x: p1.x + overshootAmount, y: p2.y + overshootAmount });
|
|
348083
|
+
push({ x: p2.x, y: p2.y + overshootAmount });
|
|
348084
|
+
} else {
|
|
348085
|
+
if (p1.x < p2.x) {
|
|
348086
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2.4;
|
|
348087
|
+
push({ x: midX, y: p1.y });
|
|
348088
|
+
push({ x: midX, y: p2Target.y });
|
|
348089
|
+
push({ x: p2.x, y: p2Target.y });
|
|
348090
|
+
} else if (p1.y <= p2.y + overshootAmount) {
|
|
348091
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2.5;
|
|
348092
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348093
|
+
push({ x: p1.x + overshootAmount, y: p1.y + overshootAmount });
|
|
348094
|
+
push({ x: p2.x, y: p1.y + overshootAmount });
|
|
348095
|
+
push({ x: p2.x, y: p2.y });
|
|
348096
|
+
} else {
|
|
348097
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 2.6;
|
|
348098
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348099
|
+
push({ x: p1.x + overshootAmount, y: (p1.y + p2.y) / 2 });
|
|
348100
|
+
push({ x: p2.x, y: (p1.y + p2.y) / 2 });
|
|
348101
|
+
}
|
|
348102
|
+
}
|
|
348103
|
+
} else if (startDir === "x+" && endDir === "x+" && !yAligned) {
|
|
348104
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 3;
|
|
348105
|
+
const commonX = Math.max(p1.x + overshootAmount, p2Target.x);
|
|
348106
|
+
push({ x: commonX, y: p1.y });
|
|
348107
|
+
push({ x: commonX, y: p2.y });
|
|
348108
|
+
} else if (startDir === "x+" && endDir === "x+" && yAligned) {
|
|
348109
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 3.1;
|
|
348110
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348111
|
+
push({ x: p1.x + overshootAmount, y: p1.y + overshootAmount });
|
|
348112
|
+
push({ x: p2.x + overshootAmount, y: p1.y + overshootAmount });
|
|
348113
|
+
push({ x: p2.x + overshootAmount, y: p2.y });
|
|
348114
|
+
} else if (startDir === "x+" && endDir === "y-") {
|
|
348115
|
+
if (xAligned && p1.y <= p2.y) {
|
|
348116
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.11;
|
|
348117
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348118
|
+
push({ x: p1.x + overshootAmount, y: midY });
|
|
348119
|
+
push({ x: p2.x, y: midY });
|
|
348120
|
+
} else if (xAligned && p1.y > p2.y) {
|
|
348121
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.12;
|
|
348122
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348123
|
+
push({ x: p1.x + overshootAmount, y: p2.y - overshootAmount });
|
|
348124
|
+
push({ x: p2.x, y: p2.y - overshootAmount });
|
|
348125
|
+
} else if (p1.x < p2.x && p1.y < p2.y) {
|
|
348126
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.2;
|
|
348127
|
+
push({ x: p2.x, y: p1.y });
|
|
348128
|
+
} else {
|
|
348129
|
+
if (p1.x > p2.x && p1.y < p2.y) {
|
|
348130
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.3;
|
|
348131
|
+
const p1OvershootX = p1.x + overshootAmount;
|
|
348132
|
+
push({ x: p1OvershootX, y: p1.y });
|
|
348133
|
+
push({ x: p1OvershootX, y: midY });
|
|
348134
|
+
push({ x: p2.x, y: midY });
|
|
348135
|
+
} else if (p1.x > p2.x && p1.y > p2.y) {
|
|
348136
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.4;
|
|
348137
|
+
const p1OvershootX = p1.x + overshootAmount;
|
|
348138
|
+
push({ x: p1OvershootX, y: p1.y });
|
|
348139
|
+
push({ x: p1OvershootX, y: p2Target.y });
|
|
348140
|
+
push({ x: p2.x, y: p2Target.y });
|
|
348141
|
+
} else if (p1.y === p2.y) {
|
|
348142
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.5;
|
|
348143
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348144
|
+
push({ x: p1.x + overshootAmount, y: p1.y - overshootAmount });
|
|
348145
|
+
push({ x: p2.x, y: p1.y - overshootAmount });
|
|
348146
|
+
} else {
|
|
348147
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 4.6;
|
|
348148
|
+
push({ x: midX, y: p1.y });
|
|
348149
|
+
push({ x: midX, y: p2Target.y });
|
|
348150
|
+
push({ x: p2.x, y: p2Target.y });
|
|
348151
|
+
}
|
|
348152
|
+
}
|
|
348153
|
+
} else if (startDir === "x+" && endDir === "x-" && p1.x + overshootAmount >= p2.x - overshootAmount && !yAligned) {
|
|
348154
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 5;
|
|
348155
|
+
const p1OvershootX = p1.x + overshootAmount;
|
|
348156
|
+
push({ x: p1OvershootX, y: p1.y });
|
|
348157
|
+
push({ x: p1OvershootX, y: midY });
|
|
348158
|
+
push({ x: p2Target.x, y: midY });
|
|
348159
|
+
push({ x: p2Target.x, y: p2Target.y });
|
|
348160
|
+
} else if (startDir === "x+" && endDir === "x-" && yAligned && p2.x > p1.x) {} else if (startDir === "x+" && endDir === "x-" && yAligned) {
|
|
348161
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 7;
|
|
348162
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348163
|
+
push({ x: p1.x + overshootAmount, y: p1.y + overshootAmount });
|
|
348164
|
+
push({ x: p2.x - overshootAmount, y: p1.y + overshootAmount });
|
|
348165
|
+
push({ x: p2.x - overshootAmount, y: p1.y });
|
|
348166
|
+
} else {
|
|
348167
|
+
globalThis.__DEBUG_CALCULATE_ELBOW_CASE = 8;
|
|
348168
|
+
if (startDir === "x+") {
|
|
348169
|
+
push({ x: p1.x + overshootAmount, y: p1.y });
|
|
348170
|
+
}
|
|
348171
|
+
push({ x: midX, y: result[result.length - 1].y });
|
|
348172
|
+
push({ x: midX, y: p2Target.y });
|
|
348173
|
+
push({ x: p2Target.x, y: p2Target.y });
|
|
348174
|
+
}
|
|
348175
|
+
push({ x: p2.x, y: p2.y });
|
|
348176
|
+
return result;
|
|
348177
|
+
};
|
|
348178
|
+
|
|
348179
|
+
// node_modules/calculate-elbow/lib/index.ts
|
|
348180
|
+
var calculateElbow2 = (point1, point2, options = {}) => {
|
|
348181
|
+
let p1 = point1;
|
|
348182
|
+
let p2 = point2;
|
|
348183
|
+
let orderFlipped = false;
|
|
348184
|
+
if (p1.x > p2.x || p1.x === p2.x && p1.y > p2.y) {
|
|
348185
|
+
orderFlipped = true;
|
|
348186
|
+
[p1, p2] = [p2, p1];
|
|
348187
|
+
}
|
|
348188
|
+
const mirrorX = p1.facingDirection === "x-";
|
|
348189
|
+
const mirrorY = p1.facingDirection === "y-";
|
|
348190
|
+
const mirrorPoint = (pt) => {
|
|
348191
|
+
const x = mirrorX ? p1.x - (pt.x - p1.x) : pt.x;
|
|
348192
|
+
const y = mirrorY ? p1.y - (pt.y - p1.y) : pt.y;
|
|
348193
|
+
let facing = pt.facingDirection;
|
|
348194
|
+
if (mirrorX) {
|
|
348195
|
+
if (facing === "x+")
|
|
348196
|
+
facing = "x-";
|
|
348197
|
+
else if (facing === "x-")
|
|
348198
|
+
facing = "x+";
|
|
348199
|
+
}
|
|
348200
|
+
if (mirrorY) {
|
|
348201
|
+
if (facing === "y+")
|
|
348202
|
+
facing = "y-";
|
|
348203
|
+
else if (facing === "y-")
|
|
348204
|
+
facing = "y+";
|
|
348205
|
+
}
|
|
348206
|
+
return { x, y, facingDirection: facing };
|
|
348207
|
+
};
|
|
348208
|
+
const rotateCW = (pt, centre) => {
|
|
348209
|
+
const dx = pt.x - centre.x;
|
|
348210
|
+
const dy = pt.y - centre.y;
|
|
348211
|
+
const x = centre.x + dy;
|
|
348212
|
+
const y = centre.y - dx;
|
|
348213
|
+
let facing = pt.facingDirection;
|
|
348214
|
+
switch (facing) {
|
|
348215
|
+
case "x+":
|
|
348216
|
+
facing = "y-";
|
|
348217
|
+
break;
|
|
348218
|
+
case "y-":
|
|
348219
|
+
facing = "x-";
|
|
348220
|
+
break;
|
|
348221
|
+
case "x-":
|
|
348222
|
+
facing = "y+";
|
|
348223
|
+
break;
|
|
348224
|
+
case "y+":
|
|
348225
|
+
facing = "x+";
|
|
348226
|
+
break;
|
|
348227
|
+
}
|
|
348228
|
+
return { x, y, facingDirection: facing };
|
|
348229
|
+
};
|
|
348230
|
+
const rotateCCW = (pt, centre) => {
|
|
348231
|
+
const dx = pt.x - centre.x;
|
|
348232
|
+
const dy = pt.y - centre.y;
|
|
348233
|
+
return { x: centre.x - dy, y: centre.y + dx };
|
|
348234
|
+
};
|
|
348235
|
+
const mp1 = mirrorX || mirrorY ? mirrorPoint(p1) : p1;
|
|
348236
|
+
const mp2 = mirrorX || mirrorY ? mirrorPoint(p2) : p2;
|
|
348237
|
+
let rp1 = mp1;
|
|
348238
|
+
let rp2 = mp2;
|
|
348239
|
+
let rotated = false;
|
|
348240
|
+
if (mp1.facingDirection === "y+") {
|
|
348241
|
+
rotated = true;
|
|
348242
|
+
rp1 = { ...mp1, facingDirection: "x+" };
|
|
348243
|
+
rp2 = rotateCW(mp2, mp1);
|
|
348244
|
+
}
|
|
348245
|
+
const overshootAmount = options?.overshoot ?? 0.1 * Math.max(Math.abs(rp1.x - rp2.x), Math.abs(rp1.y - rp2.y));
|
|
348246
|
+
let result = calculateElbowBends2(rp1, rp2, overshootAmount);
|
|
348247
|
+
if (rotated) {
|
|
348248
|
+
result = result.map((pt) => rotateCCW(pt, rp1));
|
|
348249
|
+
}
|
|
348250
|
+
if (mirrorX || mirrorY) {
|
|
348251
|
+
result = result.map(({ x, y }) => ({
|
|
348252
|
+
x: mirrorX ? p1.x - (x - p1.x) : x,
|
|
348253
|
+
y: mirrorY ? p1.y - (y - p1.y) : y
|
|
348254
|
+
}));
|
|
348255
|
+
}
|
|
348256
|
+
return orderFlipped ? result.reverse() : result;
|
|
348257
|
+
};
|
|
348258
|
+
|
|
344393
348259
|
// node_modules/@tscircuit/circuit-json-schematic-placement-analysis/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts
|
|
344394
|
-
import { calculateElbow as calculateElbow2 } from "calculate-elbow/lib";
|
|
344395
348260
|
import { BaseSolver as BaseSolver37 } from "@tscircuit/solver-utils";
|
|
344396
348261
|
class TraceSimplificationSolver extends BaseSolver37 {
|
|
344397
348262
|
static EPSILON = 0.01;
|
|
@@ -402459,9 +406324,87 @@ var resultToTable = (result) => {
|
|
|
402459
406324
|
return formatRows([headers, ...dataRows]);
|
|
402460
406325
|
};
|
|
402461
406326
|
|
|
406327
|
+
// cli/simulate/register-return-current.ts
|
|
406328
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5 } from "node:fs/promises";
|
|
406329
|
+
import { dirname as dirname7, join as join19, resolve as resolve13 } from "node:path";
|
|
406330
|
+
var positiveNumber = (value) => {
|
|
406331
|
+
const parsed = Number(value);
|
|
406332
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
406333
|
+
throw new InvalidArgumentError2("Must be a finite positive number");
|
|
406334
|
+
}
|
|
406335
|
+
return parsed;
|
|
406336
|
+
};
|
|
406337
|
+
var registerReturnCurrentSimulation = (simulateCommand) => {
|
|
406338
|
+
simulateCommand.command("return-current").description("Run a PCB return-current EM simulation with Palace").argument("<file>", "TSX or circuit JSON containing a return-current experiment").requiredOption("--frequency-hz <hz>", "Simulation frequency in Hz", positiveNumber).addOption(new Option2("--copper-model <model>", "Conductive copper model").choices(["volumetric_copper", "surface_impedance_copper"]).default("volumetric_copper")).option("--output <directory>", "Palace case directory", "return-current").option("--result-json <path>", "Result circuit JSON (default: output/circuit-result.json)").option("--experiment-id <id>", "Select an experiment when the input contains several").option("--sample-layer <layer>", "Reference copper layer to sample", "bottom").option("--cell-size <mm>", "Current sampling pitch in mm", positiveNumber, 0.1).option("--mesh-size <mm>", "FEM mesh target in mm", positiveNumber, 2).option("--air-padding <mm>", "Air domain padding in mm", positiveNumber, 2).option("--order <order>", "FEM order: 1 or 2", (value) => {
|
|
406339
|
+
if (value !== "1" && value !== "2") {
|
|
406340
|
+
throw new InvalidArgumentError2("FEM order must be 1 or 2");
|
|
406341
|
+
}
|
|
406342
|
+
return Number(value);
|
|
406343
|
+
}, 1).option("--processes <count>", "MPI processes", (value) => {
|
|
406344
|
+
const parsed = positiveNumber(value);
|
|
406345
|
+
if (!Number.isInteger(parsed)) {
|
|
406346
|
+
throw new InvalidArgumentError2("MPI processes must be an integer");
|
|
406347
|
+
}
|
|
406348
|
+
return parsed;
|
|
406349
|
+
}, 1).option("--python <path>", "Python with Gmsh/VTK (or PALACE_PYTHON)").action(async (file, options) => {
|
|
406350
|
+
await Promise.resolve().then(() => init_dist6());
|
|
406351
|
+
await Promise.resolve().then(() => init_palace());
|
|
406352
|
+
await Promise.resolve().then(() => init_circuit_json());
|
|
406353
|
+
let input;
|
|
406354
|
+
if (file.endsWith(".json")) {
|
|
406355
|
+
input = JSON.parse(await readFile4(resolve13(file), "utf8"));
|
|
406356
|
+
} else {
|
|
406357
|
+
const projectDir = findCircuitProjectDir(file);
|
|
406358
|
+
const config = await loadRuntimeProjectConfig(projectDir);
|
|
406359
|
+
const generated = await getOrGenerateCircuitJson({
|
|
406360
|
+
filePath: file,
|
|
406361
|
+
projectDir,
|
|
406362
|
+
saveToFile: false,
|
|
406363
|
+
platformConfig: getPlatformConfigWithCliDefaults(config?.platformConfig, {
|
|
406364
|
+
projectDir
|
|
406365
|
+
})
|
|
406366
|
+
});
|
|
406367
|
+
input = generated.circuitJson;
|
|
406368
|
+
}
|
|
406369
|
+
const circuitJson = parseReturnCurrentCircuitJson(input);
|
|
406370
|
+
const selected = selectReturnCurrentExperiment({
|
|
406371
|
+
circuitJson,
|
|
406372
|
+
experimentId: options.experimentId
|
|
406373
|
+
});
|
|
406374
|
+
const run = await runPalaceSimulation({
|
|
406375
|
+
circuitJson: selected.solverCircuitJson,
|
|
406376
|
+
excitations: selected.excitations,
|
|
406377
|
+
frequencyHz: options.frequencyHz,
|
|
406378
|
+
copperModel: options.copperModel,
|
|
406379
|
+
sampleLayer: options.sampleLayer,
|
|
406380
|
+
cellSize: options.cellSize,
|
|
406381
|
+
meshSize: options.meshSize,
|
|
406382
|
+
airPadding: options.airPadding,
|
|
406383
|
+
order: options.order,
|
|
406384
|
+
processes: options.processes,
|
|
406385
|
+
python: options.python,
|
|
406386
|
+
imageSize: 600,
|
|
406387
|
+
outputDirectory: resolve13(options.output)
|
|
406388
|
+
});
|
|
406389
|
+
const result = exportReturnCurrentCircuitJson({
|
|
406390
|
+
circuitJson,
|
|
406391
|
+
experimentId: selected.experiment.simulation_experiment_id,
|
|
406392
|
+
simulation: {
|
|
406393
|
+
model: JSON.parse(await readFile4(join19(run.outputDirectory, "model.json"), "utf8")),
|
|
406394
|
+
reference: JSON.parse(await readFile4(run.referencePath, "utf8"))
|
|
406395
|
+
}
|
|
406396
|
+
});
|
|
406397
|
+
const resultPath = resolve13(options.resultJson ?? join19(run.outputDirectory, "circuit-result.json"));
|
|
406398
|
+
await mkdir4(dirname7(resultPath), { recursive: true });
|
|
406399
|
+
await writeFile5(resultPath, JSON.stringify(result, null, 2));
|
|
406400
|
+
console.log(`Circuit JSON simulation result: ${resultPath}`);
|
|
406401
|
+
});
|
|
406402
|
+
};
|
|
406403
|
+
|
|
402462
406404
|
// cli/simulate/register.ts
|
|
402463
406405
|
var registerSimulate = (program) => {
|
|
402464
406406
|
const simulateCommand = program.command("simulate").description("Run a simulation");
|
|
406407
|
+
registerReturnCurrentSimulation(simulateCommand);
|
|
402465
406408
|
simulateCommand.command("analog").description("Run an analog SPICE simulation").argument("<file>", "Path to tscircuit tsx or circuit json file").option("--disable-parts-engine", "Disable the parts engine").action(async (file, options) => {
|
|
402466
406409
|
const projectConfig = await loadRuntimeProjectConfig(process.cwd());
|
|
402467
406410
|
const commandPlatformConfig = options.disablePartsEngine === true ? { partsEngineDisabled: true } : undefined;
|