From b4c7d5bead4ffe0299d537fde58a347855c860e3 Mon Sep 17 00:00:00 2001 From: rushabhcodes Date: Wed, 19 Nov 2025 08:37:53 +0530 Subject: [PATCH 1/3] feat: Introduce GroupAnchorOffsetOverlay component and example fixture for displaying group anchor hover offsets. --- src/components/GroupAnchorOffsetOverlay.tsx | 294 +++++++++++++++++ src/components/MouseElementTracker.tsx | 26 +- .../group-anchor-hover-offset.fixture.tsx | 305 ++++++++++++++++++ 3 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 src/components/GroupAnchorOffsetOverlay.tsx create mode 100644 src/examples/group-anchor-hover-offset.fixture.tsx diff --git a/src/components/GroupAnchorOffsetOverlay.tsx b/src/components/GroupAnchorOffsetOverlay.tsx new file mode 100644 index 00000000..28f04eef --- /dev/null +++ b/src/components/GroupAnchorOffsetOverlay.tsx @@ -0,0 +1,294 @@ +import { getBoundsFromPoints } from "@tscircuit/math-utils" +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" + +// Constants for visual styling and thresholds +const VISUAL_CONFIG = { + GROUP_PADDING: 1, // mm - padding around group bounds (matches PcbGroupOverlay) + MIN_LINE_LENGTH_FOR_LABEL: 40, // px - minimum line length to show labels + LABEL_OFFSET_ABOVE: 2, // px - label offset when positioned above line + LABEL_OFFSET_BELOW: -18, // px - label offset when positioned below line + LABEL_OFFSET_RIGHT: 8, // px - label offset when positioned to the right + LABEL_OFFSET_LEFT: -80, // px - label offset when positioned to the left + LINE_STROKE_WIDTH: 1.5, + LINE_DASH_PATTERN: "4,4", + COMPONENT_MARKER_RADIUS: 3, + LABEL_FONT_SIZE: 11, +} as const + +const COLORS = { + OFFSET_LINE: "white", + COMPONENT_MARKER_FILL: "#66ccff", + COMPONENT_MARKER_STROKE: "white", + LABEL_TEXT: "white", +} as const + +interface Props { + elements: AnyCircuitElement[] + highlightedPrimitives: HighlightedPrimitive[] + transform: Matrix + containerWidth: number + containerHeight: number +} + +interface Point { + x: number + y: number +} + +/** + * Calculates the bounding box for all components within a PCB group + */ +const calculateGroupBoundingBox = ( + groupComponents: PcbComponent[], +): BoundingBox | null => { + const points: Point[] = [] + + 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) +} + +/** + * Finds the anchor marker position at the nearest edge of the group boundary. + * The anchor marker ("+") is displayed at the group edge closest to the logical anchor point. + */ +const findAnchorMarkerPosition = ( + anchor: Point, + bounds: BoundingBox, +): Point => { + 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) + + // Position at the nearest edge + 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 } +} + +/** + * 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, +}: Props) => { + const is_showing_pcb_groups = useGlobalStore((s) => s.is_showing_pcb_groups) + + // Early returns for cases where overlay should not be shown + if (!is_showing_pcb_groups || highlightedPrimitives.length === 0) { + return null + } + + // Find the hovered component + 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 + + // Find the parent group and verify it has an anchor + 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 + + // Get target position (pad center or component center) + const targetCenter: Point = + hoveredPrimitive._element?.type === "pcb_smtpad" + ? { x: hoveredPrimitive.x, y: hoveredPrimitive.y } + : pcbComponent.center || { + x: hoveredPrimitive.x, + y: hoveredPrimitive.y, + } + + // Calculate group bounding box with padding + 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 + + // Apply padding to bounding box + 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, + } + + // Find where the anchor marker is visually displayed + const anchorMarkerPosition = findAnchorMarkerPosition( + parentGroup.anchor_position, + groupBounds, + ) + + // Calculate offsets from the visual anchor marker position + // This ensures displayed values match the drawn lines + const offsetX = targetCenter.x - anchorMarkerPosition.x + const offsetY = targetCenter.y - anchorMarkerPosition.y + + // Convert to screen coordinates + const anchorMarkerScreen = applyToPoint(transform, anchorMarkerPosition) + const componentScreen = applyToPoint(transform, targetCenter) + + // Calculate line lengths and label positioning + 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 + + // Only show labels if lines are long enough to avoid clutter + const shouldShowXLabel = xLineLength > VISUAL_CONFIG.MIN_LINE_LENGTH_FOR_LABEL + const shouldShowYLabel = yLineLength > VISUAL_CONFIG.MIN_LINE_LENGTH_FOR_LABEL + + // Common label styles + const labelStyle: React.CSSProperties = { + color: COLORS.LABEL_TEXT, + mixBlendMode: "difference", + pointerEvents: "none", + fontSize: VISUAL_CONFIG.LABEL_FONT_SIZE, + fontFamily: "monospace", + fontWeight: "bold", + } + + return ( +
+ + {/* Horizontal offset line (X-axis) */} + + + {/* Vertical offset line (Y-axis) */} + + + {/* Component center marker */} + + + + {/* X-axis offset label */} + {shouldShowXLabel && ( +
+ Δx: {offsetX.toFixed(2)}mm +
+ )} + + {/* Y-axis offset label */} + {shouldShowYLabel && ( +
+ Δy: {offsetY.toFixed(2)}mm +
+ )} +
+ ) +} diff --git a/src/components/MouseElementTracker.tsx b/src/components/MouseElementTracker.tsx index 6a4ab07d..3f37c919 100644 --- a/src/components/MouseElementTracker.tsx +++ b/src/components/MouseElementTracker.tsx @@ -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 }>, @@ -175,6 +177,8 @@ export const MouseElementTracker = ({ }) => { const [mousedPrimitives, setMousedPrimitives] = useState([]) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + const [containerRef, { width, height }] = useMeasure() + const highlightedPrimitives = useMemo(() => { const highlightedPrimitives: HighlightedPrimitive[] = [] for (const primitive of mousedPrimitives) { @@ -281,7 +285,8 @@ export const MouseElementTracker = ({ return (
{ if (transform) { const rect = e.currentTarget.getBoundingClientRect() @@ -306,6 +311,15 @@ export const MouseElementTracker = ({ mousePos={mousePos} highlightedPrimitives={highlightedPrimitives} /> + {transform && ( + + )}
) } diff --git a/src/examples/group-anchor-hover-offset.fixture.tsx b/src/examples/group-anchor-hover-offset.fixture.tsx new file mode 100644 index 00000000..bf239b51 --- /dev/null +++ b/src/examples/group-anchor-hover-offset.fixture.tsx @@ -0,0 +1,305 @@ +import type React from "react" +import { PCBViewer } from "../PCBViewer" + +export const GroupAnchorHoverOffset: React.FC = () => { + return ( +
+ +
+ ) +} + +export default GroupAnchorHoverOffset From ac8ab86f62900712e18062178b89e5b06ba4e62d Mon Sep 17 00:00:00 2001 From: rushabhcodes Date: Wed, 19 Nov 2025 17:00:39 +0530 Subject: [PATCH 2/3] Feat: Add Group Anchor Offset Overlay and related functionality --- bun.lock | 5 ++++- package.json | 1 + src/components/GroupAnchorOffsetOverlay.tsx | 8 ++++++-- src/components/MouseElementTracker.tsx | 2 +- src/components/ToolbarOverlay.tsx | 20 +++++++++++++------- src/global-store.ts | 13 +++++++++++++ src/hooks/useLocalStorage.ts | 1 + 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index 3bc246f1..ac0434d5 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 2c6e3245..df93463a 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.tsx b/src/components/GroupAnchorOffsetOverlay.tsx index 28f04eef..6aa40372 100644 --- a/src/components/GroupAnchorOffsetOverlay.tsx +++ b/src/components/GroupAnchorOffsetOverlay.tsx @@ -34,6 +34,7 @@ interface Props { transform: Matrix containerWidth: number containerHeight: number + children?: any } interface Point { @@ -102,11 +103,14 @@ export const GroupAnchorOffsetOverlay = ({ transform, containerWidth, containerHeight, + children, }: Props) => { - const is_showing_pcb_groups = useGlobalStore((s) => s.is_showing_pcb_groups) + const is_showing_group_anchor_offsets = useGlobalStore( + (s) => s.is_showing_group_anchor_offsets, + ) // Early returns for cases where overlay should not be shown - if (!is_showing_pcb_groups || highlightedPrimitives.length === 0) { + if (!is_showing_group_anchor_offsets || highlightedPrimitives.length === 0) { return null } diff --git a/src/components/MouseElementTracker.tsx b/src/components/MouseElementTracker.tsx index 3f37c919..6be044da 100644 --- a/src/components/MouseElementTracker.tsx +++ b/src/components/MouseElementTracker.tsx @@ -170,7 +170,7 @@ export const MouseElementTracker = ({ onMouseHoverOverPrimitives, }: { elements: AnyCircuitElement[] - children: any + children: React.ReactNode transform?: Matrix primitives: Primitive[] onMouseHoverOverPrimitives: (primitivesHoveredOver: Primitive[]) => void diff --git a/src/components/ToolbarOverlay.tsx b/src/components/ToolbarOverlay.tsx index 4b6a2141..f642f9fd 100644 --- a/src/components/ToolbarOverlay.tsx +++ b/src/components/ToolbarOverlay.tsx @@ -1,10 +1,4 @@ -import React, { - Fragment, - useEffect, - useState, - useCallback, - useRef, -} from "react" +import { useEffect, useState, useCallback, useRef } from "react" import { css } from "@emotion/css" import { type LayerRef, type PcbTraceError, all_layers } from "circuit-json" import type { AnyCircuitElement } from "circuit-json" @@ -200,6 +194,7 @@ export const ToolbarOverlay = ({ children, elements }: Props) => { setIsShowingDrcErrors, setIsShowingCopperPours, setIsShowingPcbGroups, + setIsShowingGroupAnchorOffsets, setPcbGroupViewMode, setHoveredErrorId, } = useGlobalStore((s) => ({ @@ -218,6 +213,7 @@ export const ToolbarOverlay = ({ children, elements }: Props) => { is_showing_drc_errors: s.is_showing_drc_errors, is_showing_copper_pours: s.is_showing_copper_pours, is_showing_pcb_groups: s.is_showing_pcb_groups, + is_showing_group_anchor_offsets: s.is_showing_group_anchor_offsets, pcb_group_view_mode: s.pcb_group_view_mode, }, setEditMode: s.setEditMode, @@ -227,6 +223,7 @@ export const ToolbarOverlay = ({ children, elements }: Props) => { setIsShowingDrcErrors: s.setIsShowingDrcErrors, setIsShowingCopperPours: s.setIsShowingCopperPours, setIsShowingPcbGroups: s.setIsShowingPcbGroups, + setIsShowingGroupAnchorOffsets: s.setIsShowingGroupAnchorOffsets, setPcbGroupViewMode: s.setPcbGroupViewMode, setHoveredErrorId: s.setHoveredErrorId, })) @@ -716,6 +713,15 @@ export const ToolbarOverlay = ({ children, elements }: Props) => { setIsShowingPcbGroups(!viewSettings.is_showing_pcb_groups) }} /> + { + setIsShowingGroupAnchorOffsets( + !viewSettings.is_showing_group_anchor_offsets, + ) + }} + /> {viewSettings.is_showing_pcb_groups && (
void setIsShowingCopperPours: (is_showing: boolean) => void setIsShowingPcbGroups: (is_showing: boolean) => void + setIsShowingGroupAnchorOffsets: (is_showing: boolean) => void setPcbGroupViewMode: (mode: "all" | "named_only") => void setHoveredErrorId: (errorId: string | null) => void } @@ -88,6 +90,10 @@ export const createStore = ( is_showing_pcb_groups: disablePcbGroups ? false : getStoredBoolean(STORAGE_KEYS.IS_SHOWING_PCB_GROUPS, true), + is_showing_group_anchor_offsets: getStoredBoolean( + STORAGE_KEYS.IS_SHOWING_GROUP_ANCHOR_OFFSETS, + process.env.NODE_ENV !== "production", + ), pcb_group_view_mode: disablePcbGroups ? "all" : (getStoredString( @@ -130,6 +136,13 @@ export const createStore = ( setStoredBoolean(STORAGE_KEYS.IS_SHOWING_PCB_GROUPS, is_showing) set({ is_showing_pcb_groups: is_showing }) }, + setIsShowingGroupAnchorOffsets: (is_showing) => { + setStoredBoolean( + STORAGE_KEYS.IS_SHOWING_GROUP_ANCHOR_OFFSETS, + is_showing, + ) + set({ is_showing_group_anchor_offsets: is_showing }) + }, setPcbGroupViewMode: (mode) => { if (disablePcbGroups) return setStoredString(STORAGE_KEYS.PCB_GROUP_VIEW_MODE, mode) diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 5445883d..78cf7cd3 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -4,6 +4,7 @@ export const STORAGE_KEYS = { IS_SHOWING_PCB_GROUPS: "pcb_viewer_is_showing_pcb_groups", PCB_GROUP_VIEW_MODE: "pcb_viewer_group_view_mode", IS_SHOWING_COPPER_POURS: "pcb_viewer_is_showing_copper_pours", + IS_SHOWING_GROUP_ANCHOR_OFFSETS: "pcb_viewer_is_showing_group_anchor_offsets", } as const export const getStoredBoolean = ( From b3a346672fc7ef05333339ddd0c23edfd3dd25d2 Mon Sep 17 00:00:00 2001 From: rushabhcodes Date: Sun, 23 Nov 2025 04:12:17 +0530 Subject: [PATCH 3/3] Refactor: Remove GroupAnchorOffsetOverlay component and related files; reorganize ToolbarOverlay for better structure --- .../calculateGroupBoundingBox.ts | 27 +++++ .../GroupAnchorOffsetOverlay/constants.ts | 19 +++ .../findAnchorMarkerPosition.ts | 19 +++ .../index.tsx} | 112 ++---------------- src/components/ToolbarOverlay.tsx | 16 +-- 5 files changed, 85 insertions(+), 108 deletions(-) create mode 100644 src/components/GroupAnchorOffsetOverlay/calculateGroupBoundingBox.ts create mode 100644 src/components/GroupAnchorOffsetOverlay/constants.ts create mode 100644 src/components/GroupAnchorOffsetOverlay/findAnchorMarkerPosition.ts rename src/components/{GroupAnchorOffsetOverlay.tsx => GroupAnchorOffsetOverlay/index.tsx} (63%) diff --git a/src/components/GroupAnchorOffsetOverlay/calculateGroupBoundingBox.ts b/src/components/GroupAnchorOffsetOverlay/calculateGroupBoundingBox.ts new file mode 100644 index 00000000..581b5a1f --- /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 00000000..9b002771 --- /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 00000000..ea1d13bc --- /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.tsx b/src/components/GroupAnchorOffsetOverlay/index.tsx similarity index 63% rename from src/components/GroupAnchorOffsetOverlay.tsx rename to src/components/GroupAnchorOffsetOverlay/index.tsx index 6aa40372..955c12fb 100644 --- a/src/components/GroupAnchorOffsetOverlay.tsx +++ b/src/components/GroupAnchorOffsetOverlay/index.tsx @@ -1,32 +1,18 @@ -import { getBoundsFromPoints } from "@tscircuit/math-utils" 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" - -// Constants for visual styling and thresholds -const VISUAL_CONFIG = { - GROUP_PADDING: 1, // mm - padding around group bounds (matches PcbGroupOverlay) - MIN_LINE_LENGTH_FOR_LABEL: 40, // px - minimum line length to show labels - LABEL_OFFSET_ABOVE: 2, // px - label offset when positioned above line - LABEL_OFFSET_BELOW: -18, // px - label offset when positioned below line - LABEL_OFFSET_RIGHT: 8, // px - label offset when positioned to the right - LABEL_OFFSET_LEFT: -80, // px - label offset when positioned to the left - LINE_STROKE_WIDTH: 1.5, - LINE_DASH_PATTERN: "4,4", - COMPONENT_MARKER_RADIUS: 3, - LABEL_FONT_SIZE: 11, -} as const - -const COLORS = { - OFFSET_LINE: "white", - COMPONENT_MARKER_FILL: "#66ccff", - COMPONENT_MARKER_STROKE: "white", - LABEL_TEXT: "white", -} as const +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[] @@ -37,62 +23,6 @@ interface Props { children?: any } -interface Point { - x: number - y: number -} - -/** - * Calculates the bounding box for all components within a PCB group - */ -const calculateGroupBoundingBox = ( - groupComponents: PcbComponent[], -): BoundingBox | null => { - const points: Point[] = [] - - 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) -} - -/** - * Finds the anchor marker position at the nearest edge of the group boundary. - * The anchor marker ("+") is displayed at the group edge closest to the logical anchor point. - */ -const findAnchorMarkerPosition = ( - anchor: Point, - bounds: BoundingBox, -): Point => { - 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) - - // Position at the nearest edge - 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 } -} - /** * 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. @@ -109,12 +39,10 @@ export const GroupAnchorOffsetOverlay = ({ (s) => s.is_showing_group_anchor_offsets, ) - // Early returns for cases where overlay should not be shown if (!is_showing_group_anchor_offsets || highlightedPrimitives.length === 0) { return null } - // Find the hovered component const hoveredPrimitive = highlightedPrimitives.find( (p) => p._parent_pcb_component?.type === "pcb_component" || @@ -128,14 +56,12 @@ export const GroupAnchorOffsetOverlay = ({ if (!pcbComponent?.pcb_group_id) return null - // Find the parent group and verify it has an anchor 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 - // Get target position (pad center or component center) const targetCenter: Point = hoveredPrimitive._element?.type === "pcb_smtpad" ? { x: hoveredPrimitive.x, y: hoveredPrimitive.y } @@ -144,7 +70,6 @@ export const GroupAnchorOffsetOverlay = ({ y: hoveredPrimitive.y, } - // Calculate group bounding box with padding const groupComponents = elements .filter((el): el is PcbComponent => el.type === "pcb_component") .filter((comp) => comp.pcb_group_id === parentGroup.pcb_group_id) @@ -152,7 +77,6 @@ export const GroupAnchorOffsetOverlay = ({ const boundingBox = calculateGroupBoundingBox(groupComponents) if (!boundingBox) return null - // Apply padding to bounding box const groupBounds: BoundingBox = { minX: boundingBox.minX - VISUAL_CONFIG.GROUP_PADDING, maxX: boundingBox.maxX + VISUAL_CONFIG.GROUP_PADDING, @@ -160,22 +84,17 @@ export const GroupAnchorOffsetOverlay = ({ maxY: boundingBox.maxY + VISUAL_CONFIG.GROUP_PADDING, } - // Find where the anchor marker is visually displayed const anchorMarkerPosition = findAnchorMarkerPosition( parentGroup.anchor_position, groupBounds, ) - // Calculate offsets from the visual anchor marker position - // This ensures displayed values match the drawn lines const offsetX = targetCenter.x - anchorMarkerPosition.x const offsetY = targetCenter.y - anchorMarkerPosition.y - // Convert to screen coordinates const anchorMarkerScreen = applyToPoint(transform, anchorMarkerPosition) const componentScreen = applyToPoint(transform, targetCenter) - // Calculate line lengths and label positioning const xLineLength = Math.abs(componentScreen.x - anchorMarkerScreen.x) const yLineLength = Math.abs(componentScreen.y - anchorMarkerScreen.y) @@ -190,11 +109,9 @@ export const GroupAnchorOffsetOverlay = ({ ? VISUAL_CONFIG.LABEL_OFFSET_RIGHT : VISUAL_CONFIG.LABEL_OFFSET_LEFT - // Only show labels if lines are long enough to avoid clutter const shouldShowXLabel = xLineLength > VISUAL_CONFIG.MIN_LINE_LENGTH_FOR_LABEL const shouldShowYLabel = yLineLength > VISUAL_CONFIG.MIN_LINE_LENGTH_FOR_LABEL - // Common label styles const labelStyle: React.CSSProperties = { color: COLORS.LABEL_TEXT, mixBlendMode: "difference", @@ -227,7 +144,6 @@ export const GroupAnchorOffsetOverlay = ({ width={containerWidth} height={containerHeight} > - {/* Horizontal offset line (X-axis) */} - {/* Vertical offset line (Y-axis) */} - {/* Component center marker */} - {/* X-axis offset label */} {shouldShowXLabel && (
)} - {/* Y-axis offset label */} {shouldShowYLabel && (
{ ) }} /> - { - setIsShowingPcbGroups(!viewSettings.is_showing_pcb_groups) - }} - /> - { @@ -722,6 +715,13 @@ export const ToolbarOverlay = ({ children, elements }: Props) => { ) }} /> + { + setIsShowingPcbGroups(!viewSettings.is_showing_pcb_groups) + }} + /> {viewSettings.is_showing_pcb_groups && (