From 9cc37d20251bcc1faf596bba73d13d946beb49f2 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 24 Jun 2026 14:00:13 -0600 Subject: [PATCH 1/2] draft 1 --- apps/probe-viewer/src/App.css | 82 ++++ .../src/components/DoubleSidedProbeCanvas.tsx | 216 ++++++++++ .../src/components/ProbeCanvas.tsx | 395 +----------------- .../src/components/ProbeViewer.tsx | 93 ++++- apps/probe-viewer/src/geometry/draw.ts | 107 +++++ apps/probe-viewer/src/geometry/sides.ts | 135 ++++++ .../src/hooks/useProbeViewport.ts | 317 ++++++++++++++ apps/probe-viewer/src/state/useAppStore.ts | 42 ++ apps/probe-viewer/src/types/probe.ts | 6 +- 9 files changed, 1010 insertions(+), 383 deletions(-) create mode 100644 apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx create mode 100644 apps/probe-viewer/src/geometry/draw.ts create mode 100644 apps/probe-viewer/src/geometry/sides.ts create mode 100644 apps/probe-viewer/src/hooks/useProbeViewport.ts diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index ff5e061..482d3e7 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -330,6 +330,88 @@ color: #1e293b; } +/* Double-sided probe layout controls */ +.viewer-controls-sides { + align-items: center; + gap: 0.6rem; +} + +.viewer-controls-label { + font-size: 0.9rem; + font-weight: 600; + color: #1e293b; +} + +/* Segmented button group: adjacent buttons read as one control, the active + segment highlighted. */ +.viewer-segmented { + display: inline-flex; + gap: 0; +} + +.viewer-controls .viewer-segmented button { + border-radius: 0; + text-transform: capitalize; +} + +.viewer-controls .viewer-segmented button:first-child { + border-top-left-radius: 0.75rem; + border-bottom-left-radius: 0.75rem; +} + +.viewer-controls .viewer-segmented button:last-child { + border-top-right-radius: 0.75rem; + border-bottom-right-radius: 0.75rem; +} + +.viewer-controls .viewer-segmented button:not(:first-child) { + border-left: none; +} + +.viewer-controls .viewer-segmented button.is-active { + background: #2563eb; + color: #fff; +} + +/* Overlay opacity / offset sliders. */ +.viewer-slider { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: #1e293b; +} + +.viewer-slider span { + text-transform: capitalize; + white-space: nowrap; +} + +.viewer-slider input[type="range"] { + width: 6rem; +} + +.viewer-slider-value { + min-width: 3.2rem; + font-variant-numeric: tabular-nums; +} + +/* Color key matching the canvas: gold front, steel-blue back. */ +.viewer-side-swatch { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + display: inline-block; +} + +.viewer-side-swatch--front { + background: rgb(212, 175, 55); +} + +.viewer-side-swatch--back { + background: rgb(70, 130, 180); +} + .viewer-canvas { position: relative; min-height: 360px; diff --git a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx new file mode 100644 index 0000000..40a4d4b --- /dev/null +++ b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { useResizeObserver } from "../hooks/useResizeObserver"; +import { useProbeViewport } from "../hooks/useProbeViewport"; +import { CONTACT_COLORS, drawContactShape, renderScaleBar } from "../geometry/draw"; +import { buildSideRenderPlan, type SideRenderPlan } from "../geometry/sides"; +import type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; + +interface DoubleSidedProbeCanvasProps { + entry: ManifestEntry; + probeData: ProbeInterfaceFile; + camera: ProbeViewerCamera; + showContactIds: boolean; + showScaleBar: boolean; + prominentSide: string | null; + // Independent opacity (0–1) per side; a missing side defaults to 1. + sideOpacity: Record; + // Separation between faces, in probe units (µm). + offsetUm: number; + onViewCenterChange: (x: number | null, y: number | null) => void; + onZoom: (zoom: number) => void; +} + +interface GeometrySummary { + width: number; + height: number; + centerX: number; + centerY: number; +} + +// Bounds over the laid-out contacts and contours. The back face is displaced in +// probe coordinates, so framing must include it; computing from the plan (rather +// than the raw positions) accounts for the offset automatically. +function computeGeometryFromPlan(plan: SideRenderPlan): GeometrySummary | null { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + const update = (x: number, y: number) => { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + }; + + plan.contacts.forEach((contact) => update(contact.x, contact.y)); + plan.contours.forEach((contour) => contour.points.forEach((p) => update(p[0], p[1]))); + + if (!Number.isFinite(minX)) return null; + + const width = Math.max(10, maxX - minX); + const height = Math.max(10, maxY - minY); + return { width, height, centerX: minX + width / 2, centerY: minY + height / 2 }; +} + +function colorForSide(side: string | null) { + return side === "back" ? CONTACT_COLORS.back : CONTACT_COLORS.front; +} + +export function DoubleSidedProbeCanvas({ + entry, + probeData, + camera, + showContactIds, + showScaleBar, + prominentSide, + sideOpacity, + offsetUm, + onViewCenterChange, + onZoom, +}: DoubleSidedProbeCanvasProps) { + const { zoom, centerX, centerY } = camera; + const { ref: containerRef, size } = useResizeObserver(); + const lastCanvasSizeRef = useRef({ w: 0, h: 0, dpr: 0 }); + + const probe = probeData.probes?.[0]; + const plan = useMemo( + () => (probe ? buildSideRenderPlan(probe, prominentSide, offsetUm) : null), + [probe, prominentSide, offsetUm], + ); + const geometry = useMemo(() => (plan ? computeGeometryFromPlan(plan) : null), [plan]); + + const { + canvasRef, + getProjection, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleDoubleClick, + } = useProbeViewport({ geometry, camera, size, onViewCenterChange, onZoom }); + + useEffect(() => { + if (!canvasRef.current || !size.width || !size.height || !geometry || !probe || !plan) { + return; + } + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const projection = getProjection(); + if (!projection) return; + const { scale, projectPoint } = projection; + + const devicePixelRatio = window.devicePixelRatio || 1; + const widthPx = size.width; + const heightPx = size.height; + const targetW = Math.round(widthPx * devicePixelRatio); + const targetH = Math.round(heightPx * devicePixelRatio); + const lastSize = lastCanvasSizeRef.current; + if (lastSize.w !== targetW || lastSize.h !== targetH || lastSize.dpr !== devicePixelRatio) { + canvas.width = targetW; + canvas.height = targetH; + canvas.style.width = `${widthPx}px`; + canvas.style.height = `${heightPx}px`; + lastCanvasSizeRef.current = { w: targetW, h: targetH, dpr: devicePixelRatio }; + } + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + ctx.clearRect(0, 0, widthPx, heightPx); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + const contactShapes = probe.contact_shapes ?? []; + const contactShapeParams = probe.contact_shape_params ?? []; + + const drawContour = (points: number[][]) => { + ctx.beginPath(); + points.forEach((point, index) => { + const [x, y] = projectPoint(point); + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.fillStyle = "rgba(180, 185, 195, 0.7)"; + ctx.strokeStyle = "rgba(100, 105, 115, 0.95)"; + ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 100)); + ctx.fill(); + ctx.stroke(); + }; + + // Draw each face (shank outline + its contacts) as a unit, the prominent face + // last (on top) with adjustable opacity. The whole back face is already + // displaced in the plan, so the outline and contacts shift together. + const facesInOrder = [...plan.info.sides].sort( + (a, b) => Number(a === prominentSide) - Number(b === prominentSide), + ); + + facesInOrder.forEach((side) => { + const alpha = sideOpacity[side] ?? 1; + if (alpha === 0) return; + ctx.globalAlpha = alpha; + + const contour = plan.contours.find((c) => c.side === side); + if (contour) drawContour(contour.points); + + const colors = colorForSide(side); + ctx.fillStyle = colors.fill; + ctx.strokeStyle = colors.stroke; + ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150)); + plan.contacts.forEach((contact) => { + if (contact.side !== side) return; + const [px, py] = projectPoint([contact.x, contact.y]); + const shape = contactShapes[contact.index] ?? ""; + const params = contactShapeParams[contact.index] ?? {}; + drawContactShape(ctx, px, py, shape, params, scale); + ctx.fill(); + ctx.stroke(); + }); + + ctx.globalAlpha = 1; + }); + + // Contact IDs for the prominent (focused) face only; the two faces sit close + // together, so showing both sets would overlap. + if (showContactIds && probe.contact_ids) { + const contactIds = probe.contact_ids; + ctx.font = `${Math.max(10, Math.min(14, 10 * (scale / 100)))}px "Inter", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(15, 23, 42, 0.95)"; + plan.contacts.forEach((contact) => { + if (contact.side !== prominentSide) return; + const [px, py] = projectPoint([contact.x, contact.y]); + ctx.fillText(String(contactIds[contact.index] ?? contact.index), px, py + 4); + }); + } + + if (showScaleBar) { + renderScaleBar(ctx, scale, heightPx); + } + }, [canvasRef, entry.id, geometry, getProjection, plan, prominentSide, probe, showContactIds, showScaleBar, size.height, size.width, zoom, centerX, centerY, sideOpacity]); + + return ( +
+ {geometry && probe ? ( + + ) : ( +
+

No planar geometry available for this probe.

+
+ )} +
+ ); +} diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index 8059139..92d1730 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -1,21 +1,9 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; -import type { - MouseEvent as ReactMouseEvent, - PointerEvent as ReactPointerEvent, -} from "react"; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; -import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; +import { useProbeViewport } from "../hooks/useProbeViewport"; +import { drawContactShape, renderScaleBar } from "../geometry/draw"; import type { - ContactShapeParams, ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera, @@ -91,28 +79,26 @@ export const ProbeCanvas = forwardRef( ref ) { const { zoom, centerX, centerY } = camera; - const canvasRef = useRef(null); - - // Expose canvas to parent for export - useImperativeHandle(ref, () => canvasRef.current!, []); const { ref: containerRef, size } = useResizeObserver(); - const [isDragging, setIsDragging] = useState(false); - const dragOriginRef = useRef<{ x: number; y: number; viewCenterX: number; viewCenterY: number } | null>(null); // Track the last applied canvas backing-store size so we only reallocate (an // expensive clear + realloc of the whole pixel buffer) when the size or // device-pixel-ratio actually changes, not on every pan/zoom redraw. const lastCanvasSizeRef = useRef({ w: 0, h: 0, dpr: 0 }); - // Coalesce pan updates to one per animation frame: pointermove fires far more - // often than the screen repaints, so we keep only the latest target. - const panRafRef = useRef(0); - const pendingViewCenterRef = useRef<{ x: number; y: number } | null>(null); const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]); const probe = useMemo(() => probeData.probes?.[0], [probeData]); - // Calculate effective view center (use geometry center if null) - const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0; - const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0; + const { + canvasRef, + getProjection, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleDoubleClick, + } = useProbeViewport({ geometry, camera, size, onViewCenterChange, onZoom }); + + // Expose canvas to parent for export + useImperativeHandle(ref, () => canvasRef.current!, [canvasRef]); useEffect(() => { if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) { @@ -125,6 +111,10 @@ export const ProbeCanvas = forwardRef( return; } + const projection = getProjection(); + if (!projection) return; + const { scale, projectPoint } = projection; + const devicePixelRatio = window.devicePixelRatio || 1; const widthPx = size.width; const heightPx = size.height; @@ -146,29 +136,6 @@ export const ProbeCanvas = forwardRef( ctx.clearRect(0, 0, widthPx, heightPx); - const padding = 40; - const availableWidth = Math.max(10, widthPx - padding * 2); - const availableHeight = Math.max(10, heightPx - padding * 2); - const baseScale = Math.min( - availableWidth / geometry.width, - availableHeight / geometry.height, - ); - const scale = baseScale * zoom; - - // Calculate pixel pan from view center in probe coordinates - const panX = (geometry.centerX - effectiveViewCenterX) * scale; - const panY = (effectiveViewCenterY - geometry.centerY) * scale; - - const offsetX = widthPx / 2 + panX; - const offsetY = heightPx / 2 + panY; - - const projectPoint = (point: number[]) => { - const [x, y] = point; - const normX = (x - geometry.centerX) * scale + offsetX; - const normY = -(y - geometry.centerY) * scale + offsetY; - return [normX, normY]; - }; - ctx.lineCap = "round"; ctx.lineJoin = "round"; @@ -194,46 +161,6 @@ export const ProbeCanvas = forwardRef( const contactShapes = probe.contact_shapes ?? []; const contactShapeParams = probe.contact_shape_params ?? []; - // Helper to draw a contact shape - const drawContactShape = ( - x: number, - y: number, - shape: string, - params: ContactShapeParams, - ) => { - ctx.beginPath(); - switch (shape) { - case "circle": { - const radius = (params.radius ?? 5) * scale; - ctx.arc(x, y, radius, 0, Math.PI * 2); - break; - } - case "square": { - const side = (params.width ?? 10) * scale; - ctx.rect(x - side / 2, y - side / 2, side, side); - break; - } - case "rect": { - const w = (params.width ?? 10) * scale; - const h = (params.height ?? 15) * scale; - ctx.rect(x - w / 2, y - h / 2, w, h); - break; - } - default: { - // Unknown/missing shape: draw a dot with X to indicate missing data - const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100))); - // Draw small circle - ctx.arc(x, y, markerSize * 0.4, 0, Math.PI * 2); - ctx.closePath(); - // Draw X through the center - ctx.moveTo(x - markerSize, y - markerSize); - ctx.lineTo(x + markerSize, y + markerSize); - ctx.moveTo(x + markerSize, y - markerSize); - ctx.lineTo(x - markerSize, y + markerSize); - } - } - }; - // Shadow offset for depth effect - subtle, proportional to scale const shadowOffset = 0.4 * scale; // 0.4 micrometer offset for subtle depth @@ -243,7 +170,7 @@ export const ProbeCanvas = forwardRef( const shape = contactShapes[index] ?? ""; const params = contactShapeParams[index] ?? {}; - drawContactShape(x + shadowOffset, y + shadowOffset, shape, params); + drawContactShape(ctx, x + shadowOffset, y + shadowOffset, shape, params, scale); ctx.fillStyle = "rgba(30, 20, 5, 0.7)"; // Even darker and more opaque ctx.fill(); }); @@ -254,7 +181,7 @@ export const ProbeCanvas = forwardRef( const shape = contactShapes[index] ?? ""; const params = contactShapeParams[index] ?? {}; - drawContactShape(x, y, shape, params); + drawContactShape(ctx, x, y, shape, params, scale); ctx.fillStyle = "rgba(212, 175, 55, 1.0)"; // Gold contacts - fully opaque to cover shadow ctx.strokeStyle = "rgba(80, 60, 15, 0.9)"; // Dark bronze outline @@ -276,288 +203,10 @@ export const ProbeCanvas = forwardRef( }); } - // === L-Shaped Scale Bar === - // Renders a scale bar in the bottom-left corner showing reference lengths - // for both X and Y dimensions. The length adapts to zoom level using "nice" numbers. - const renderScaleBar = () => { - // Calculate adaptive scale bar length using "nice" numbers - const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]; - const targetPixels = 80; // Target bar length in pixels - const targetUm = targetPixels / scale; - const scaleBarUm = niceNumbers.reduce((prev, curr) => - Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev - ); - const scaleBarPixels = scaleBarUm * scale; - - // Position: bottom-left corner - const margin = 20; - const cornerX = margin; - const cornerY = heightPx - margin; - const tickSize = 4; - - // Style for L shape - ctx.strokeStyle = "rgba(15, 23, 42, 0.9)"; - ctx.lineWidth = 2; - ctx.lineCap = "square"; - - // Draw L shape - ctx.beginPath(); - // Vertical arm (Y) - goes up from corner - ctx.moveTo(cornerX, cornerY); - ctx.lineTo(cornerX, cornerY - scaleBarPixels); - // Horizontal arm (X) - goes right from corner - ctx.moveTo(cornerX, cornerY); - ctx.lineTo(cornerX + scaleBarPixels, cornerY); - ctx.stroke(); - - // End ticks - ctx.beginPath(); - // Top of vertical arm - ctx.moveTo(cornerX - tickSize, cornerY - scaleBarPixels); - ctx.lineTo(cornerX + tickSize, cornerY - scaleBarPixels); - // Right of horizontal arm - ctx.moveTo(cornerX + scaleBarPixels, cornerY - tickSize); - ctx.lineTo(cornerX + scaleBarPixels, cornerY + tickSize); - ctx.stroke(); - - // Labels - const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`; - ctx.font = '11px "Inter", sans-serif'; - ctx.fillStyle = "rgba(15, 23, 42, 0.9)"; - - // X label (below horizontal arm) - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - ctx.fillText(label, cornerX + scaleBarPixels / 2, cornerY + 5); - - // Y label (rotated, to the left of vertical arm) - ctx.save(); - ctx.translate(cornerX - 6, cornerY - scaleBarPixels / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, 0, 0); - ctx.restore(); - }; - if (showScaleBar) { - renderScaleBar(); - } - }, [entry.id, effectiveViewCenterX, effectiveViewCenterY, geometry, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]); - - const clampZoom = useCallback( - (value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)), - [], - ); - - // Helper to calculate scale (needed for coordinate conversion in handlers) - const getScale = useCallback(() => { - if (!size.width || !size.height || !geometry) return 1; - const padding = 40; - const availableWidth = Math.max(10, size.width - padding * 2); - const availableHeight = Math.max(10, size.height - padding * 2); - const baseScale = Math.min( - availableWidth / geometry.width, - availableHeight / geometry.height, - ); - return baseScale * zoom; - }, [geometry, size.width, size.height, zoom]); - - // Wheel-to-zoom is attached as a NATIVE, non-passive listener (not React's - // onWheel) so preventDefault() actually stops the page from scrolling. React - // registers wheel handlers as passive by default, which ignores preventDefault() - // and lets the page scroll while we zoom. The listener lives only on the canvas. - // Live values are read through a ref so the listener does not re-subscribe on - // every zoom/pan change; it only re-attaches when the canvas itself changes. - const wheelStateRef = useRef({ - zoom, - effectiveViewCenterX, - effectiveViewCenterY, - geometry, - getScale, - clampZoom, - onViewCenterChange, - onZoom, - }); - wheelStateRef.current = { - zoom, - effectiveViewCenterX, - effectiveViewCenterY, - geometry, - getScale, - clampZoom, - onViewCenterChange, - onZoom, - }; - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const handleWheel = (event: WheelEvent) => { - event.preventDefault(); - const { - zoom, - effectiveViewCenterX, - effectiveViewCenterY, - geometry, - getScale, - clampZoom, - onViewCenterChange, - onZoom, - } = wheelStateRef.current; - if (!geometry) return; - - // Normalize wheel units so zoom speed is consistent across devices: mouse - // wheels often report "line" deltas, trackpads report pixels. - const unit = - event.deltaMode === 1 - ? 16 // lines -> ~16px - : event.deltaMode === 2 - ? canvas.clientHeight // pages -> viewport height - : 1; // already pixels - // Holding Shift moves the scroll onto the horizontal axis on most systems. - const delta = (event.deltaY || event.deltaX) * unit; - - const rect = canvas.getBoundingClientRect(); - const offsetFromCenterX = event.clientX - rect.left - rect.width / 2; - const offsetFromCenterY = event.clientY - rect.top - rect.height / 2; - - const scale = getScale(); - const panX = (geometry.centerX - effectiveViewCenterX) * scale; - const panY = (effectiveViewCenterY - geometry.centerY) * scale; - - const zoomFactor = Math.exp(-delta * 0.002); - const nextZoom = clampZoom(zoom * zoomFactor); - const actualZoomFactor = nextZoom / zoom; - - // Keep the point under the cursor fixed. The (1 - factor) sign anchors the - // zoom at the cursor; (factor - 1) would anchor at the cursor's mirror across - // the center, which is what made zoom feel like it pulled toward the middle. - const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor); - const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor); - - // Convert back to probe coordinates. - const newScale = scale * actualZoomFactor; - const newViewCenterX = geometry.centerX - newPanX / newScale; - const newViewCenterY = geometry.centerY + newPanY / newScale; - - onViewCenterChange(newViewCenterX, newViewCenterY); - onZoom(nextZoom); - }; - - canvas.addEventListener("wheel", handleWheel, { passive: false }); - return () => canvas.removeEventListener("wheel", handleWheel); - }, [geometry, probe]); - - const handlePointerDown = useCallback((event: ReactPointerEvent) => { - event.preventDefault(); - setIsDragging(true); - dragOriginRef.current = { - x: event.clientX, - y: event.clientY, - viewCenterX: effectiveViewCenterX, - viewCenterY: effectiveViewCenterY, - }; - (event.target as HTMLCanvasElement).setPointerCapture(event.pointerId); - }, [effectiveViewCenterX, effectiveViewCenterY]); - - const handlePointerMove = useCallback((event: ReactPointerEvent) => { - if (!isDragging || !dragOriginRef.current) { - return; + renderScaleBar(ctx, scale, heightPx); } - event.preventDefault(); - const deltaX = event.clientX - dragOriginRef.current.x; - const deltaY = event.clientY - dragOriginRef.current.y; - - // Convert pixel delta to probe coordinate delta, but only apply one update - // per animation frame so a flood of pointermove events collapses into a - // single redraw. - const scale = getScale(); - pendingViewCenterRef.current = { - x: dragOriginRef.current.viewCenterX - deltaX / scale, - y: dragOriginRef.current.viewCenterY + deltaY / scale, - }; - if (!panRafRef.current) { - panRafRef.current = requestAnimationFrame(() => { - panRafRef.current = 0; - const pending = pendingViewCenterRef.current; - if (pending) onViewCenterChange(pending.x, pending.y); - }); - } - }, [getScale, isDragging, onViewCenterChange]); - - const handlePointerUp = useCallback((event: ReactPointerEvent) => { - if (isDragging) { - event.preventDefault(); - // Flush any pending coalesced pan so the final position is exact. - if (panRafRef.current) { - cancelAnimationFrame(panRafRef.current); - panRafRef.current = 0; - } - const pending = pendingViewCenterRef.current; - if (pending) { - onViewCenterChange(pending.x, pending.y); - pendingViewCenterRef.current = null; - } - setIsDragging(false); - dragOriginRef.current = null; - (event.target as HTMLCanvasElement).releasePointerCapture(event.pointerId); - } - }, [isDragging, onViewCenterChange]); - - // Cancel any pending pan frame on unmount. - useEffect(() => { - return () => { - if (panRafRef.current) cancelAnimationFrame(panRafRef.current); - }; - }, []); - - const handleDoubleClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - if (!geometry) return; - - // Get click position relative to canvas - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; - - // Canvas center - const canvasCenterX = rect.width / 2; - const canvasCenterY = rect.height / 2; - - // Mouse offset from center - const offsetFromCenterX = mouseX - canvasCenterX; - const offsetFromCenterY = mouseY - canvasCenterY; - - // Calculate scale and pan in pixels - const scale = getScale(); - const panX = (geometry.centerX - effectiveViewCenterX) * scale; - const panY = (effectiveViewCenterY - geometry.centerY) * scale; - - // Calculate new zoom - const zoomFactor = event.shiftKey ? 1 / 1.5 : 1.5; - const nextZoom = clampZoom(zoom * zoomFactor); - const actualZoomFactor = nextZoom / zoom; - - // Adjust pan so the clicked point stays fixed (see wheel handler note on - // the (1 - factor) sign that anchors at the cursor rather than its mirror). - const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor); - const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor); - - // Convert back to probe coordinates - const newScale = scale * actualZoomFactor; - const newViewCenterX = geometry.centerX - newPanX / newScale; - const newViewCenterY = geometry.centerY + newPanY / newScale; - - onViewCenterChange(newViewCenterX, newViewCenterY); - onZoom(nextZoom); - }, - [clampZoom, effectiveViewCenterX, effectiveViewCenterY, geometry, getScale, onViewCenterChange, onZoom, zoom], - ); + }, [canvasRef, entry.id, geometry, getProjection, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom, centerX, centerY]); return (
diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index ae11786..02aa792 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -3,7 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useAppStore, VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; import { exportProbeAsPng, exportProbeAsSvg } from "../utils/exportUtils"; +import { getSideInfo, resolveProminentSide } from "../geometry/sides"; import { ProbeCanvas } from "./ProbeCanvas"; +import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas"; import { ProbeOverview } from "./ProbeOverview"; const ZoomInIcon = ( @@ -62,6 +64,9 @@ export function ProbeViewer() { const toggleContactIds = useAppStore((state) => state.toggleContactIds); const toggleScaleBar = useAppStore((state) => state.toggleScaleBar); const toggleOverview = useAppStore((state) => state.toggleOverview); + const setProminentSide = useAppStore((state) => state.setProminentSide); + const setSideOpacity = useAppStore((state) => state.setSideOpacity); + const setOverlayOffsetUm = useAppStore((state) => state.setOverlayOffsetUm); useEffect(() => { if (selectedProbeId) { @@ -86,6 +91,15 @@ export function ProbeViewer() { // Only offer the "Show contact IDs" toggle when the probe actually carries them. const hasContactIds = !!probeData?.probes?.[0]?.contact_ids?.length; + // Double-sided probes (front + back contacts at the same positions) get a + // dedicated canvas and a layout control; single-sided probes are unaffected. + const sideInfo = useMemo( + () => getSideInfo(probeData?.probes?.[0]), + [probeData], + ); + const isDoubleSided = sideInfo.isDoubleSided; + const resolvedProminentSide = resolveProminentSide(sideInfo, view.prominentSide); + // Track canvas container size for minimap const { ref: canvasContainerRef, size: canvasSize } = useResizeObserver(); @@ -341,6 +355,52 @@ export function ProbeViewer() { Overview
+ {isDoubleSided && ( +
+ Double-sided +
+ {sideInfo.sides.map((side) => ( + + ))} +
+ {sideInfo.sides.map((side) => ( + + ))} + +
+ )}
@@ -351,15 +411,30 @@ export function ProbeViewer() { )} {status !== "error" && probeData && ( <> - setViewCenter(x, y)} - onZoom={(value) => setZoom(value)} - /> + {isDoubleSided ? ( + setViewCenter(x, y)} + onZoom={(value) => setZoom(value)} + /> + ) : ( + setViewCenter(x, y)} + onZoom={(value) => setZoom(value)} + /> + )} {view.showOverview && ( + Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev, + ); + const scaleBarPixels = scaleBarUm * scale; + + const margin = 20; + const cornerX = margin; + const cornerY = heightPx - margin; + const tickSize = 4; + + ctx.strokeStyle = "rgba(15, 23, 42, 0.9)"; + ctx.lineWidth = 2; + ctx.lineCap = "square"; + + ctx.beginPath(); + // Vertical arm (Y) - goes up from corner + ctx.moveTo(cornerX, cornerY); + ctx.lineTo(cornerX, cornerY - scaleBarPixels); + // Horizontal arm (X) - goes right from corner + ctx.moveTo(cornerX, cornerY); + ctx.lineTo(cornerX + scaleBarPixels, cornerY); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(cornerX - tickSize, cornerY - scaleBarPixels); + ctx.lineTo(cornerX + tickSize, cornerY - scaleBarPixels); + ctx.moveTo(cornerX + scaleBarPixels, cornerY - tickSize); + ctx.lineTo(cornerX + scaleBarPixels, cornerY + tickSize); + ctx.stroke(); + + const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`; + ctx.font = '11px "Inter", sans-serif'; + ctx.fillStyle = "rgba(15, 23, 42, 0.9)"; + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(label, cornerX + scaleBarPixels / 2, cornerY + 5); + + ctx.save(); + ctx.translate(cornerX - 6, cornerY - scaleBarPixels / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, 0, 0); + ctx.restore(); +} + +// Gold for the (single-sided or "front") face, steel-blue for the back face. +// Single-sided probes keep the original gold look exactly. +export const CONTACT_COLORS = { + front: { fill: "rgba(212, 175, 55, 1.0)", stroke: "rgba(80, 60, 15, 0.9)" }, + back: { fill: "rgba(70, 130, 180, 1.0)", stroke: "rgba(25, 55, 90, 0.9)" }, +} as const; diff --git a/apps/probe-viewer/src/geometry/sides.ts b/apps/probe-viewer/src/geometry/sides.ts new file mode 100644 index 0000000..fa98cea --- /dev/null +++ b/apps/probe-viewer/src/geometry/sides.ts @@ -0,0 +1,135 @@ +import type { ProbeInterfaceProbe } from "../types/probe"; + +// Double-sided probes (front + back contacts at the same positions) are drawn as +// an overlay: the back face — its shank outline and its contacts together — is +// displaced from the front by an adjustable offset, and the emphasized face is +// drawn on top with adjustable opacity. (A separate side-by-side layout was +// removed for now; revisit later.) + +export interface SideInfo { + // True when the probe carries contacts on more than one face. + isDoubleSided: boolean; + // Distinct side labels in first-seen order, e.g. ["front", "back"]. + sides: string[]; +} + +export interface SideContact { + index: number; // index into the probe's contact_* arrays + x: number; // display x in probe coordinates (the back face is displaced) + y: number; + side: string | null; + prominent: boolean; // the emphasized face (drawn on top, opacity-controlled) +} + +export interface SideContour { + points: number[][]; // display coordinates (displaced for the back face) + side: string | null; + prominent: boolean; +} + +export interface SideRenderPlan { + info: SideInfo; + contacts: SideContact[]; + contours: SideContour[]; +} + +// A representative contact half-size in probe units (micrometers), used to scale +// the overlay offset to the probe geometry rather than to screen pixels. Takes +// the first contact's shape: radius for circles, half-width for squares, half of +// the larger side for rectangles. Falls back to 5 µm when shapes are missing. +export function representativeContactSize(probe: ProbeInterfaceProbe): number { + const shape = probe.contact_shapes?.[0]; + const params = probe.contact_shape_params?.[0]; + if (params) { + if (shape === "circle" && params.radius) return params.radius; + if (shape === "square" && params.width) return params.width / 2; + if (shape === "rect") return Math.max(params.width ?? 0, params.height ?? 0) / 2 || 5; + } + return 5; +} + +export function getSideInfo(probe: ProbeInterfaceProbe | undefined): SideInfo { + const sides = probe?.contact_sides; + if (!sides || sides.length === 0) { + return { isDoubleSided: false, sides: [] }; + } + const distinct: string[] = []; + for (const side of sides) { + if (!distinct.includes(side)) distinct.push(side); + } + return { isDoubleSided: distinct.length > 1, sides: distinct }; +} + +// Resolve which side is the emphasized one. `null` (the default) means "the +// first side", so a fresh probe emphasizes "front" without the caller needing +// to know the side names up front. +export function resolveProminentSide( + info: SideInfo, + prominentSide: string | null, +): string | null { + if (!info.isDoubleSided) return null; + if (prominentSide && info.sides.includes(prominentSide)) return prominentSide; + return info.sides[0] ?? null; +} + +// Build the display layout. The first side stays in place; every later side +// (i.e. the back) is displaced horizontally by `offset` probe units — applied to +// both its contacts and its shank outline, so the whole face shifts as one. For +// single-sided probes this is a thin pass-through. +export function buildSideRenderPlan( + probe: ProbeInterfaceProbe, + prominentSide: string | null, + offset: number, +): SideRenderPlan { + const info = getSideInfo(probe); + const positions = probe.contact_positions ?? []; + const sidesArr = probe.contact_sides ?? []; + const contour = + probe.probe_planar_contour && probe.probe_planar_contour.length > 1 + ? probe.probe_planar_contour + : null; + + if (!info.isDoubleSided) { + return { + info, + contacts: positions.map((point, index) => ({ + index, + x: point[0], + y: point[1], + side: sidesArr[index] ?? null, + prominent: true, + })), + contours: contour ? [{ points: contour, side: null, prominent: true }] : [], + }; + } + + const resolved = resolveProminentSide(info, prominentSide); + const displacement = (side: string | null) => + side ? info.sides.indexOf(side) * offset : 0; + + const contacts: SideContact[] = positions.map((point, index) => { + const side = sidesArr[index] ?? null; + return { + index, + x: point[0] + displacement(side), + y: point[1], + side, + prominent: side === resolved, + }; + }); + + // One shank outline per face, shifted by the same displacement as its contacts. + const contours: SideContour[] = []; + if (contour) { + for (const side of info.sides) { + const dx = displacement(side); + contours.push({ + points: contour.map((point) => [point[0] + dx, point[1]]), + side, + prominent: side === resolved, + }); + } + } + + return { info, contacts, contours }; +} diff --git a/apps/probe-viewer/src/hooks/useProbeViewport.ts b/apps/probe-viewer/src/hooks/useProbeViewport.ts new file mode 100644 index 0000000..3c1934c --- /dev/null +++ b/apps/probe-viewer/src/hooks/useProbeViewport.ts @@ -0,0 +1,317 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react"; + +import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; +import type { ProbeViewerCamera } from "../types/probe"; + +// The probe-space bounding box the viewport frames. Both the single-sided and +// double-sided canvases compute one of these and hand it to the hook. +export interface ViewportGeometry { + width: number; + height: number; + centerX: number; + centerY: number; +} + +export interface ViewportSize { + width: number; + height: number; +} + +// Projection from probe coordinates (micrometers, y-up) to canvas pixels +// (y-down). `scale` is pixels per micrometer at the current zoom. +export interface Projection { + scale: number; + offsetX: number; + offsetY: number; + projectPoint: (point: number[]) => [number, number]; +} + +interface UseProbeViewportArgs { + geometry: ViewportGeometry | null; + camera: ProbeViewerCamera; + size: ViewportSize; + onViewCenterChange: (x: number | null, y: number | null) => void; + onZoom: (zoom: number) => void; +} + +const PADDING = 40; + +// Camera + interaction logic shared by every probe canvas. This is a verbatim +// extraction of the pan/zoom/projection math that used to live inside +// ProbeCanvas; keeping it in one place means a scroll-zoom or drag fix lands for +// both the single-sided and double-sided views at once. The hook owns no +// drawing: each canvas component runs its own draw effect using getProjection(). +export function useProbeViewport({ + geometry, + camera, + size, + onViewCenterChange, + onZoom, +}: UseProbeViewportArgs) { + const { zoom, centerX, centerY } = camera; + const canvasRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const dragOriginRef = useRef<{ + x: number; + y: number; + viewCenterX: number; + viewCenterY: number; + } | null>(null); + // Coalesce pan updates to one per animation frame: pointermove fires far more + // often than the screen repaints, so we keep only the latest target. + const panRafRef = useRef(0); + const pendingViewCenterRef = useRef<{ x: number; y: number } | null>(null); + + // Use geometry center when the camera has no explicit center yet. + const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0; + const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0; + + const clampZoom = useCallback( + (value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)), + [], + ); + + const getScale = useCallback(() => { + if (!size.width || !size.height || !geometry) return 1; + const availableWidth = Math.max(10, size.width - PADDING * 2); + const availableHeight = Math.max(10, size.height - PADDING * 2); + const baseScale = Math.min( + availableWidth / geometry.width, + availableHeight / geometry.height, + ); + return baseScale * zoom; + }, [geometry, size.width, size.height, zoom]); + + // Current projection from probe coordinates to canvas pixels. Recomputed on + // demand so the draw effect always sees the live camera. + const getProjection = useCallback((): Projection | null => { + if (!geometry || !size.width || !size.height) return null; + const scale = getScale(); + const panX = (geometry.centerX - effectiveViewCenterX) * scale; + const panY = (effectiveViewCenterY - geometry.centerY) * scale; + const offsetX = size.width / 2 + panX; + const offsetY = size.height / 2 + panY; + const projectPoint = (point: number[]): [number, number] => [ + (point[0] - geometry.centerX) * scale + offsetX, + -(point[1] - geometry.centerY) * scale + offsetY, + ]; + return { scale, offsetX, offsetY, projectPoint }; + }, [geometry, size.width, size.height, getScale, effectiveViewCenterX, effectiveViewCenterY]); + + // Wheel-to-zoom is attached as a NATIVE, non-passive listener (not React's + // onWheel) so preventDefault() actually stops the page from scrolling. React + // registers wheel handlers as passive by default, which ignores preventDefault() + // and lets the page scroll while we zoom. Live values are read through a ref so + // the listener does not re-subscribe on every zoom/pan change; it only + // re-attaches when the canvas itself changes. + const wheelStateRef = useRef({ + zoom, + effectiveViewCenterX, + effectiveViewCenterY, + geometry, + getScale, + clampZoom, + onViewCenterChange, + onZoom, + }); + wheelStateRef.current = { + zoom, + effectiveViewCenterX, + effectiveViewCenterY, + geometry, + getScale, + clampZoom, + onViewCenterChange, + onZoom, + }; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + const { + zoom, + effectiveViewCenterX, + effectiveViewCenterY, + geometry, + getScale, + clampZoom, + onViewCenterChange, + onZoom, + } = wheelStateRef.current; + if (!geometry) return; + + // Normalize wheel units so zoom speed is consistent across devices: mouse + // wheels often report "line" deltas, trackpads report pixels. + const unit = + event.deltaMode === 1 + ? 16 // lines -> ~16px + : event.deltaMode === 2 + ? canvas.clientHeight // pages -> viewport height + : 1; // already pixels + // Holding Shift moves the scroll onto the horizontal axis on most systems. + const delta = (event.deltaY || event.deltaX) * unit; + + const rect = canvas.getBoundingClientRect(); + const offsetFromCenterX = event.clientX - rect.left - rect.width / 2; + const offsetFromCenterY = event.clientY - rect.top - rect.height / 2; + + const scale = getScale(); + const panX = (geometry.centerX - effectiveViewCenterX) * scale; + const panY = (effectiveViewCenterY - geometry.centerY) * scale; + + const zoomFactor = Math.exp(-delta * 0.002); + const nextZoom = clampZoom(zoom * zoomFactor); + const actualZoomFactor = nextZoom / zoom; + + // Keep the point under the cursor fixed. The (1 - factor) sign anchors the + // zoom at the cursor; (factor - 1) would anchor at the cursor's mirror across + // the center, which is what made zoom feel like it pulled toward the middle. + const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor); + const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor); + + // Convert back to probe coordinates. + const newScale = scale * actualZoomFactor; + const newViewCenterX = geometry.centerX - newPanX / newScale; + const newViewCenterY = geometry.centerY + newPanY / newScale; + + onViewCenterChange(newViewCenterX, newViewCenterY); + onZoom(nextZoom); + }; + + canvas.addEventListener("wheel", handleWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", handleWheel); + // Re-run once geometry becomes available so the listener attaches after the + // canvas is actually rendered (it is conditional on geometry being non-null). + // Live values are still read through wheelStateRef, so this never needs to + // re-attach on plain zoom/pan changes. + }, [geometry]); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + setIsDragging(true); + dragOriginRef.current = { + x: event.clientX, + y: event.clientY, + viewCenterX: effectiveViewCenterX, + viewCenterY: effectiveViewCenterY, + }; + (event.target as HTMLCanvasElement).setPointerCapture(event.pointerId); + }, + [effectiveViewCenterX, effectiveViewCenterY], + ); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + if (!isDragging || !dragOriginRef.current) { + return; + } + event.preventDefault(); + const deltaX = event.clientX - dragOriginRef.current.x; + const deltaY = event.clientY - dragOriginRef.current.y; + + // Convert pixel delta to probe coordinate delta, but only apply one update + // per animation frame so a flood of pointermove events collapses into a + // single redraw. + const scale = getScale(); + pendingViewCenterRef.current = { + x: dragOriginRef.current.viewCenterX - deltaX / scale, + y: dragOriginRef.current.viewCenterY + deltaY / scale, + }; + if (!panRafRef.current) { + panRafRef.current = requestAnimationFrame(() => { + panRafRef.current = 0; + const pending = pendingViewCenterRef.current; + if (pending) onViewCenterChange(pending.x, pending.y); + }); + } + }, + [getScale, isDragging, onViewCenterChange], + ); + + const handlePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (isDragging) { + event.preventDefault(); + // Flush any pending coalesced pan so the final position is exact. + if (panRafRef.current) { + cancelAnimationFrame(panRafRef.current); + panRafRef.current = 0; + } + const pending = pendingViewCenterRef.current; + if (pending) { + onViewCenterChange(pending.x, pending.y); + pendingViewCenterRef.current = null; + } + setIsDragging(false); + dragOriginRef.current = null; + (event.target as HTMLCanvasElement).releasePointerCapture(event.pointerId); + } + }, + [isDragging, onViewCenterChange], + ); + + // Cancel any pending pan frame on unmount. + useEffect(() => { + return () => { + if (panRafRef.current) cancelAnimationFrame(panRafRef.current); + }; + }, []); + + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + if (!geometry) return; + + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const canvasCenterX = rect.width / 2; + const canvasCenterY = rect.height / 2; + const offsetFromCenterX = mouseX - canvasCenterX; + const offsetFromCenterY = mouseY - canvasCenterY; + + const scale = getScale(); + const panX = (geometry.centerX - effectiveViewCenterX) * scale; + const panY = (effectiveViewCenterY - geometry.centerY) * scale; + + // Shift-double-click zooms out; plain double-click zooms in. + const zoomFactor = event.shiftKey ? 1 / 1.5 : 1.5; + const nextZoom = clampZoom(zoom * zoomFactor); + const actualZoomFactor = nextZoom / zoom; + + // Adjust pan so the clicked point stays fixed (see wheel handler note on + // the (1 - factor) sign that anchors at the cursor rather than its mirror). + const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor); + const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor); + + const newScale = scale * actualZoomFactor; + const newViewCenterX = geometry.centerX - newPanX / newScale; + const newViewCenterY = geometry.centerY + newPanY / newScale; + + onViewCenterChange(newViewCenterX, newViewCenterY); + onZoom(nextZoom); + }, + [clampZoom, effectiveViewCenterX, effectiveViewCenterY, geometry, getScale, onViewCenterChange, onZoom, zoom], + ); + + return { + canvasRef, + isDragging, + effectiveViewCenterX, + effectiveViewCenterY, + getScale, + getProjection, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handleDoubleClick, + }; +} diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index f4b93a9..ded5baa 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -16,6 +16,15 @@ interface ViewState { showContactIds: boolean; showScaleBar: boolean; showOverview: boolean; + // Which face is on top (drawn last) and whose contact IDs show; null means + // "the first side". Does not affect opacity. + prominentSide: string | null; + // Independent opacity per side, keyed by side name (e.g. "front"/"back"). + // Range 0–1; a missing side defaults to 1. Each side is controlled on its own + // so "back opacity" always means the back face, regardless of stacking. + sideOpacity: Record; + // Separation between the two faces in overlay mode, in probe units (µm). + overlayOffsetUm: number; } interface AppState { @@ -45,6 +54,9 @@ interface AppState { toggleContactIds: (value?: boolean) => void; toggleScaleBar: (value?: boolean) => void; toggleOverview: (value?: boolean) => void; + setProminentSide: (side: string | null) => void; + setSideOpacity: (side: string, opacity: number) => void; + setOverlayOffsetUm: (offsetUm: number) => void; } export const VIEW_ZOOM_MIN = 0.1; @@ -61,6 +73,11 @@ const INITIAL_VIEW_STATE: ViewState = { showContactIds: false, showScaleBar: true, showOverview: true, + prominentSide: null, + // Both faces fully opaque by default; the offset alone keeps them readable. + sideOpacity: {}, + // A slight separation by default so both faces are distinguishable on load. + overlayOffsetUm: 10, }; function clamp(value: number, min: number, max: number) { @@ -201,6 +218,9 @@ export const useAppStore = create((set, get) => ({ view: { ...INITIAL_VIEW_STATE, showContactIds: state.view.showContactIds, + prominentSide: state.view.prominentSide, + sideOpacity: state.view.sideOpacity, + overlayOffsetUm: state.view.overlayOffsetUm, }, })), @@ -230,6 +250,28 @@ export const useAppStore = create((set, get) => ({ value !== undefined ? value : !state.view.showOverview, }, })), + + setProminentSide: (side) => + set((state) => ({ view: { ...state.view, prominentSide: side } })), + + setSideOpacity: (side, opacity) => + set((state) => ({ + view: { + ...state.view, + sideOpacity: { + ...state.view.sideOpacity, + [side]: Math.min(1, Math.max(0, opacity)), + }, + }, + })), + + setOverlayOffsetUm: (offsetUm) => + set((state) => ({ + view: { + ...state.view, + overlayOffsetUm: Math.min(100, Math.max(0, offsetUm)), + }, + })), })); export type { AppState, LoadStatus, ManifestEntry, ProbeInterfaceFile }; diff --git a/apps/probe-viewer/src/types/probe.ts b/apps/probe-viewer/src/types/probe.ts index 68f6158..9172e40 100644 --- a/apps/probe-viewer/src/types/probe.ts +++ b/apps/probe-viewer/src/types/probe.ts @@ -45,7 +45,11 @@ export interface ProbeInterfaceProbe { contact_shapes?: string[]; // "circle" | "square" | "rect" contact_shape_params?: ContactShapeParams[]; contact_ids?: (string | number)[]; - shank_ids?: number[]; + shank_ids?: (string | number)[]; + // Per-contact face of the shank, e.g. "front" / "back". Present on + // double-sided probes (Cambridge NeuroTech ASSY-325D-*), where front and back + // contacts share the same (x, y) position. + contact_sides?: string[]; probe_planar_contour?: number[][]; } From bef833147fe0bba003b64d04585df9f10e11161d Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 24 Jun 2026 16:57:08 -0600 Subject: [PATCH 2/2] double sided --- apps/probe-viewer/src/App.css | 23 ---- .../src/components/DoubleSidedProbeCanvas.tsx | 126 +++++++----------- .../src/components/ProbeViewer.tsx | 65 ++++----- apps/probe-viewer/src/geometry/sides.ts | 118 +--------------- apps/probe-viewer/src/state/useAppStore.ts | 50 ++----- 5 files changed, 86 insertions(+), 296 deletions(-) diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index 482d3e7..d8eb3fc 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -373,29 +373,6 @@ color: #fff; } -/* Overlay opacity / offset sliders. */ -.viewer-slider { - display: inline-flex; - align-items: center; - gap: 0.4rem; - font-size: 0.85rem; - color: #1e293b; -} - -.viewer-slider span { - text-transform: capitalize; - white-space: nowrap; -} - -.viewer-slider input[type="range"] { - width: 6rem; -} - -.viewer-slider-value { - min-width: 3.2rem; - font-variant-numeric: tabular-nums; -} - /* Color key matching the canvas: gold front, steel-blue back. */ .viewer-side-swatch { width: 0.7rem; diff --git a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx index 40a4d4b..11b7977 100644 --- a/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/DoubleSidedProbeCanvas.tsx @@ -3,20 +3,15 @@ import { useEffect, useMemo, useRef } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useProbeViewport } from "../hooks/useProbeViewport"; import { CONTACT_COLORS, drawContactShape, renderScaleBar } from "../geometry/draw"; -import { buildSideRenderPlan, type SideRenderPlan } from "../geometry/sides"; import type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; interface DoubleSidedProbeCanvasProps { entry: ManifestEntry; probeData: ProbeInterfaceFile; camera: ProbeViewerCamera; - showContactIds: boolean; showScaleBar: boolean; - prominentSide: string | null; - // Independent opacity (0–1) per side; a missing side defaults to 1. - sideOpacity: Record; - // Separation between faces, in probe units (µm). - offsetUm: number; + // "both" overlays the faces (registration view); a side name isolates one. + overlaySide: string; onViewCenterChange: (x: number | null, y: number | null) => void; onZoom: (zoom: number) => void; } @@ -28,33 +23,28 @@ interface GeometrySummary { centerY: number; } -// Bounds over the laid-out contacts and contours. The back face is displaced in -// probe coordinates, so framing must include it; computing from the plan (rather -// than the raw positions) accounts for the offset automatically. -function computeGeometryFromPlan(plan: SideRenderPlan): GeometrySummary | null { +// Bounds over the raw contacts and contour (true positions — the overlay never +// displaces a face, so framing is just the probe's own extent). +function computeGeometry(positions: number[][], contour: number[][]): GeometrySummary | null { let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; - - const update = (x: number, y: number) => { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; + const update = (point: number[]) => { + if (point[0] < minX) minX = point[0]; + if (point[0] > maxX) maxX = point[0]; + if (point[1] < minY) minY = point[1]; + if (point[1] > maxY) maxY = point[1]; }; - - plan.contacts.forEach((contact) => update(contact.x, contact.y)); - plan.contours.forEach((contour) => contour.points.forEach((p) => update(p[0], p[1]))); - + positions.forEach(update); + contour.forEach(update); if (!Number.isFinite(minX)) return null; - const width = Math.max(10, maxX - minX); const height = Math.max(10, maxY - minY); return { width, height, centerX: minX + width / 2, centerY: minY + height / 2 }; } -function colorForSide(side: string | null) { +function colorForSide(side: string | undefined) { return side === "back" ? CONTACT_COLORS.back : CONTACT_COLORS.front; } @@ -62,11 +52,8 @@ export function DoubleSidedProbeCanvas({ entry, probeData, camera, - showContactIds, showScaleBar, - prominentSide, - sideOpacity, - offsetUm, + overlaySide, onViewCenterChange, onZoom, }: DoubleSidedProbeCanvasProps) { @@ -75,11 +62,10 @@ export function DoubleSidedProbeCanvas({ const lastCanvasSizeRef = useRef({ w: 0, h: 0, dpr: 0 }); const probe = probeData.probes?.[0]; - const plan = useMemo( - () => (probe ? buildSideRenderPlan(probe, prominentSide, offsetUm) : null), - [probe, prominentSide, offsetUm], - ); - const geometry = useMemo(() => (plan ? computeGeometryFromPlan(plan) : null), [plan]); + const geometry = useMemo(() => { + if (!probe) return null; + return computeGeometry(probe.contact_positions ?? [], probe.probe_planar_contour ?? []); + }, [probe]); const { canvasRef, @@ -91,7 +77,7 @@ export function DoubleSidedProbeCanvas({ } = useProbeViewport({ geometry, camera, size, onViewCenterChange, onZoom }); useEffect(() => { - if (!canvasRef.current || !size.width || !size.height || !geometry || !probe || !plan) { + if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) { return; } const canvas = canvasRef.current; @@ -120,12 +106,11 @@ export function DoubleSidedProbeCanvas({ ctx.lineCap = "round"; ctx.lineJoin = "round"; - const contactShapes = probe.contact_shapes ?? []; - const contactShapeParams = probe.contact_shape_params ?? []; - - const drawContour = (points: number[][]) => { + // Shared shank outline (both faces occupy the same shank). + const contour = probe.probe_planar_contour ?? []; + if (contour.length > 1) { ctx.beginPath(); - points.forEach((point, index) => { + contour.forEach((point, index) => { const [x, y] = projectPoint(point); if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); @@ -136,59 +121,46 @@ export function DoubleSidedProbeCanvas({ ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 100)); ctx.fill(); ctx.stroke(); - }; - - // Draw each face (shank outline + its contacts) as a unit, the prominent face - // last (on top) with adjustable opacity. The whole back face is already - // displaced in the plan, so the outline and contacts shift together. - const facesInOrder = [...plan.info.sides].sort( - (a, b) => Number(a === prominentSide) - Number(b === prominentSide), - ); - - facesInOrder.forEach((side) => { - const alpha = sideOpacity[side] ?? 1; - if (alpha === 0) return; - ctx.globalAlpha = alpha; - - const contour = plan.contours.find((c) => c.side === side); - if (contour) drawContour(contour.points); - - const colors = colorForSide(side); - ctx.fillStyle = colors.fill; - ctx.strokeStyle = colors.stroke; - ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150)); - plan.contacts.forEach((contact) => { - if (contact.side !== side) return; - const [px, py] = projectPoint([contact.x, contact.y]); - const shape = contactShapes[contact.index] ?? ""; - const params = contactShapeParams[contact.index] ?? {}; - drawContactShape(ctx, px, py, shape, params, scale); - ctx.fill(); - ctx.stroke(); - }); + } - ctx.globalAlpha = 1; + const positions = probe.contact_positions ?? []; + const sides = probe.contact_sides ?? []; + const contactShapes = probe.contact_shapes ?? []; + const contactShapeParams = probe.contact_shape_params ?? []; + + // The view shows one face at a time as its own channel map: that face's + // contacts in the face color, drawn solid. Front and back share positions, + // so only one set is ever on screen, which is why the IDs below never collide. + const colors = colorForSide(overlaySide); + ctx.fillStyle = colors.fill; + ctx.strokeStyle = colors.stroke; + ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150)); + positions.forEach((position, index) => { + if ((sides[index] ?? "front") !== overlaySide) return; + const [x, y] = projectPoint(position); + drawContactShape(ctx, x, y, contactShapes[index] ?? "", contactShapeParams[index] ?? {}, scale); + ctx.fill(); + ctx.stroke(); }); - // Contact IDs for the prominent (focused) face only; the two faces sit close - // together, so showing both sets would overlap. - if (showContactIds && probe.contact_ids) { + // Contact IDs make the isolated face a channel map (the point of the view). + if (probe.contact_ids) { const contactIds = probe.contact_ids; ctx.font = `${Math.max(10, Math.min(14, 10 * (scale / 100)))}px "Inter", sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = "rgba(15, 23, 42, 0.95)"; - plan.contacts.forEach((contact) => { - if (contact.side !== prominentSide) return; - const [px, py] = projectPoint([contact.x, contact.y]); - ctx.fillText(String(contactIds[contact.index] ?? contact.index), px, py + 4); + positions.forEach((position, index) => { + if ((sides[index] ?? "front") !== overlaySide) return; + const [x, y] = projectPoint(position); + ctx.fillText(String(contactIds[index] ?? index), x, y + 4); }); } if (showScaleBar) { renderScaleBar(ctx, scale, heightPx); } - }, [canvasRef, entry.id, geometry, getProjection, plan, prominentSide, probe, showContactIds, showScaleBar, size.height, size.width, zoom, centerX, centerY, sideOpacity]); + }, [canvasRef, entry.id, geometry, getProjection, overlaySide, probe, showScaleBar, size.height, size.width, zoom, centerX, centerY]); return (
diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index 02aa792..db98c30 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useAppStore, VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; import { exportProbeAsPng, exportProbeAsSvg } from "../utils/exportUtils"; -import { getSideInfo, resolveProminentSide } from "../geometry/sides"; +import { getSideInfo } from "../geometry/sides"; import { ProbeCanvas } from "./ProbeCanvas"; import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas"; import { ProbeOverview } from "./ProbeOverview"; @@ -64,9 +64,7 @@ export function ProbeViewer() { const toggleContactIds = useAppStore((state) => state.toggleContactIds); const toggleScaleBar = useAppStore((state) => state.toggleScaleBar); const toggleOverview = useAppStore((state) => state.toggleOverview); - const setProminentSide = useAppStore((state) => state.setProminentSide); - const setSideOpacity = useAppStore((state) => state.setSideOpacity); - const setOverlayOffsetUm = useAppStore((state) => state.setOverlayOffsetUm); + const setOverlaySide = useAppStore((state) => state.setOverlaySide); useEffect(() => { if (selectedProbeId) { @@ -98,7 +96,19 @@ export function ProbeViewer() { [probeData], ); const isDoubleSided = sideInfo.isDoubleSided; - const resolvedProminentSide = resolveProminentSide(sideInfo, view.prominentSide); + // Fall back to the probe's first side if the stored selection is not one of + // this probe's sides (e.g. a stale value from a previous probe). + const activeSide = sideInfo.sides.includes(view.overlaySide) + ? view.overlaySide + : sideInfo.sides[0]; + // Per-side contact counts, for the "double-sided" badge. + const sideCounts = useMemo(() => { + const counts: Record = {}; + for (const side of probeData?.probes?.[0]?.contact_sides ?? []) { + counts[side] = (counts[side] ?? 0) + 1; + } + return counts; + }, [probeData]); // Track canvas container size for minimap const { ref: canvasContainerRef, size: canvasSize } = useResizeObserver(); @@ -357,48 +367,24 @@ export function ProbeViewer() {
{isDoubleSided && (
- Double-sided -
+ + Double-sided ·{" "} + {sideInfo.sides.map((side) => `${sideCounts[side] ?? 0} ${side}`).join(" / ")} + +
{sideInfo.sides.map((side) => ( ))}
- {sideInfo.sides.map((side) => ( - - ))} -
)}
@@ -416,11 +402,8 @@ export function ProbeViewer() { entry={entry} probeData={probeData} camera={view.camera} - showContactIds={view.showContactIds} showScaleBar={view.showScaleBar} - prominentSide={resolvedProminentSide} - sideOpacity={view.sideOpacity} - offsetUm={view.overlayOffsetUm} + overlaySide={activeSide} onViewCenterChange={(x, y) => setViewCenter(x, y)} onZoom={(value) => setZoom(value)} /> diff --git a/apps/probe-viewer/src/geometry/sides.ts b/apps/probe-viewer/src/geometry/sides.ts index fa98cea..2d2b163 100644 --- a/apps/probe-viewer/src/geometry/sides.ts +++ b/apps/probe-viewer/src/geometry/sides.ts @@ -1,10 +1,9 @@ import type { ProbeInterfaceProbe } from "../types/probe"; -// Double-sided probes (front + back contacts at the same positions) are drawn as -// an overlay: the back face — its shank outline and its contacts together — is -// displaced from the front by an adjustable offset, and the emphasized face is -// drawn on top with adjustable opacity. (A separate side-by-side layout was -// removed for now; revisit later.) +// Double-sided probes carry front and back contacts at the same (x, y) positions +// (distinguished by `contact_sides`). They are drawn as a registration overlay: +// both faces in one true-scale frame, color-coded and semi-transparent, like a +// two-layer PCB view. A Both / front / back selector isolates a single face. export interface SideInfo { // True when the probe carries contacts on more than one face. @@ -13,41 +12,6 @@ export interface SideInfo { sides: string[]; } -export interface SideContact { - index: number; // index into the probe's contact_* arrays - x: number; // display x in probe coordinates (the back face is displaced) - y: number; - side: string | null; - prominent: boolean; // the emphasized face (drawn on top, opacity-controlled) -} - -export interface SideContour { - points: number[][]; // display coordinates (displaced for the back face) - side: string | null; - prominent: boolean; -} - -export interface SideRenderPlan { - info: SideInfo; - contacts: SideContact[]; - contours: SideContour[]; -} - -// A representative contact half-size in probe units (micrometers), used to scale -// the overlay offset to the probe geometry rather than to screen pixels. Takes -// the first contact's shape: radius for circles, half-width for squares, half of -// the larger side for rectangles. Falls back to 5 µm when shapes are missing. -export function representativeContactSize(probe: ProbeInterfaceProbe): number { - const shape = probe.contact_shapes?.[0]; - const params = probe.contact_shape_params?.[0]; - if (params) { - if (shape === "circle" && params.radius) return params.radius; - if (shape === "square" && params.width) return params.width / 2; - if (shape === "rect") return Math.max(params.width ?? 0, params.height ?? 0) / 2 || 5; - } - return 5; -} - export function getSideInfo(probe: ProbeInterfaceProbe | undefined): SideInfo { const sides = probe?.contact_sides; if (!sides || sides.length === 0) { @@ -59,77 +23,3 @@ export function getSideInfo(probe: ProbeInterfaceProbe | undefined): SideInfo { } return { isDoubleSided: distinct.length > 1, sides: distinct }; } - -// Resolve which side is the emphasized one. `null` (the default) means "the -// first side", so a fresh probe emphasizes "front" without the caller needing -// to know the side names up front. -export function resolveProminentSide( - info: SideInfo, - prominentSide: string | null, -): string | null { - if (!info.isDoubleSided) return null; - if (prominentSide && info.sides.includes(prominentSide)) return prominentSide; - return info.sides[0] ?? null; -} - -// Build the display layout. The first side stays in place; every later side -// (i.e. the back) is displaced horizontally by `offset` probe units — applied to -// both its contacts and its shank outline, so the whole face shifts as one. For -// single-sided probes this is a thin pass-through. -export function buildSideRenderPlan( - probe: ProbeInterfaceProbe, - prominentSide: string | null, - offset: number, -): SideRenderPlan { - const info = getSideInfo(probe); - const positions = probe.contact_positions ?? []; - const sidesArr = probe.contact_sides ?? []; - const contour = - probe.probe_planar_contour && probe.probe_planar_contour.length > 1 - ? probe.probe_planar_contour - : null; - - if (!info.isDoubleSided) { - return { - info, - contacts: positions.map((point, index) => ({ - index, - x: point[0], - y: point[1], - side: sidesArr[index] ?? null, - prominent: true, - })), - contours: contour ? [{ points: contour, side: null, prominent: true }] : [], - }; - } - - const resolved = resolveProminentSide(info, prominentSide); - const displacement = (side: string | null) => - side ? info.sides.indexOf(side) * offset : 0; - - const contacts: SideContact[] = positions.map((point, index) => { - const side = sidesArr[index] ?? null; - return { - index, - x: point[0] + displacement(side), - y: point[1], - side, - prominent: side === resolved, - }; - }); - - // One shank outline per face, shifted by the same displacement as its contacts. - const contours: SideContour[] = []; - if (contour) { - for (const side of info.sides) { - const dx = displacement(side); - contours.push({ - points: contour.map((point) => [point[0] + dx, point[1]]), - side, - prominent: side === resolved, - }); - } - } - - return { info, contacts, contours }; -} diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index ded5baa..2013ea0 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -16,15 +16,9 @@ interface ViewState { showContactIds: boolean; showScaleBar: boolean; showOverview: boolean; - // Which face is on top (drawn last) and whose contact IDs show; null means - // "the first side". Does not affect opacity. - prominentSide: string | null; - // Independent opacity per side, keyed by side name (e.g. "front"/"back"). - // Range 0–1; a missing side defaults to 1. Each side is controlled on its own - // so "back opacity" always means the back face, regardless of stacking. - sideOpacity: Record; - // Separation between the two faces in overlay mode, in probe units (µm). - overlayOffsetUm: number; + // Double-sided probes: which face to show as a channel map. A side name + // ("front"/"back"); resolved against the probe's actual sides at render time. + overlaySide: string; } interface AppState { @@ -54,9 +48,7 @@ interface AppState { toggleContactIds: (value?: boolean) => void; toggleScaleBar: (value?: boolean) => void; toggleOverview: (value?: boolean) => void; - setProminentSide: (side: string | null) => void; - setSideOpacity: (side: string, opacity: number) => void; - setOverlayOffsetUm: (offsetUm: number) => void; + setOverlaySide: (side: string) => void; } export const VIEW_ZOOM_MIN = 0.1; @@ -73,11 +65,8 @@ const INITIAL_VIEW_STATE: ViewState = { showContactIds: false, showScaleBar: true, showOverview: true, - prominentSide: null, - // Both faces fully opaque by default; the offset alone keeps them readable. - sideOpacity: {}, - // A slight separation by default so both faces are distinguishable on load. - overlayOffsetUm: 10, + // Default to the front face; resolved to the probe's first side if absent. + overlaySide: "front", }; function clamp(value: number, min: number, max: number) { @@ -218,9 +207,7 @@ export const useAppStore = create((set, get) => ({ view: { ...INITIAL_VIEW_STATE, showContactIds: state.view.showContactIds, - prominentSide: state.view.prominentSide, - sideOpacity: state.view.sideOpacity, - overlayOffsetUm: state.view.overlayOffsetUm, + overlaySide: state.view.overlaySide, }, })), @@ -251,27 +238,8 @@ export const useAppStore = create((set, get) => ({ }, })), - setProminentSide: (side) => - set((state) => ({ view: { ...state.view, prominentSide: side } })), - - setSideOpacity: (side, opacity) => - set((state) => ({ - view: { - ...state.view, - sideOpacity: { - ...state.view.sideOpacity, - [side]: Math.min(1, Math.max(0, opacity)), - }, - }, - })), - - setOverlayOffsetUm: (offsetUm) => - set((state) => ({ - view: { - ...state.view, - overlayOffsetUm: Math.min(100, Math.max(0, offsetUm)), - }, - })), + setOverlaySide: (side) => + set((state) => ({ view: { ...state.view, overlaySide: side } })), })); export type { AppState, LoadStatus, ManifestEntry, ProbeInterfaceFile };