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 ( +
+ + + + + + + + + {shouldShowXLabel && ( +
+ Δx: {offsetX.toFixed(2)}mm +
+ )} + + {shouldShowYLabel && ( +
+ Δy: {offsetY.toFixed(2)}mm +
+ )} +
+ ) +} diff --git a/src/components/MouseElementTracker.tsx b/src/components/MouseElementTracker.tsx index 6a4ab07..6be044d 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 }>, @@ -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([]) 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/components/ToolbarOverlay.tsx b/src/components/ToolbarOverlay.tsx index 4b6a214..10db5de 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, })) @@ -708,6 +705,15 @@ export const ToolbarOverlay = ({ children, elements }: Props) => { !viewSettings.is_showing_copper_pours, ) }} + /> + { + setIsShowingGroupAnchorOffsets( + !viewSettings.is_showing_group_anchor_offsets, + ) + }} /> { + return ( +
+ +
+ ) +} + +export default GroupAnchorHoverOffset diff --git a/src/global-store.ts b/src/global-store.ts index 8329a08..11ae0df 100644 --- a/src/global-store.ts +++ b/src/global-store.ts @@ -32,6 +32,7 @@ export interface State { is_showing_rats_nest: boolean is_showing_copper_pours: boolean is_showing_pcb_groups: boolean + is_showing_group_anchor_offsets: boolean pcb_group_view_mode: "all" | "named_only" hovered_error_id: string | null @@ -47,6 +48,7 @@ export interface State { setIsShowingDrcErrors: (is_showing: boolean) => 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 5445883..78cf7cd 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 = (