seveibar/nema17-rp2040-stepper-controller
A tscircuit package created by seveibar.
- Version
- 0.3.1
- License
- unset
- Stars
- 0
schematic/refineSchematicStyle.ts
import { packMeasuredGroups } from "./packMeasuredGroups";
import { analyzeSchematicPlacement } from "../vendor/schematic-style-analysis";
/** Refine the generated layout from analyzer suggestions, without component coordinate overrides. */
export function installSchematicStyleRefinement(
board: any,
notes: Array<{
text: string;
ref?: string;
sectionName?: string;
fontSize: number;
color: string;
}> = [],
) {
if (!board || board.__styleRefinementInstalled) return;
board.__styleRefinementInstalled = true;
function associateCustomSymbolAnnotations() {
if (board.root.schematicDisabled) return;
const db = board.root.db;
for (const primitive of board.getDescendants()) {
if (!primitive.schematic_text_id) continue;
const text = db.schematic_text.get(primitive.schematic_text_id);
let parent = primitive.parent;
while (parent && !parent.schematic_component_id) parent = parent.parent;
if (text && parent?.schematic_component_id)
text.schematic_component_id = parent.schematic_component_id;
}
}
// Core moves component-owned text during automatic layout. Associate custom
// symbol annotations before that phase so labels follow their symbols.
const originalSymbolResize = board.doInitialSchematicSymbolResize?.bind(board);
board.doInitialSchematicSymbolResize = () => {
originalSymbolResize?.();
associateCustomSymbolAnnotations();
};
const original = board.doInitialSchematicSheetRender.bind(board);
board.doInitialSchematicSheetRender = () => {
if (board.root.schematicDisabled) return original();
const db = board.root.db;
const components = () => db.schematic_component.list();
const byName = (name: string) => {
const source = db.source_component
.list()
.find((e: any) => e.name === name);
return components().find(
(e: any) => e.source_component_id === source?.source_component_id,
);
};
const fromBox = (box: any) =>
box && db.schematic_component.get(box.schematicComponentId);
function move(component: any, x: number, y: number, rotation = 0) {
if (!component) return;
const cx = component.center.x,
cy = component.center.y;
const angle = (rotation * Math.PI) / 180,
cos = Math.cos(angle),
sin = Math.sin(angle);
const transform = (p: any) => ({
x: x + (p.x - cx) * cos - (p.y - cy) * sin,
y: y + (p.x - cx) * sin + (p.y - cy) * cos,
});
for (const port of db.schematic_port
.list()
.filter(
(p: any) =>
p.schematic_component_id === component.schematic_component_id,
)) {
port.center = transform(port.center);
const dirs = ["right", "up", "left", "down"];
for (const field of ["facing_direction", "side_of_component"]) {
const index = dirs.indexOf(port[field]);
if (index >= 0)
port[field] = dirs[(index + Math.round(rotation / 90) + 4) % 4];
}
}
for (const text of db.schematic_text
.list()
.filter(
(t: any) =>
t.schematic_component_id === component.schematic_component_id,
))
text.position = transform(text.position);
if (rotation && component.symbol_name) {
const dirs = ["right", "up", "left", "down"];
if (Math.abs(rotation) % 180 === 90 && /^fuse_(vert|horz)$/.test(component.symbol_name))
component.symbol_name = component.symbol_name === "fuse_vert" ? "fuse_horz" : "fuse_vert";
component.symbol_name = component.symbol_name.replace(
/_(right|up|left|down)$/,
(_: any, dir: string) =>
`_${dirs[(dirs.indexOf(dir) + Math.round(rotation / 90) + 4) % 4]}`,
);
if (Math.abs(rotation) % 180 === 90)
component.size = {
width: component.size.height,
height: component.size.width,
};
}
for (const type of [
"schematic_path",
"schematic_rect",
"schematic_circle",
"schematic_line",
"schematic_arc",
]) {
for (const primitive of db[type]
.list()
.filter(
(p: any) =>
p.schematic_component_id === component.schematic_component_id,
)) {
if (primitive.center) primitive.center = transform(primitive.center);
if (primitive.points)
primitive.points = primitive.points.map(transform);
if (typeof primitive.x1 === "number") {
const a = transform({ x: primitive.x1, y: primitive.y1 }),
b = transform({ x: primitive.x2, y: primitive.y2 });
Object.assign(primitive, { x1: a.x, y1: a.y, x2: b.x, y2: b.y });
}
}
}
component.center = { x, y };
}
function reroute() {
// Trace rendering also emits schematic_text labels; discard the old
// generated text before rebuilding routes so moved labels do not accumulate.
for (const text of db.schematic_text.list())
if (text.source_trace_id)
db.schematic_text.delete(text.schematic_text_id);
for (const type of ["schematic_trace", "schematic_net_label"])
for (const e of db[type].list()) db[type].delete(e[`${type}_id`]);
for (const group of [...board.getDescendants(), board].filter(
(g: any) => g.isSubcircuit && g.doInitialSchematicTraceRender,
))
group.doInitialSchematicTraceRender();
}
associateCustomSymbolAnnotations();
// Restore explanatory text above its own section and include it in section packing.
const sectionNotes = new Map<string, { owner: any; notes: typeof notes }>();
for (const note of notes) {
const group = note.sectionName
? db.schematic_group
.list()
.find((g: any) => g.name === `layout_${note.sectionName}`)
: undefined;
const owner = note.ref
? byName(note.ref)
: components().find(
(c: any) => c.schematic_group_id === group?.schematic_group_id,
);
if (!owner)
throw new Error(`No section for schematic text: ${note.text}`);
const entry = sectionNotes.get(owner.schematic_group_id) ?? {
owner,
notes: [],
};
entry.notes.push(note);
sectionNotes.set(owner.schematic_group_id, entry);
}
for (const [groupId, { owner, notes: groupNotes }] of sectionNotes) {
const members = components().filter(
(c: any) => c.schematic_group_id === groupId,
);
const left = Math.min(
...members.map((c: any) => c.center.x - c.size.width / 2),
);
const top = Math.max(
...members.map((c: any) => c.center.y + c.size.height / 2),
);
const lines = groupNotes.flatMap((note) => {
const wrapped: string[] = [];
let line = "";
for (const word of note.text.split(" ")) {
if ((line + " " + word).trim().length > 54) {
wrapped.push(line);
line = word;
} else line = (line + " " + word).trim();
}
if (line) wrapped.push(line);
return wrapped.map((text) => ({ ...note, text }));
});
lines.forEach((note, i) =>
db.schematic_text.insert({
text: note.text,
position: { x: left, y: top + 1.4 + (lines.length - i) * 0.38 },
anchor: "top_left",
font_size: note.fontSize,
color: note.color,
rotation: 0,
schematic_component_id: owner.schematic_component_id,
schematic_sheet_id: owner.schematic_sheet_id,
}),
);
}
// Pack whole sections using their measured bounds; component placements remain automatic.
const sheets = db.schematic_sheet.list();
for (const sheet of sheets) {
const groups = new Map<string, any[]>();
for (const c of components().filter(
(c: any) => c.schematic_sheet_id === sheet.schematic_sheet_id,
)) {
const list = groups.get(c.schematic_group_id) ?? [];
list.push(c);
groups.set(c.schematic_group_id, list);
}
const blocks = [...groups.values()]
.map((members) => {
const texts = db.schematic_text
.list()
.filter((t: any) =>
members.some(
(c) => c.schematic_component_id === t.schematic_component_id,
),
);
const minX = Math.min(
...members.map((c) => c.center.x - c.size.width / 2),
...texts.map((t: any) => t.position.x),
);
const maxX = Math.max(
...members.map((c) => c.center.x + c.size.width / 2),
...texts.map(
(t: any) => t.position.x + t.text.length * t.font_size * 0.62,
),
);
const minY = Math.min(
...members.map((c) => c.center.y - c.size.height / 2),
);
const maxY = Math.max(
...members.map((c) => c.center.y + c.size.height / 2),
...texts.map((t: any) => t.position.y),
);
const sourceGroup = db.schematic_group.get(members[0].schematic_group_id);
const sectionName = sourceGroup?.name?.replace(/^layout_/, "");
const section = board.getDescendants().find((c: any) => c.componentName === "SchematicSection" && c._parsedProps.name === sectionName);
const titleWidth = (section?._parsedProps.displayName?.length ?? 0) * 0.26 * 0.65;
return {
members,
minX,
maxY,
width: Math.max(maxX - minX, titleWidth) + 3,
height: maxY - minY + 3,
};
})
.sort((a, b) => b.height - a.height);
const availableWidth = ((sheet.sheet_width ?? 297) - 20) * 1.1 / 10.16;
for (const block of packMeasuredGroups(blocks, availableWidth)) {
for (const c of block.members)
move(c, c.center.x - block.minX + block.x, c.center.y - block.maxY - block.y);
}
}
const sections = board
.getDescendants()
.filter((c: any) => c.componentName === "SchematicSection");
// Core's section bounds omit routed labels. Include labels owned by a
// connected port in the section so a divider cannot cross their text.
for (const section of sections) {
const computeBounds = section._computeSectionBounds.bind(section);
section._computeSectionBounds = (
root: any,
name: string,
sheetId: string,
) => {
const bounds = computeBounds(root, name, sheetId);
if (!bounds) return bounds;
const memberIds = new Set(
root
.getDescendants()
.filter((c: any) => c.getSchematicSectionName() === name)
.map((c: any) => c.schematic_component_id)
.filter(Boolean),
);
for (const label of db.schematic_net_label.list()) {
if (label.schematic_sheet_id !== sheetId)
continue;
const trace = db.source_trace.get(label.source_trace_id);
const anchor = label.anchor_position ?? label.center;
const ports = db.schematic_port
.list()
.filter(
(p: any) =>
p.schematic_sheet_id === sheetId &&
(!trace || trace.connected_source_port_ids.includes(p.source_port_id)),
);
const nearestPort = ports.sort(
(a: any, b: any) =>
Math.hypot(a.center.x - anchor.x, a.center.y - anchor.y) -
Math.hypot(b.center.x - anchor.x, b.center.y - anchor.y),
)[0];
if (!memberIds.has(nearestPort?.schematic_component_id)) continue;
const vertical =
label.anchor_side === "top" || label.anchor_side === "bottom";
const length = label.text.length * 0.13 + 0.2;
const labelBounds = vertical
? {
minX: label.center.x - 0.1,
maxX: label.center.x + 0.1,
minY: anchor.y - (label.anchor_side === "top" ? length : 0),
maxY: anchor.y + (label.anchor_side === "bottom" ? length : 0),
}
: {
minX: Math.min(anchor.x, 2 * label.center.x - anchor.x) - 0.1,
maxX: Math.max(anchor.x, 2 * label.center.x - anchor.x) + 0.1,
minY: label.center.y - 0.1,
maxY: label.center.y + 0.1,
};
bounds.minX = Math.min(bounds.minX, labelBounds.minX);
bounds.maxX = Math.max(bounds.maxX, labelBounds.maxX);
bounds.minY = Math.min(bounds.minY, labelBounds.minY);
bounds.maxY = Math.max(bounds.maxY, labelBounds.maxY);
}
return bounds;
};
}
function renderSections() {
for (const e of db.schematic_line.list())
if (e.is_dashed && !e.schematic_component_id)
db.schematic_line.delete(e.schematic_line_id);
for (const e of db.schematic_text.list())
if (
!e.schematic_component_id &&
!e.schematic_symbol_id &&
!e.source_trace_id
)
db.schematic_text.delete(e.schematic_text_id);
for (const section of sections) section.doInitialSchematicSectionRender();
// Native section headings can share a cell boundary even for separate
// groups. Anchor each heading to its own measured content, not that boundary.
for (const section of sections) {
const title = section._parsedProps.displayName;
const sheetId = section._resolveSchematicSheetId();
const bounds = section._computeSectionBounds(board, section._parsedProps.name, sheetId);
const heading = db.schematic_text.list().find((t: any) => !t.schematic_component_id && !t.source_trace_id && t.schematic_sheet_id === sheetId && t.text === title);
if (bounds && heading) {
heading.font_size = 0.26;
heading.position = { x: bounds.minX, y: bounds.maxY + 0.9 };
}
}
}
reroute();
renderSections();
const history: any[] = [];
const seenLayouts = new Set<string>();
for (let iteration = 0; iteration < 60; iteration++) {
const issues: any[] = analyzeSchematicPlacement(db.toArray()).getIssues();
history.push(issues.map((i) => i.lineItemType));
if (!issues.length) break;
const layout = JSON.stringify({
components: components().map((c: any) => [c.schematic_component_id, c.center, c.size, c.symbol_name]),
texts: db.schematic_text.list().map((t: any) => [t.text, t.position, t.schematic_component_id]),
}, (_key, value) => typeof value === "number" ? Math.round(value * 1e6) / 1e6 : value);
if (seenLayouts.has(layout)) break;
seenLayouts.add(layout);
let changed = false;
const moved = new Set<string>();
const apply = (c: any, x: number, y: number, r = 0) => {
if (!c || moved.has(c.schematic_component_id)) return;
move(c, x, y, r);
moved.add(c.schematic_component_id);
changed = true;
};
for (const issue of issues) {
const type = issue.lineItemType;
if (type === "SchematicTextCollision") {
const t = db.schematic_text.get(issue.schematicTextId);
if (t && issue.suggestedMove) {
t.position = {
x: issue.suggestedMove.newSchX,
y: issue.suggestedMove.newSchY,
};
changed = true;
}
} else if (type === "ComponentOverlap") {
const suggestion = issue.correctionSuggestions[0];
const c = byName(suggestion.targetComponentName);
if (c)
apply(
c,
suggestion.newSchX + Math.sign(suggestion.deltaSchX) * 0.25,
suggestion.newSchY + Math.sign(suggestion.deltaSchY) * 0.25,
);
} else if (type === "NetLabelCollision") {
for (const m of issue.moves ?? [])
apply(byName(m.componentName), m.newSchX, m.newSchY);
} else if (type === "TraceCanBeSimplifiedByMovingComponent") {
apply(fromBox(issue.targetComponent), issue.newSchX, issue.newSchY);
} else if (
[
"CapacitorSymbolHorizontal",
"TwoPinComponentCouldBeFlipped",
"TwoPinComponentHasInvertedRails",
"TwoPinComponentShouldBeVertical",
"DiodeResistorNotAligned",
].includes(type)
) {
const c = fromBox(
issue.schematicBox ??
issue.targetComponent ??
issue.diodeSchematicBox,
);
// A flying capacitor connects two nearby pins on the same chip.
// Center it between those pins instead of repeatedly flipping it
// to improve one of its two routes at the expense of the other.
if (type === "TwoPinComponentCouldBeFlipped" && c) {
const chip = fromBox(issue.connectedComponent);
const ownPorts = db.schematic_port.list().filter((p: any) => p.schematic_component_id === c.schematic_component_id);
const pairs = ownPorts.map((port: any) => {
const trace = db.source_trace.list().find((t: any) => t.connected_source_port_ids.includes(port.source_port_id) &&
db.schematic_port.list().some((p: any) => p.schematic_component_id === chip?.schematic_component_id && t.connected_source_port_ids.includes(p.source_port_id)));
const other = db.schematic_port.list().find((p: any) => p.schematic_component_id === chip?.schematic_component_id && trace?.connected_source_port_ids.includes(p.source_port_id));
return { port, other };
});
if (pairs.length === 2 && pairs.every((p: any) => p.other?.facing_direction === "left") &&
Math.abs(pairs[0].other.center.y - pairs[1].other.center.y) >= c.size.height) {
const y = (pairs[0].other.center.y + pairs[1].other.center.y) / 2;
const first = pairs[0];
const desired = first.other.center.y > y ? "up" : "down";
apply(c, Math.min(...pairs.map((p: any) => p.other.center.x)) - 1.2, y,
first.port.facing_direction === desired ? 0 : 180);
continue;
}
}
if (c)
apply(
c,
c.center.x,
c.center.y,
issue.deltaSchRotation ??
(type === "CapacitorSymbolHorizontal" ? 90 : 180),
);
} else if (type === "FuseDiodeNotAligned") {
const fuse = fromBox(issue.fuseSchematicBox);
const diode = fromBox(issue.diodeSchematicBox);
const ports = db.schematic_port.list();
const a = ports.find((p: any) => p.source_port_id === issue.fuseSourcePortId);
const b = ports.find((p: any) => p.source_port_id === issue.diodeSourcePortId);
if (fuse && diode && a && b) {
const dirs = ["right", "up", "left", "down"];
const target = (dirs.indexOf(b.facing_direction) + 2) % 4;
const rotation = ((target - dirs.indexOf(a.facing_direction) + 4) % 4) * 90;
const angle = rotation * Math.PI / 180;
const dx = a.center.x - fuse.center.x, dy = a.center.y - fuse.center.y;
const offset = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }][target];
apply(fuse,
b.center.x - offset.x * 1.2 - (dx * Math.cos(angle) - dy * Math.sin(angle)),
b.center.y - offset.y * 1.2 - (dx * Math.sin(angle) + dy * Math.cos(angle)), rotation);
}
} else if (type === "RailPathTooSpreadOut") {
const host = fromBox(issue.hostSchematicBox);
if (host) (issue.supportSchematicBoxes ?? []).forEach((box: any, i: number) => {
const c = fromBox(box);
if (c) apply(c, host.center.x - 1 - i * 1.5, host.center.y + 2.5);
});
} else if (type === "DecouplingCapacitorsNotCloseTogether") {
const caps = issue.capacitorSchematicBoxes
.map(fromBox)
.filter(Boolean)
.sort((a: any, b: any) => a.center.x - b.center.x);
// Two rows keep the entire same-rail bank compact without hand-placing any capacitor.
const x = caps[0].center.x,
y = caps[0].center.y;
const pitch = Math.min(Math.min(...caps.map((c: any) => c.size.width)) + issue.maxRecommendedBodyGap - 0.1, Math.max(1.25, ...caps.map((c: any) => {
const name = db.source_component.get(c.source_component_id)?.name ?? "";
return name.length * 0.12 + 0.3;
})));
caps.forEach((c: any, i: number) =>
apply(c, x + (i % 3) * pitch, y - Math.floor(i / 3) * 1.4),
);
}
}
if (!changed) break;
reroute();
// Recompute dividers when component geometry changes, but keep the
// analyzer's final title-only moves instead of resetting them each pass.
if (moved.size) renderSections();
}
const remaining = analyzeSchematicPlacement(db.toArray()).getIssues();
board.__schematicStyleHistory = history;
if (remaining.length)
throw new Error(
`Automatic schematic style refinement did not converge: ${JSON.stringify(remaining)}`,
);
// Sheet warnings must be recomputed after moving the automatic layout.
for (const e of db.schematic_element_outside_sheet_warning.list())
db.schematic_element_outside_sheet_warning.delete(
e.schematic_element_outside_sheet_warning_id,
);
for (const c of board.getDescendants())
if (c.componentName === "SchematicSheet")
c.doInitialSchematicSheetRender();
original();
};
}