Skip to content
Merged
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
5 changes: 4 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dependencies": {
"@emotion/css": "^11.11.2",
"@tscircuit/alphabet": "^0.0.3",
"@tscircuit/math-utils": "^0.0.29",
"@vitejs/plugin-react": "^5.0.2",
"circuit-json": "^0.0.307",
"circuit-to-svg": "^0.0.265",
Expand Down Expand Up @@ -403,7 +404,7 @@

"@tscircuit/matchpack": ["@tscircuit/matchpack@0.0.16", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-F9QX7uQdml88XGKwe7kDkYnwHfG0kykr2cHD+JsnATKlgi32vYwFGuRaOR4tyRrkDGdmzt5T7YYb4Mhi9uncGA=="],

"@tscircuit/math-utils": ["@tscircuit/math-utils@0.0.21", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-M1viGlMkOQsYiGfNBEbvsJXxB7DDrMqzA475DzK0QaD/jGueyEk5JgIQsd91SRG0JDtZUH1SvysPjNMJbUOy3Q=="],
"@tscircuit/math-utils": ["@tscircuit/math-utils@0.0.29", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-tWz9PE5F6GhyU9J96wS4NAA/b0Yy9viYCPYi0uWzl77zelywB8Z4Pj7f0zO9kt7e0sNl4e3l2DvxTBsCf2qasg=="],

"@tscircuit/miniflex": ["@tscircuit/miniflex@0.0.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-+Bf/FVAFQfe/foHJXlFPbCjIpi7krjwlrvKgqdQX/J5PnS8xgZW4whe31xqRyN83oWE4zeBV3l2WG2cx3bOvGg=="],

Expand Down Expand Up @@ -2263,6 +2264,8 @@

"tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],

"tscircuit/@tscircuit/math-utils": ["@tscircuit/math-utils@0.0.21", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-M1viGlMkOQsYiGfNBEbvsJXxB7DDrMqzA475DzK0QaD/jGueyEk5JgIQsd91SRG0JDtZUH1SvysPjNMJbUOy3Q=="],

"tscircuit/circuit-json": ["circuit-json@0.0.278", "", {}, "sha512-qJVj1huQt7FZ0lV5KTpJjQ0LHXid0Nh3v1KCqRPXbBdp35GhyJ1GshS92xD+xNotuKngvxJIwf1eZgXJXv6I1A=="],

"tscircuit/circuit-to-svg": ["circuit-to-svg@0.0.238", "", { "dependencies": { "@types/node": "^22.5.5", "bun-types": "^1.1.40", "calculate-elbow": "0.0.12", "svgson": "^5.3.1", "transformation-matrix": "^2.16.1" } }, "sha512-gRLKDhj/PrbFu+xMNRJqbU0C9l8HrZiFNebG1o9hs6GdpTf+H93nR/BltzQNHNXULGEO1NG+cgMeFahGMuBSbQ=="],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"dependencies": {
"@emotion/css": "^11.11.2",
"@tscircuit/alphabet": "^0.0.3",
"@tscircuit/math-utils": "^0.0.29",
"@vitejs/plugin-react": "^5.0.2",
"circuit-json": "^0.0.307",
"circuit-to-svg": "^0.0.265",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getBoundsFromPoints } from "@tscircuit/math-utils"
import type { PcbComponent } from "circuit-json"
import type { BoundingBox } from "../../lib/util/get-primitive-bounding-box"

export const calculateGroupBoundingBox = (
groupComponents: PcbComponent[],
): BoundingBox | null => {
const points: Array<{ x: number; y: number }> = []

for (const comp of groupComponents) {
if (
!comp.center ||
typeof comp.width !== "number" ||
typeof comp.height !== "number"
) {
continue
}

const halfWidth = comp.width / 2
const halfHeight = comp.height / 2

points.push({ x: comp.center.x - halfWidth, y: comp.center.y - halfHeight })
points.push({ x: comp.center.x + halfWidth, y: comp.center.y + halfHeight })
}

return getBoundsFromPoints(points)
}
19 changes: 19 additions & 0 deletions src/components/GroupAnchorOffsetOverlay/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const VISUAL_CONFIG = {
GROUP_PADDING: 1,
MIN_LINE_LENGTH_FOR_LABEL: 40,
LABEL_OFFSET_ABOVE: 2,
LABEL_OFFSET_BELOW: -18,
LABEL_OFFSET_RIGHT: 8,
LABEL_OFFSET_LEFT: -80,
LINE_STROKE_WIDTH: 1.5,
LINE_DASH_PATTERN: "4,4",
COMPONENT_MARKER_RADIUS: 3,
LABEL_FONT_SIZE: 11,
} as const

export const COLORS = {
OFFSET_LINE: "white",
COMPONENT_MARKER_FILL: "#66ccff",
COMPONENT_MARKER_STROKE: "white",
LABEL_TEXT: "white",
} as const
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { BoundingBox } from "../../lib/util/get-primitive-bounding-box"
export const findAnchorMarkerPosition = (
anchor: { x: number; y: number },
bounds: BoundingBox,
): { x: number; y: number } => {
const { minX, maxX, minY, maxY } = bounds

const distToLeft = Math.abs(anchor.x - minX)
const distToRight = Math.abs(anchor.x - maxX)
const distToTop = Math.abs(anchor.y - maxY)
const distToBottom = Math.abs(anchor.y - minY)

const minDist = Math.min(distToLeft, distToRight, distToTop, distToBottom)

if (minDist === distToLeft) return { x: minX, y: anchor.y }
if (minDist === distToRight) return { x: maxX, y: anchor.y }
if (minDist === distToTop) return { x: anchor.x, y: maxY }
return { x: anchor.x, y: minY }
}
210 changes: 210 additions & 0 deletions src/components/GroupAnchorOffsetOverlay/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import type { AnyCircuitElement, PcbComponent, PcbGroup } from "circuit-json"
import { applyToPoint } from "transformation-matrix"
import type { Matrix } from "transformation-matrix"
import { useGlobalStore } from "../../global-store"
import type { BoundingBox } from "../../lib/util/get-primitive-bounding-box"
import { zIndexMap } from "../../lib/util/z-index-map"
import type { HighlightedPrimitive } from "../MouseElementTracker"
import { calculateGroupBoundingBox } from "./calculateGroupBoundingBox"
import { COLORS, VISUAL_CONFIG } from "./constants"
import { findAnchorMarkerPosition } from "./findAnchorMarkerPosition"

type Point = {
x: number
y: number
}

interface Props {
elements: AnyCircuitElement[]
highlightedPrimitives: HighlightedPrimitive[]
transform: Matrix
containerWidth: number
containerHeight: number
children?: any
}

/**
* Overlay component that displays offset measurements from a group's anchor point
* to the hovered component. Shows dotted lines and distance labels for X and Y axes.
*/
export const GroupAnchorOffsetOverlay = ({
elements,
highlightedPrimitives,
transform,
containerWidth,
containerHeight,
children,
}: Props) => {
const is_showing_group_anchor_offsets = useGlobalStore(
(s) => s.is_showing_group_anchor_offsets,
)

if (!is_showing_group_anchor_offsets || highlightedPrimitives.length === 0) {
return null
}

const hoveredPrimitive = highlightedPrimitives.find(
(p) =>
p._parent_pcb_component?.type === "pcb_component" ||
p._element?.type === "pcb_component",
)

if (!hoveredPrimitive) return null

const pcbComponent = (hoveredPrimitive._parent_pcb_component ||
hoveredPrimitive._element) as PcbComponent | undefined

if (!pcbComponent?.pcb_group_id) return null

const parentGroup = elements
.filter((el): el is PcbGroup => el.type === "pcb_group")
.find((group) => group.pcb_group_id === pcbComponent.pcb_group_id)

if (!parentGroup?.anchor_position) return null

const targetCenter: Point =
hoveredPrimitive._element?.type === "pcb_smtpad"
? { x: hoveredPrimitive.x, y: hoveredPrimitive.y }
: pcbComponent.center || {
x: hoveredPrimitive.x,
y: hoveredPrimitive.y,
}

const groupComponents = elements
.filter((el): el is PcbComponent => el.type === "pcb_component")
.filter((comp) => comp.pcb_group_id === parentGroup.pcb_group_id)

const boundingBox = calculateGroupBoundingBox(groupComponents)
if (!boundingBox) return null

const groupBounds: BoundingBox = {
minX: boundingBox.minX - VISUAL_CONFIG.GROUP_PADDING,
maxX: boundingBox.maxX + VISUAL_CONFIG.GROUP_PADDING,
minY: boundingBox.minY - VISUAL_CONFIG.GROUP_PADDING,
maxY: boundingBox.maxY + VISUAL_CONFIG.GROUP_PADDING,
}

const anchorMarkerPosition = findAnchorMarkerPosition(
parentGroup.anchor_position,
groupBounds,
)

const offsetX = targetCenter.x - anchorMarkerPosition.x
const offsetY = targetCenter.y - anchorMarkerPosition.y

const anchorMarkerScreen = applyToPoint(transform, anchorMarkerPosition)
const componentScreen = applyToPoint(transform, targetCenter)

const xLineLength = Math.abs(componentScreen.x - anchorMarkerScreen.x)
const yLineLength = Math.abs(componentScreen.y - anchorMarkerScreen.y)

const isComponentAboveAnchor = componentScreen.y < anchorMarkerScreen.y
const isComponentRightOfAnchor = componentScreen.x > anchorMarkerScreen.x

const xLabelOffset = isComponentAboveAnchor
? VISUAL_CONFIG.LABEL_OFFSET_ABOVE
: VISUAL_CONFIG.LABEL_OFFSET_BELOW

const yLabelOffset = isComponentRightOfAnchor
? VISUAL_CONFIG.LABEL_OFFSET_RIGHT
: VISUAL_CONFIG.LABEL_OFFSET_LEFT

const shouldShowXLabel = xLineLength > VISUAL_CONFIG.MIN_LINE_LENGTH_FOR_LABEL
const shouldShowYLabel = yLineLength > VISUAL_CONFIG.MIN_LINE_LENGTH_FOR_LABEL

const labelStyle: React.CSSProperties = {
color: COLORS.LABEL_TEXT,
mixBlendMode: "difference",
pointerEvents: "none",
fontSize: VISUAL_CONFIG.LABEL_FONT_SIZE,
fontFamily: "monospace",
fontWeight: "bold",
}

return (
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: containerWidth,
height: containerHeight,
overflow: "hidden",
pointerEvents: "none",
zIndex: zIndexMap.dimensionOverlay,
}}
>
<svg
style={{
position: "absolute",
left: 0,
top: 0,
pointerEvents: "none",
}}
width={containerWidth}
height={containerHeight}
>
<line
x1={anchorMarkerScreen.x}
y1={anchorMarkerScreen.y}
x2={componentScreen.x}
y2={anchorMarkerScreen.y}
stroke={COLORS.OFFSET_LINE}
strokeWidth={VISUAL_CONFIG.LINE_STROKE_WIDTH}
strokeDasharray={VISUAL_CONFIG.LINE_DASH_PATTERN}
/>

<line
x1={componentScreen.x}
y1={anchorMarkerScreen.y}
x2={componentScreen.x}
y2={componentScreen.y}
stroke={COLORS.OFFSET_LINE}
strokeWidth={VISUAL_CONFIG.LINE_STROKE_WIDTH}
strokeDasharray={VISUAL_CONFIG.LINE_DASH_PATTERN}
/>

<circle
cx={componentScreen.x}
cy={componentScreen.y}
r={VISUAL_CONFIG.COMPONENT_MARKER_RADIUS}
fill={COLORS.COMPONENT_MARKER_FILL}
stroke={COLORS.COMPONENT_MARKER_STROKE}
strokeWidth={1}
/>
</svg>

{shouldShowXLabel && (
<div
style={{
...labelStyle,
position: "absolute",
left: Math.min(anchorMarkerScreen.x, componentScreen.x),
top: anchorMarkerScreen.y + xLabelOffset,
width: Math.abs(componentScreen.x - anchorMarkerScreen.x),
textAlign: "center",
}}
>
Δx: {offsetX.toFixed(2)}mm
</div>
)}

{shouldShowYLabel && (
<div
style={{
...labelStyle,
position: "absolute",
left: componentScreen.x + yLabelOffset,
top: Math.min(anchorMarkerScreen.y, componentScreen.y),
height: Math.abs(componentScreen.y - anchorMarkerScreen.y),
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
Δy: {offsetY.toFixed(2)}mm
</div>
)}
</div>
)
}
28 changes: 21 additions & 7 deletions src/components/MouseElementTracker.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { pointToSegmentDistance } from "@tscircuit/math-utils"
import type { AnyCircuitElement } from "circuit-json"
import { distance } from "circuit-json"
import type { Primitive } from "lib/types"
import { ifSetsMatchExactly } from "lib/util/if-sets-match-exactly"
import React, { useState, useMemo } from "react"
import { useMeasure } from "react-use"
import type { Matrix } from "transformation-matrix"
import { applyToPoint, inverse } from "transformation-matrix"
import type { Primitive } from "lib/types"
import { ElementOverlayBox } from "./ElementOverlayBox"
import type { AnyCircuitElement } from "circuit-json"
import { distance } from "circuit-json"
import { ifSetsMatchExactly } from "lib/util/if-sets-match-exactly"
import { pointToSegmentDistance } from "@tscircuit/math-utils"
import { GroupAnchorOffsetOverlay } from "./GroupAnchorOffsetOverlay"

const getPolygonBoundingBox = (
points: ReadonlyArray<{ x: number; y: number }>,
Expand Down Expand Up @@ -168,13 +170,15 @@ export const MouseElementTracker = ({
onMouseHoverOverPrimitives,
}: {
elements: AnyCircuitElement[]
children: any
children: React.ReactNode
transform?: Matrix
primitives: Primitive[]
onMouseHoverOverPrimitives: (primitivesHoveredOver: Primitive[]) => void
}) => {
const [mousedPrimitives, setMousedPrimitives] = useState<Primitive[]>([])
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
const [containerRef, { width, height }] = useMeasure<HTMLDivElement>()

const highlightedPrimitives = useMemo(() => {
const highlightedPrimitives: HighlightedPrimitive[] = []
for (const primitive of mousedPrimitives) {
Expand Down Expand Up @@ -281,7 +285,8 @@ export const MouseElementTracker = ({

return (
<div
style={{ position: "relative" }}
ref={containerRef}
style={{ position: "relative", width: "100%", height: "100%" }}
onMouseMove={(e) => {
if (transform) {
const rect = e.currentTarget.getBoundingClientRect()
Expand All @@ -306,6 +311,15 @@ export const MouseElementTracker = ({
mousePos={mousePos}
highlightedPrimitives={highlightedPrimitives}
/>
{transform && (
<GroupAnchorOffsetOverlay
Copy link
Contributor

Choose a reason for hiding this comment

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

why is this inside the MouseElement tracker?

Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't it be with the other overlays?

Copy link
Contributor

Choose a reason for hiding this comment

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

Marking this resolved? Why?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

GroupAnchorOffsetOverlay sits inside MouseElementTracker because it needs the tracker’s hover payload (highlighted primitives, mouse coords, container size).
It’s the same pattern one line above with ElementOverlayBox: both overlays rely on that internal hover state, so they live alongside it, while the rest of the overlays, those that only need elements and transform, so they stay in the outer stack. Is this not corrent ??

elements={elements}
highlightedPrimitives={highlightedPrimitives}
transform={transform}
containerWidth={width}
containerHeight={height}
/>
)}
</div>
)
}
Expand Down
Loading