Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 66 additions & 39 deletions src/components/EditPlacementOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import type { AnyCircuitElement, PcbComponent } from "circuit-json"
import { useGlobalStore } from "../global-store"
import { useEffect, useRef, useState } from "react"
import { type State, useGlobalStore } from "../global-store"
import type { ReactNode } from "react"
import { useMemo, useRef, useState } from "react"
import type { Matrix } from "transformation-matrix"
import { applyToPoint, identity, inverse } from "transformation-matrix"
import type { ManualEditEvent } from "@tscircuit/props"
import { shouldPickPcbComponent } from "../lib/should-pick-pcb-component"

interface Props {
transform?: Matrix
children: any
children: ReactNode
soup: AnyCircuitElement[]
disabled?: boolean
cancelPanDrag: () => void
onCreateEditEvent: (event: ManualEditEvent) => void
onModifyEditEvent: (event: Partial<ManualEditEvent>) => void
}

const HIT_PADDING_PX = 10

const selectPlacementPickerState = (state: State) => ({
inMoveFootprintMode: state.in_move_footprint_mode,
selectedLayer: state.selected_layer,
showTopComponents: state.is_showing_top_components,
showBottomComponents: state.is_showing_bottom_components,
setIsMovingComponent: state.setIsMovingComponent,
})

const isInsideOf = (
pcb_component: PcbComponent,
point: { x: number; y: number },
Expand Down Expand Up @@ -52,11 +64,30 @@ export const EditPlacementOverlay = ({
edit_event_id: string
} | null>(null)
const isPcbComponentActive = activePcbComponentId !== null
const in_edit_mode = useGlobalStore((s) => s.in_edit_mode)
const in_move_footprint_mode = useGlobalStore((s) => s.in_move_footprint_mode)
const setIsMovingComponent = useGlobalStore((s) => s.setIsMovingComponent)
const scale = Math.abs(transform.a)
const {
inMoveFootprintMode,
selectedLayer,
showTopComponents,
showBottomComponents,
setIsMovingComponent,
} = useGlobalStore(selectPlacementPickerState)

const pickablePcbComponents = useMemo(
() =>
soup.filter(
(element): element is PcbComponent =>
element.type === "pcb_component" &&
shouldPickPcbComponent(element, {
selectedLayer,
showTopComponents,
showBottomComponents,
}),
),
[soup, selectedLayer, showTopComponents, showBottomComponents],
)

const disabled = disabledProp || !in_move_footprint_mode
const disabled = disabledProp || !inMoveFootprintMode

return (
<div
Expand All @@ -72,13 +103,11 @@ export const EditPlacementOverlay = ({
const y = e.clientY - rect.top
if (Number.isNaN(x) || Number.isNaN(y)) return
const rwMousePoint = applyToPoint(inverse(transform!), { x, y })
const hitPadding = HIT_PADDING_PX / scale

let foundActiveComponent = false
for (const e of soup) {
if (
e.type === "pcb_component" &&
isInsideOf(e, rwMousePoint, 10 / transform.a)
) {
for (const e of pickablePcbComponents) {
if (isInsideOf(e, rwMousePoint, hitPadding)) {
cancelPanDrag()
setActivePcbComponent(e.pcb_component_id)
foundActiveComponent = true
Expand Down Expand Up @@ -139,7 +168,7 @@ export const EditPlacementOverlay = ({
},
})
}}
onMouseUp={(e) => {
onMouseUp={() => {
if (!activePcbComponentId) return
setActivePcbComponent(null)
setIsMovingComponent(false)
Expand All @@ -154,33 +183,31 @@ export const EditPlacementOverlay = ({
>
{children}
{!disabled &&
soup
.filter((e): e is PcbComponent => e.type === "pcb_component")
.map((e) => {
if (!e?.center) return null
const projectedCenter = applyToPoint(transform, e.center)
pickablePcbComponents.map((e) => {
if (!e?.center) return null
const projectedCenter = applyToPoint(transform, e.center)

return (
<div
key={e.pcb_component_id}
style={{
position: "absolute",
pointerEvents: "none",
// b/c of transform, this is actually center not left/top
left: projectedCenter.x,
top: projectedCenter.y,
width: e.width * transform.a + 20,
height: e.height * transform.a + 20,
transform: "translate(-50%, -50%)",
background:
isPcbComponentActive &&
activePcbComponentId === e.pcb_component_id
? "rgba(255, 0, 0, 0.2)"
: "",
}}
/>
)
})}
return (
<div
key={e.pcb_component_id}
style={{
position: "absolute",
pointerEvents: "none",
// b/c of transform, this is actually center not left/top
left: projectedCenter.x,
top: projectedCenter.y,
width: e.width * scale + HIT_PADDING_PX * 2,
height: e.height * scale + HIT_PADDING_PX * 2,
transform: "translate(-50%, -50%)",
background:
isPcbComponentActive &&
activePcbComponentId === e.pcb_component_id
? "rgba(255, 0, 0, 0.2)"
: "",
}}
/>
)
})}
</div>
)
}
14 changes: 11 additions & 3 deletions src/global-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export interface State {
is_showing_solder_mask: boolean
is_showing_silkscreen: boolean
is_showing_fabrication_notes: boolean
// These defaults keep component picking aligned with any host app that
// exposes side-specific component visibility toggles.
is_showing_top_components: boolean
is_showing_bottom_components: boolean
pcb_group_view_mode: "all" | "named_only"

hovered_error_id: string | null
Expand All @@ -62,9 +66,11 @@ export interface State {
setFocusedErrorId: (errorId: string | null) => void
}

export type StateProps = {
[key in keyof State]: State[key] extends boolean ? boolean : never
}
type BooleanStateKeys<T> = {
[Key in keyof T]: Exclude<T[Key], undefined> extends boolean ? Key : never
}[keyof T]

export type StateProps = Pick<State, BooleanStateKeys<State>>

const DEFAULT_PCB_GROUP_VIEW_MODE: "all" | "named_only" =
process.env.NODE_ENV !== "production" ? "named_only" : "all"
Expand Down Expand Up @@ -119,6 +125,8 @@ export const createStore = (
STORAGE_KEYS.IS_SHOWING_FABRICATION_NOTES,
false,
),
is_showing_top_components: true,
is_showing_bottom_components: true,
pcb_group_view_mode: disablePcbGroups
? "all"
: (getStoredString(
Expand Down
26 changes: 26 additions & 0 deletions src/lib/should-pick-pcb-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { LayerRef } from "circuit-json"

interface PickabilityOptions {
selectedLayer: LayerRef
showTopComponents?: boolean
showBottomComponents?: boolean
}

export const shouldPickPcbComponent = (
component: { layer?: LayerRef | null },
{
selectedLayer,
showTopComponents = true,
showBottomComponents = true,
}: PickabilityOptions,
) => {
if (component.layer !== "top" && component.layer !== "bottom") {
// Through-hole style components can be interacted with from either side.
return true
}

if (component.layer === "top" && !showTopComponents) return false
if (component.layer === "bottom" && !showBottomComponents) return false

return component.layer === selectedLayer
}
58 changes: 58 additions & 0 deletions tests/lib/should-pick-pcb-component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "bun:test"
import { shouldPickPcbComponent } from "../../src/lib/should-pick-pcb-component"

describe("shouldPickPcbComponent", () => {
it("allows a component on the selected side", () => {
expect(
shouldPickPcbComponent({ layer: "top" }, { selectedLayer: "top" }),
).toBe(true)
})

it("blocks a component on the non-selected side", () => {
expect(
shouldPickPcbComponent(
{ layer: "top" },
{
selectedLayer: "bottom",
},
),
).toBe(false)
})

it("blocks hidden top-side components even when top is selected", () => {
expect(
shouldPickPcbComponent(
{ layer: "top" },
{
selectedLayer: "top",
showTopComponents: false,
},
),
).toBe(false)
})

it("blocks hidden bottom-side components even when bottom is selected", () => {
expect(
shouldPickPcbComponent(
{ layer: "bottom" },
{
selectedLayer: "bottom",
showBottomComponents: false,
},
),
).toBe(false)
})

it("keeps layerless components pickable regardless of side filters", () => {
expect(
shouldPickPcbComponent(
{ layer: undefined },
{
selectedLayer: "inner1",
showTopComponents: false,
showBottomComponents: false,
},
),
).toBe(true)
})
})