Fix silkscreen elements not moving with manual edit events#655
Fix silkscreen elements not moving with manual edit events#655buildingvibes wants to merge 5 commits intotscircuit:mainfrom
Conversation
Resolves issue where silkscreen lines and other silkscreen elements don't move when their parent component is moved via manual edit events. The root cause is that the applyEditEvents function from @tscircuit/core uses transformPCBElement which doesn't properly handle silkscreen line coordinates (x1, y1, x2, y2). This fix creates a wrapper function that post-processes the results to apply the correct translations to all silkscreen element types: - pcb_silkscreen_line - pcb_silkscreen_path - pcb_silkscreen_circle - pcb_silkscreen_rect - pcb_silkscreen_oval - pcb_silkscreen_pill - pcb_silkscreen_text
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Tests verify that all silkscreen element types move correctly with their parent components when manual edit events are applied.
… silkscreen elements Track original positions before calling applyEditEvents, and only apply the manual translation to elements that were not already moved upstream.
| for (const editEvent of editEvents) { | ||
| if ( | ||
| editEvent.edit_event_type === "edit_pcb_component_location" && | ||
| editEvent.original_center && | ||
| editEvent.new_center | ||
| ) { | ||
| const dx = editEvent.new_center.x - editEvent.original_center.x | ||
| const dy = editEvent.new_center.y - editEvent.original_center.y | ||
|
|
||
| if (dx === 0 && dy === 0) continue | ||
|
|
||
| result = result.map((element: any) => { | ||
| if (element.pcb_component_id !== editEvent.pcb_component_id) { | ||
| return element | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_line") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_line_id) | ||
| if (!orig || element.x1 !== orig.x1 || element.y1 !== orig.y1) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| x1: element.x1 + dx, | ||
| y1: element.y1 + dy, | ||
| x2: element.x2 + dx, | ||
| y2: element.y2 + dy, | ||
| } | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_path") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_path_id) | ||
| if ( | ||
| !orig || | ||
| !element.route?.length || | ||
| element.route[0].x !== orig.route?.[0]?.x | ||
| ) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| route: element.route.map((point: { x: number; y: number }) => ({ | ||
| x: point.x + dx, | ||
| y: point.y + dy, | ||
| })), | ||
| } | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_circle") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_circle_id) | ||
| if (!orig || element.center.x !== orig.cx) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| center: { | ||
| x: element.center.x + dx, | ||
| y: element.center.y + dy, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_rect") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_rect_id) | ||
| if (!orig || element.center.x !== orig.cx) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| center: { | ||
| x: element.center.x + dx, | ||
| y: element.center.y + dy, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_oval") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_oval_id) | ||
| if (!orig || element.center.x !== orig.cx) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| center: { | ||
| x: element.center.x + dx, | ||
| y: element.center.y + dy, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_pill") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_pill_id) | ||
| if (!orig || element.center.x !== orig.cx) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| center: { | ||
| x: element.center.x + dx, | ||
| y: element.center.y + dy, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| if (element.type === "pcb_silkscreen_text") { | ||
| const orig = originalPositions.get(element.pcb_silkscreen_text_id) | ||
| if (!orig || element.anchor_position?.x !== orig.ax) { | ||
| return element | ||
| } | ||
| return { | ||
| ...element, | ||
| anchor_position: { | ||
| x: element.anchor_position.x + dx, | ||
| y: element.anchor_position.y + dy, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| return element | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Critical Bug: Multiple edit events only apply the first transformation to silkscreen elements
When there are multiple edit events for the same component, only the first edit event will move the silkscreen elements. Subsequent edit events will be skipped because:
originalPositionsis captured once before any edits (lines 16-57)- After the first edit event, silkscreen positions in
resultare updated - When processing the second edit event, the check
element.x1 !== orig.x1(line 85) compares the UPDATED position against the ORIGINAL position - Since they differ (due to the first edit), the function returns early without applying the second transformation
Example failure scenario:
- Component moves from (0,0) → (2,0), then (2,0) → (5,0)
- First edit: silkscreen x1 moves from 0 to 2 ✓
- Second edit: check fails (2 ≠ 0), silkscreen stays at 2 instead of moving to 5 ✗
Fix:
Update originalPositions after each edit event, or compare against positions before the current edit event iteration:
for (const editEvent of editEvents) {
if (editEvent.edit_event_type === "edit_pcb_component_location" && ...) {
// Capture positions BEFORE this specific edit
const beforeEditPositions = new Map<string, any>()
for (const element of result) {
if (element.type === "pcb_silkscreen_line") {
beforeEditPositions.set(element.pcb_silkscreen_line_id, {
x1: element.x1, y1: element.y1, x2: element.x2, y2: element.y2
})
}
// ... handle other types
}
// Then use beforeEditPositions instead of originalPositions in comparisons
}
}| for (const editEvent of editEvents) { | |
| if ( | |
| editEvent.edit_event_type === "edit_pcb_component_location" && | |
| editEvent.original_center && | |
| editEvent.new_center | |
| ) { | |
| const dx = editEvent.new_center.x - editEvent.original_center.x | |
| const dy = editEvent.new_center.y - editEvent.original_center.y | |
| if (dx === 0 && dy === 0) continue | |
| result = result.map((element: any) => { | |
| if (element.pcb_component_id !== editEvent.pcb_component_id) { | |
| return element | |
| } | |
| if (element.type === "pcb_silkscreen_line") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_line_id) | |
| if (!orig || element.x1 !== orig.x1 || element.y1 !== orig.y1) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| x1: element.x1 + dx, | |
| y1: element.y1 + dy, | |
| x2: element.x2 + dx, | |
| y2: element.y2 + dy, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_path") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_path_id) | |
| if ( | |
| !orig || | |
| !element.route?.length || | |
| element.route[0].x !== orig.route?.[0]?.x | |
| ) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| route: element.route.map((point: { x: number; y: number }) => ({ | |
| x: point.x + dx, | |
| y: point.y + dy, | |
| })), | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_circle") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_circle_id) | |
| if (!orig || element.center.x !== orig.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_rect") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_rect_id) | |
| if (!orig || element.center.x !== orig.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_oval") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_oval_id) | |
| if (!orig || element.center.x !== orig.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_pill") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_pill_id) | |
| if (!orig || element.center.x !== orig.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_text") { | |
| const orig = originalPositions.get(element.pcb_silkscreen_text_id) | |
| if (!orig || element.anchor_position?.x !== orig.ax) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| anchor_position: { | |
| x: element.anchor_position.x + dx, | |
| y: element.anchor_position.y + dy, | |
| }, | |
| } | |
| } | |
| return element | |
| }) | |
| } | |
| } | |
| for (const editEvent of editEvents) { | |
| if ( | |
| editEvent.edit_event_type === "edit_pcb_component_location" && | |
| editEvent.original_center && | |
| editEvent.new_center | |
| ) { | |
| const dx = editEvent.new_center.x - editEvent.original_center.x | |
| const dy = editEvent.new_center.y - editEvent.original_center.y | |
| if (dx === 0 && dy === 0) continue | |
| // Capture positions before this specific edit | |
| const beforeEditPositions = new Map<string, any>() | |
| for (const element of result) { | |
| if (element.pcb_component_id === editEvent.pcb_component_id) { | |
| if (element.type === "pcb_silkscreen_line") { | |
| beforeEditPositions.set(element.pcb_silkscreen_line_id, { | |
| x1: element.x1, | |
| y1: element.y1, | |
| }) | |
| } else if (element.type === "pcb_silkscreen_path" && element.route?.length) { | |
| beforeEditPositions.set(element.pcb_silkscreen_path_id, { | |
| route: [{ x: element.route[0].x }], | |
| }) | |
| } else if (element.type === "pcb_silkscreen_circle") { | |
| beforeEditPositions.set(element.pcb_silkscreen_circle_id, { | |
| cx: element.center.x, | |
| }) | |
| } else if (element.type === "pcb_silkscreen_rect") { | |
| beforeEditPositions.set(element.pcb_silkscreen_rect_id, { | |
| cx: element.center.x, | |
| }) | |
| } else if (element.type === "pcb_silkscreen_oval") { | |
| beforeEditPositions.set(element.pcb_silkscreen_oval_id, { | |
| cx: element.center.x, | |
| }) | |
| } else if (element.type === "pcb_silkscreen_pill") { | |
| beforeEditPositions.set(element.pcb_silkscreen_pill_id, { | |
| cx: element.center.x, | |
| }) | |
| } else if (element.type === "pcb_silkscreen_text" && element.anchor_position) { | |
| beforeEditPositions.set(element.pcb_silkscreen_text_id, { | |
| ax: element.anchor_position.x, | |
| }) | |
| } | |
| } | |
| } | |
| result = result.map((element: any) => { | |
| if (element.pcb_component_id !== editEvent.pcb_component_id) { | |
| return element | |
| } | |
| if (element.type === "pcb_silkscreen_line") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_line_id) | |
| if (!before || element.x1 !== before.x1 || element.y1 !== before.y1) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| x1: element.x1 + dx, | |
| y1: element.y1 + dy, | |
| x2: element.x2 + dx, | |
| y2: element.y2 + dy, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_path") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_path_id) | |
| if ( | |
| !before || | |
| !element.route?.length || | |
| element.route[0].x !== before.route?.[0]?.x | |
| ) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| route: element.route.map((point: { x: number; y: number }) => ({ | |
| x: point.x + dx, | |
| y: point.y + dy, | |
| })), | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_circle") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_circle_id) | |
| if (!before || element.center.x !== before.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_rect") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_rect_id) | |
| if (!before || element.center.x !== before.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_oval") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_oval_id) | |
| if (!before || element.center.x !== before.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_pill") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_pill_id) | |
| if (!before || element.center.x !== before.cx) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| center: { | |
| x: element.center.x + dx, | |
| y: element.center.y + dy, | |
| }, | |
| } | |
| } | |
| if (element.type === "pcb_silkscreen_text") { | |
| const before = beforeEditPositions.get(element.pcb_silkscreen_text_id) | |
| if (!before || element.anchor_position?.x !== before.ax) { | |
| return element | |
| } | |
| return { | |
| ...element, | |
| anchor_position: { | |
| x: element.anchor_position.x + dx, | |
| y: element.anchor_position.y + dy, | |
| }, | |
| } | |
| } | |
| return element | |
| }) | |
| } | |
| } | |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Fixed in latest commit. Now capturing positions before each edit event instead of only at the start. This ensures multiple sequential edit events work correctly. Also added a test case for this scenario.
- Split test file into 6 separate files per project rule (max 1 test per file) - Fix critical bug where multiple edit events only applied first transformation - Capture positions before each edit event instead of only at the start - Add test for multiple sequential edit events scenario
buildingvibes
left a comment
There was a problem hiding this comment.
Addressed all Graphite AI review feedback:
- Split test file into 6 separate files (1 test per file) per project rule
- Fixed critical bug where multiple edit events only applied first transformation to silkscreen elements
- Added test case for multiple sequential edit events
The implementation now captures positions before each edit event iteration, ensuring that silkscreen elements move correctly even when multiple sequential edits are applied. Thanks for the thorough review!
| // Index original silkscreen positions before applying edit events | ||
| const originalPositions = new Map<string, any>() | ||
| for (const element of circuitJson as any[]) { | ||
| if (!element.pcb_component_id) continue | ||
| if (element.type === "pcb_silkscreen_line") { | ||
| originalPositions.set(element.pcb_silkscreen_line_id, { | ||
| x1: element.x1, | ||
| y1: element.y1, | ||
| x2: element.x2, | ||
| y2: element.y2, | ||
| }) | ||
| } else if (element.type === "pcb_silkscreen_path") { | ||
| originalPositions.set(element.pcb_silkscreen_path_id, { | ||
| route: element.route?.map((p: any) => ({ x: p.x, y: p.y })), | ||
| }) | ||
| } else if (element.type === "pcb_silkscreen_circle") { | ||
| originalPositions.set(element.pcb_silkscreen_circle_id, { | ||
| cx: element.center.x, | ||
| cy: element.center.y, | ||
| }) | ||
| } else if (element.type === "pcb_silkscreen_text") { | ||
| originalPositions.set(element.pcb_silkscreen_text_id, { | ||
| ax: element.anchor_position?.x, | ||
| ay: element.anchor_position?.y, | ||
| }) | ||
| } else if (element.type === "pcb_silkscreen_rect") { | ||
| originalPositions.set(element.pcb_silkscreen_rect_id, { | ||
| cx: element.center.x, | ||
| cy: element.center.y, | ||
| }) | ||
| } else if (element.type === "pcb_silkscreen_oval") { | ||
| originalPositions.set(element.pcb_silkscreen_oval_id, { | ||
| cx: element.center.x, | ||
| cy: element.center.y, | ||
| }) | ||
| } else if (element.type === "pcb_silkscreen_pill") { | ||
| originalPositions.set(element.pcb_silkscreen_pill_id, { | ||
| cx: element.center.x, | ||
| cy: element.center.y, | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Dead code: The originalPositions map is created but never used anywhere in the function. This wastes computation iterating through all circuit elements and storing positions that are never referenced. The map should be removed entirely.
// Remove lines 16-57 entirely
// The originalPositions map is not used anywhereSpotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
| route: [{ x: element.route[0].x }], | ||
| }) |
There was a problem hiding this comment.
Incomplete position capture: Only stores the x coordinate of the first route point but not the y coordinate. If applyEditEvents changes only the y-coordinate, the check on line 145 (element.route[0].x !== before.route?.[0]?.x) will pass and the translation will be incorrectly applied, potentially double-translating in the y-direction.
beforeEditPositions.set(element.pcb_silkscreen_path_id, {
route: [{ x: element.route[0].x, y: element.route[0].y }],
})Then update line 145 to check both coordinates:
if (
!before ||
!element.route?.length ||
element.route[0].x !== before.route?.[0]?.x ||
element.route[0].y !== before.route?.[0]?.y
) {
return element
}Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
The silkscreen elements were being moved twice - once by applyEditEvents and once by the fix code. This was because beforeEditPositions was captured after applyEditEvents had already run, so it was comparing the already-moved position to itself, which would always match and trigger the transformation again. Changed to compare against originalPositions (captured before applyEditEvents) instead of beforeEditPositions (captured after), which correctly detects if applyEditEvents already moved the elements and skips the duplicate transformation.
7d87016 to
94383e6
Compare
Summary
applyEditEventsWithSilkscreenFixto post-process edit eventsProblem
When a component is moved using manual edit events (drag and drop), silkscreen elements attached to that component stay in their original position instead of moving with the component. This is because the
applyEditEventsfunction from@tscircuit/coreusestransformPCBElementwhich doesn't properly handle silkscreen line coordinates (x1, y1, x2, y2).Solution
Created a wrapper function that:
applyEditEventsfunctionpcb_silkscreen_line(x1, y1, x2, y2)pcb_silkscreen_path(route array)pcb_silkscreen_circle(center)pcb_silkscreen_rect(center)pcb_silkscreen_oval(center)pcb_silkscreen_pill(center)pcb_silkscreen_text(anchor_position)Files Changed
src/PCBViewer.tsx: Updated to use the new wrapper function instead of directapplyEditEventssrc/lib/apply-edit-events-with-silkscreen-fix.ts: New utility function that wrapsapplyEditEventssrc/examples/2025/silkscreen/silkscreen-line-with-component-move.fixture.tsx: Test fixture demonstrating the fixTest Plan
/claim #84