diff --git a/bun.lock b/bun.lock index 3bc246f..ac0434d 100644 --- a/bun.lock +++ b/bun.lock @@ -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", @@ -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=="], @@ -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=="], diff --git a/package.json b/package.json index 2c6e324..df93463 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/GroupAnchorOffsetOverlay/calculateGroupBoundingBox.ts b/src/components/GroupAnchorOffsetOverlay/calculateGroupBoundingBox.ts new file mode 100644 index 0000000..581b5a1 --- /dev/null +++ b/src/components/GroupAnchorOffsetOverlay/calculateGroupBoundingBox.ts @@ -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) +} diff --git a/src/components/GroupAnchorOffsetOverlay/constants.ts b/src/components/GroupAnchorOffsetOverlay/constants.ts new file mode 100644 index 0000000..9b00277 --- /dev/null +++ b/src/components/GroupAnchorOffsetOverlay/constants.ts @@ -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 diff --git a/src/components/GroupAnchorOffsetOverlay/findAnchorMarkerPosition.ts b/src/components/GroupAnchorOffsetOverlay/findAnchorMarkerPosition.ts new file mode 100644 index 0000000..ea1d13b --- /dev/null +++ b/src/components/GroupAnchorOffsetOverlay/findAnchorMarkerPosition.ts @@ -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 } +} diff --git a/src/components/GroupAnchorOffsetOverlay/index.tsx b/src/components/GroupAnchorOffsetOverlay/index.tsx new file mode 100644 index 0000000..955c12f --- /dev/null +++ b/src/components/GroupAnchorOffsetOverlay/index.tsx @@ -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 ( +