Skip to content

manualEdits position double-counts the parent group's anchor for components inside positioned regions #2281

@gsdali

Description

@gsdali

Summary

manualEdits.pcb_placements lands a manually-placed component at the wrong absolute position when its enclosing <group> has a non-zero pcbX/pcbY. The group anchor gets applied twice — once when computing the parent's transform, again via translate(manualPlacement.x, manualPlacement.y) — even though manualPlacement is already in subcircuit-absolute coordinates.

Repro

<board width="40mm" height="20mm" manualEdits={{
  pcb_placements: [
    { selector: "C1", center: { x: 11.4, y: 4.3 }, relative_to: "group_center" },
  ],
}}>
  <group name="region" pcbX={16} pcbY={0}>
    <chip name="U1" footprint="soic8" pinLabels={{ pin1: "VCC", pin8: "GND" }} />
    <capacitor name="C1" capacitance="100nF" footprint="0402"
      connections={{ pin1: "U1.VCC", pin2: "U1.GND" }} />
  </group>
</board>

Expected: C1.center = (11.4, 4.3) — the user dragged it there.

Actual: C1.center = (27.4, 4.3). The +16 region anchor was added on top of the manual position.

(With the static-marking fix from #TBD applied, the bug becomes more visible: C1.center = (43.4, 4.3) — the anchor is added a third time by applyPackOutput. Without the static fix, the packer overwrites the wrong position with its own different wrong position, masking this bug.)

Cause

In lib/components/base-components/PrimitiveComponent.ts's _computePcbGlobalTransformBeforeLayout:

const manualPlacement = this.getSubcircuit()._getPcbManualPlacementForComponent(this)
if (manualPlacement && this.props.pcbX === undefined && this.props.pcbY === undefined) {
  return compose(
    this.parent?._computePcbGlobalTransformBeforeLayout() ?? identity(),
    compose(
      translate(manualPlacement.x, manualPlacement.y),
      rotate(rotation * Math.PI / 180),
    ),
  )
}

manualPlacement comes from _getPcbManualPlacementForComponent:

const center = applyToPoint(
  this._computePcbGlobalTransformBeforeLayout(),  // subcircuit's transform
  position.center,
)
return center

For a board subcircuit (transform = identity), the returned center is just position.center — the value the user wrote into manual-edits.json, which the IDE recorded in board-absolute coordinates. So manualPlacement is already in the same frame as the final intended position.

But then _computePcbGlobalTransformBeforeLayout composes it on top of the parent group's transform. For a chip directly under the board (parent = board, transform = identity), this happens to work. For a component inside a positioned group, the parent's translate is applied AND the absolute manual position is applied as a translate, double-counting the group anchor.

Fix sketch

When a manualPlacement exists, the component's transform should be the manualPlacement directly, NOT composed with the parent's transform:

if (manualPlacement && this.props.pcbX === undefined && this.props.pcbY === undefined) {
  const rotation = this._getPcbRotationBeforeLayout() ?? 0
  return compose(
    translate(manualPlacement.x, manualPlacement.y),
    rotate(rotation * Math.PI / 180),
  )
}

(The same applies to the schematic equivalent at line ~1815 if it has the same shape.)

Impact

Anyone using the IDE drag-and-pin flow for components inside positioned regions sees their drags land at the wrong place after rebuild. This is the dominant pattern for any board with multiple chip regions — every cap, resistor, etc. inside a regional group becomes mis-placed.

The companion fix #TBD (this PR's parent issue) makes the packer respect manualEdits placements; this issue is what makes those placements land where the user actually dragged them. Both are needed for the IDE drag workflow to be correct end-to-end.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions