Skip to content

Fix silkscreen elements not moving with manual edit events#655

Open
buildingvibes wants to merge 5 commits intotscircuit:mainfrom
buildingvibes:fix-silkscreen-lines-manual-edit
Open

Fix silkscreen elements not moving with manual edit events#655
buildingvibes wants to merge 5 commits intotscircuit:mainfrom
buildingvibes:fix-silkscreen-lines-manual-edit

Conversation

@buildingvibes
Copy link
Copy Markdown

@buildingvibes buildingvibes commented Feb 10, 2026

Summary

  • Fixes silkscreen lines and other silkscreen elements not moving when their parent component is moved via manual edit events
  • Creates wrapper function applyEditEventsWithSilkscreenFix to post-process edit events
  • Adds test fixture to verify the fix

Problem

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 applyEditEvents function from @tscircuit/core uses transformPCBElement which doesn't properly handle silkscreen line coordinates (x1, y1, x2, y2).

Solution

Created a wrapper function that:

  1. Calls the standard applyEditEvents function
  2. Post-processes the result to apply the same translation to silkscreen elements
  3. Handles all silkscreen element types with their specific coordinate systems:
    • pcb_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 direct applyEditEvents
  • src/lib/apply-edit-events-with-silkscreen-fix.ts: New utility function that wraps applyEditEvents
  • src/examples/2025/silkscreen/silkscreen-line-with-component-move.fixture.tsx: Test fixture demonstrating the fix

Test Plan

  • Build succeeds without errors
  • TypeScript type checking passes
  • Code formatting passes (Biome)
  • Manual testing: Open the test fixture and verify silkscreen lines move with components
  • Test with existing fixtures to ensure no regression

/claim #84

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
@vercel
Copy link
Copy Markdown

vercel bot commented Feb 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pcb-viewer Ready Ready Preview, Comment Feb 10, 2026 5:21am

Request Review

Tests verify that all silkscreen element types move correctly with their
parent components when manual edit events are applied.
Comment thread tests/lib/apply-edit-events-with-silkscreen-fix.test.ts Outdated
… silkscreen elements

Track original positions before calling applyEditEvents, and only apply
the manual translation to elements that were not already moved upstream.
Comment on lines +67 to +188
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
})
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. originalPositions is captured once before any edits (lines 16-57)
  2. After the first edit event, silkscreen positions in result are updated
  3. When processing the second edit event, the check element.x1 !== orig.x1 (line 85) compares the UPDATED position against the ORIGINAL position
  4. 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
  }
}
Suggested change
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

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Author

@buildingvibes buildingvibes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed all Graphite AI review feedback:

  1. Split test file into 6 separate files (1 test per file) per project rule
  2. Fixed critical bug where multiple edit events only applied first transformation to silkscreen elements
  3. 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!

Comment on lines +16 to +57
// 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,
})
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 anywhere

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +92 to +93
route: [{ x: element.route[0].x }],
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix in Graphite


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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant