diff --git a/.github/workflows/bun-formatcheck.yml b/.github/workflows/bun-formatcheck.yml new file mode 100644 index 0000000..be449cf --- /dev/null +++ b/.github/workflows/bun-formatcheck.yml @@ -0,0 +1,26 @@ +# Created using @tscircuit/plop (npm install -g @tscircuit/plop) +name: Format Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + format-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run format check + run: bun run format:check diff --git a/.github/workflows/bun-pver-release.yml b/.github/workflows/bun-pver-release.yml new file mode 100644 index 0000000..11105c4 --- /dev/null +++ b/.github/workflows/bun-pver-release.yml @@ -0,0 +1,71 @@ +# Created using @tscircuit/plop (npm install -g @tscircuit/plop) +name: Publish to npm +on: + push: + branches: + - main + workflow_dispatch: + +env: + UPSTREAM_REPOS: "" # comma-separated list, e.g. "eval,tscircuit,docs" + UPSTREAM_PACKAGES_TO_UPDATE: "" # comma-separated list, e.g. "@tscircuit/core,@tscircuit/protos" + +jobs: + publish: + runs-on: ubuntu-latest + if: ${{ !(github.event_name == 'push' && startsWith(github.event.head_commit.message, 'v')) }} + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN }} + - name: Setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-node@v3 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + - run: npm install -g pver + - run: bun install --frozen-lockfile + - run: bun run build + - run: pver release --no-push-main + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN }} + + - name: Create Pull Request + id: create-pr + uses: peter-evans/create-pull-request@v5 + with: + commit-message: "chore: bump version" + title: "chore: bump version" + body: "Automated package update" + branch: bump-version-${{ github.run_number }} + base: main + token: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN }} + committer: tscircuitbot + author: tscircuitbot + + - name: Enable auto-merge + if: steps.create-pr.outputs.pull-request-number != '' + run: | + gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash --delete-branch + env: + GH_TOKEN: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN }} + + # - name: Trigger upstream repo updates + # if: env.UPSTREAM_REPOS && env.UPSTREAM_PACKAGES_TO_UPDATE + # run: | + # IFS=',' read -ra REPOS <<< "${{ env.UPSTREAM_REPOS }}" + # for repo in "${REPOS[@]}"; do + # if [[ -n "$repo" ]]; then + # echo "Triggering update for repo: $repo" + # curl -X POST \ + # -H "Accept: application/vnd.github.v3+json" \ + # -H "Authorization: token ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN }}" \ + # -H "Content-Type: application/json" \ + # "https://api.github.com/repos/tscircuit/$repo/actions/workflows/update-package.yml/dispatches" \ + # -d "{\"ref\":\"main\",\"inputs\":{\"package_names\":\"${{ env.UPSTREAM_PACKAGES_TO_UPDATE }}\"}}" + # fi + # done \ No newline at end of file diff --git a/.github/workflows/bun-typecheck.yml b/.github/workflows/bun-typecheck.yml new file mode 100644 index 0000000..68acb7d --- /dev/null +++ b/.github/workflows/bun-typecheck.yml @@ -0,0 +1,26 @@ +# Created using @tscircuit/plop (npm install -g @tscircuit/plop) +name: Type Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + type-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun i + + - name: Run type check + run: bunx tsc --noEmit diff --git a/.gitignore b/.gitignore index a14702c..0898819 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +package-lock.json +.vercel +cosmos-export +bun.lock \ No newline at end of file diff --git a/README.md b/README.md index 217c62f..90ae421 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ -# trace-capacity-visualizer -A react component and poppygl layer for rendering capacity nodes and 3d traces for viewing routing +# CapacityNode3dDebugger + +A React component for visualizing capacity mesh nodes in 3D space using Three.js. + +## Features + +- 3D visualization of capacity mesh nodes +- Interactive controls for orbiting, zooming, and panning +- Layer-based visualization with customizable thickness +- Toggle options for different visual elements (root, obstacles, output) +- Wireframe and solid rendering modes +- Opacity controls for mesh visualization +- Box shrinking for better spatial visualization +- Border highlighting for mesh boxes + +## Usage + +```tsx +import { CapacityNode3dDebugger } from './lib/CapacityNode3dDebugger' +import type { CapacityMeshNode } from './lib/types' + +const nodes: CapacityMeshNode[] = [ + { + capacityMeshNodeId: "node1", + center: { x: 0, y: 0 }, + width: 10, + height: 10, + layer: "top", + availableZ: [0, 1, 2] + } + // ... more nodes +] + +function App() { + return ( + + ) +} +``` + +## Props + +- `nodes`: Array of capacity mesh nodes to visualize +- `simpleRouteJson`: Optional SimpleRouteJson data for obstacles and bounds +- `layerThickness`: Visual Z thickness per layer (default: 1) +- `height`: Canvas height (default: 600) +- `defaultShowRoot`: Show root bounds initially (default: true) +- `defaultShowObstacles`: Show obstacles initially (default: false) +- `defaultShowOutput`: Show output mesh initially (default: true) +- `defaultWireframeOutput`: Use wireframe for output initially (default: false) +- `style`: Optional CSS styles for the container + +## Controls + +- **Show 3D/Hide 3D**: Toggle 3D visualization +- **Rebuild 3D**: Rebuild the 3D scene +- **Root**: Toggle root bounds visibility +- **Obstacles**: Toggle obstacles visibility +- **Output**: Toggle output mesh visibility +- **Wireframe Output**: Toggle between solid and wireframe rendering +- **Opacity**: Adjust mesh opacity (0-1) +- **Shrink boxes**: Enable box shrinking for better visualization +- **Show borders**: Toggle border highlighting on mesh boxes + +## Mouse Controls + +- **Drag**: Orbit camera +- **Wheel**: Zoom in/out +- **Right-drag**: Pan camera \ No newline at end of file diff --git a/lib/CapacityNode3dDebugger.tsx b/lib/CapacityNode3dDebugger.tsx new file mode 100644 index 0000000..3fc83ef --- /dev/null +++ b/lib/CapacityNode3dDebugger.tsx @@ -0,0 +1,246 @@ +import { + useCallback, + useEffect, + useState, +} from "react" +import type { CapacityMeshNode, SimpleRouteJson } from "./types" +import { ThreeBoardView } from "./ThreeBoardView" + +type CapacityNode3dDebuggerProps = { + nodes: CapacityMeshNode[] + simpleRouteJson?: SimpleRouteJson + layerThickness?: number + height?: number + defaultShowObstacles?: boolean + defaultWireframeOutput?: boolean + defaultShowRoot?: boolean + defaultShowOutput?: boolean + style?: React.CSSProperties +} + +export const CapacityNode3dDebugger: React.FC = ({ + nodes: initialNodes, + simpleRouteJson, + layerThickness = 1, + height = 600, + defaultShowObstacles = false, // don't show obstacles by default + defaultWireframeOutput = false, + defaultShowRoot = false, + defaultShowOutput = true, + style, +}) => { + const [nodes, setNodes] = useState(initialNodes) + useEffect(() => { + setNodes(initialNodes) + }, [initialNodes]) + + const [show3d, setShow3d] = useState(true) + const [rebuildKey, setRebuildKey] = useState(0) + + const [showObstacles, setShowObstacles] = useState(defaultShowObstacles) + const [wireframeOutput, setWireframeOutput] = useState(defaultWireframeOutput) + const [isOrthographic, setIsOrthographic] = useState(false) + + const [meshOpacity, setMeshOpacity] = useState(0.6) + const [shrinkBoxes, setShrinkBoxes] = useState(true) + const [boxShrinkAmount, setBoxShrinkAmount] = useState(0.1) + const [showBorders, setShowBorders] = useState(true) + + const rebuild = useCallback(() => setRebuildKey((k) => k + 1), []) + + const handleDeleteNodes = useCallback((nodesToDelete: CapacityMeshNode[]) => { + const nodesToDeleteSet = new Set(nodesToDelete) + setNodes((currentNodes) => + currentNodes.filter((n) => !nodesToDeleteSet.has(n)), + ) + }, []) + + const handleReset = () => { + setNodes(initialNodes) + } + + return ( + <> +
+
+ + + + + {/* Mesh opacity slider */} + {show3d && ( + + )} + + {/* Shrink boxes option */} + {show3d && ( + <> + + {shrinkBoxes && ( + + )} + + )} + + {/* Show borders option */} + {show3d && ( + + )} + + {nodes.length !== initialNodes.length && ( + + )} + +
+ Drag to orbit · Wheel to zoom · Right-drag to pan +
+
+ + {show3d && ( + + )} +
+ + {/* White margin at bottom of the page */} +
+ + ) +} diff --git a/lib/ThreeBoardView.tsx b/lib/ThreeBoardView.tsx new file mode 100644 index 0000000..cf4eaee --- /dev/null +++ b/lib/ThreeBoardView.tsx @@ -0,0 +1,558 @@ +import { useMemo, useRef, useState, useEffect } from "react" +import type { CapacityMeshNode, SimpleRouteJson } from "./types" +import * as THREE from "three" +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" +import { canonicalizeLayerOrder } from "../utils/canonicalizeLayerOrder" +import { buildPrismsFromNodes } from "../utils/buildPrismsFromNodes" +import { clamp01 } from "../utils/clamp01" +import { darkenColor } from "../utils/darkenColor" + +export const ThreeBoardView: React.FC<{ + nodes: CapacityMeshNode[] + srj?: SimpleRouteJson + layerThickness: number + height: number + showRoot: boolean + showObstacles: boolean + showOutput: boolean + wireframeOutput: boolean + meshOpacity: number + shrinkBoxes: boolean + boxShrinkAmount: number + showBorders: boolean + isOrthographic: boolean + onDeleteNodes: (nodes: CapacityMeshNode[]) => void +}> = ({ + nodes, + srj, + layerThickness, + height, + showRoot, + showObstacles, + showOutput, + wireframeOutput, + meshOpacity, + shrinkBoxes, + boxShrinkAmount, + showBorders, + isOrthographic, + onDeleteNodes, +}) => { + const containerRef = useRef(null) + const destroyRef = useRef<() => void>(() => {}) + const sceneRef = useRef(null) + const cameraRef = + useRef(null) + const outputGroupRef = useRef(null) + const controlsStateRef = + useRef<{ position: THREE.Vector3; target: THREE.Vector3; zoom: number } | null>( + null, + ) + + const [contextMenu, setContextMenu] = useState<{ + x: number + y: number + nodes: CapacityMeshNode[] + } | null>(null) + + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setContextMenu(null) // Close any existing menu + + const scene = sceneRef.current + const camera = cameraRef.current + const outputGroup = outputGroupRef.current + const el = containerRef.current + if (!el || !scene || !camera || !outputGroup) return + + const rect = el.getBoundingClientRect() + const pointer = new THREE.Vector2() + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + const raycaster = new THREE.Raycaster() + raycaster.setFromCamera(pointer, camera) + const intersects = raycaster.intersectObjects(outputGroup.children, true) + + if (intersects.length > 0 && intersects[0]) { + let intersectedObject: THREE.Object3D | null = intersects[0].object + while (intersectedObject && !intersectedObject.userData.nodes) { + intersectedObject = intersectedObject.parent + } + + if (intersectedObject && intersectedObject.userData.nodes) { + setContextMenu({ + x: event.clientX, + y: event.clientY, + nodes: intersectedObject.userData.nodes, + }) + } + } + } + + const handleDelete = (nodesToDelete: CapacityMeshNode[]) => { + onDeleteNodes(nodesToDelete) + setContextMenu(null) + } + + useEffect(() => { + const handleClick = () => setContextMenu(null) + window.addEventListener("click", handleClick) + return () => window.removeEventListener("click", handleClick) + }, []) + + const layerNames = useMemo(() => { + // Build from nodes (preferred, matches solver) and fall back to SRJ obstacle names + const fromNodes = canonicalizeLayerOrder(nodes.map((n) => n.layer)) + if (fromNodes.length) return fromNodes + const fromObs = canonicalizeLayerOrder( + (srj?.obstacles ?? []).flatMap((o) => o.layers ?? []), + ) + return fromObs.length ? fromObs : ["top"] + }, [nodes, srj]) + + const zIndexByLayerName = useMemo(() => { + const m = new Map() + layerNames.forEach((n, i) => m.set(n, i)) + return m + }, [layerNames]) + + const layerCount = layerNames.length || srj?.layerCount || 1 + + const prisms = useMemo( + () => buildPrismsFromNodes(nodes, layerCount), + [nodes, layerCount], + ) + + useEffect(() => { + let mounted = true + ;(async () => { + const el = containerRef.current + if (!el) return + if (!mounted) return + + destroyRef.current?.() + + const w = el.clientWidth || 800 + const h = el.clientHeight || height + + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + premultipliedAlpha: false, + }) + // Increase pixel ratio for better alphaHash quality + renderer.setPixelRatio(window.devicePixelRatio) + renderer.setSize(w, h) + el.innerHTML = "" + el.appendChild(renderer.domElement) + + const scene = new THREE.Scene() + sceneRef.current = scene + scene.background = new THREE.Color(0xf7f8fa) + + const fitBox = srj + ? { + minX: srj.bounds.minX, + maxX: srj.bounds.maxX, + minY: srj.bounds.minY, + maxY: srj.bounds.maxY, + z0: 0, + z1: layerCount, + } + : (() => { + if (prisms.length === 0) { + return { + minX: -10, + maxX: 10, + minY: -10, + maxY: 10, + z0: 0, + z1: layerCount, + } + } + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + for (const p of prisms) { + minX = Math.min(minX, p.minX) + maxX = Math.max(maxX, p.maxX) + minY = Math.min(minY, p.minY) + maxY = Math.max(maxY, p.maxY) + } + return { minX, maxX, minY, maxY, z0: 0, z1: layerCount } + })() + + const dx = fitBox.maxX - fitBox.minX + const dz = fitBox.maxY - fitBox.minY + const dy = (fitBox.z1 - fitBox.z0) * layerThickness + const size = Math.max(dx, dz, dy) + + let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera + if (isOrthographic) { + const aspect = w / h + const frustumSize = size * 1.2 + camera = new THREE.OrthographicCamera( + (frustumSize * aspect) / -2, + (frustumSize * aspect) / 2, + frustumSize / 2, + frustumSize / -2, + 0.1, + size * 20, + ) + } else { + camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10000) + } + cameraRef.current = camera + + const controls = new OrbitControls(camera, renderer.domElement) + controls.enableDamping = true + + const amb = new THREE.AmbientLight(0xffffff, 0.9) + scene.add(amb) + const dir = new THREE.DirectionalLight(0xffffff, 0.6) + dir.position.set(1, 2, 3) + scene.add(dir) + + const rootGroup = new THREE.Group() + const obstaclesGroup = new THREE.Group() + const outputGroup = new THREE.Group() + outputGroupRef.current = outputGroup + scene.add(rootGroup, obstaclesGroup, outputGroup) + + // Axes helper for orientation (similar to experiment) + const axes = new THREE.AxesHelper(50) + scene.add(axes) + + const colorRoot = 0x111827 + const colorOb = 0xef4444 + + // Palette for layer-span-based coloring + const spanPalette = [ + 0x0ea5e9, // cyan-ish + 0x22c55e, // green + 0xf97316, // orange + 0xa855f7, // purple + 0xfacc15, // yellow + 0x38bdf8, // light blue + 0xec4899, // pink + 0x14b8a6, // teal + ] + const spanColorMap = new Map() + let spanColorIndex = 0 + const getSpanColor = (z0: number, z1: number) => { + const key = `${z0}-${z1}` + let c = spanColorMap.get(key) + if (c == null) { + c = spanPalette[spanColorIndex % spanPalette.length]! + spanColorMap.set(key, c) + spanColorIndex++ + } + return c + } + + function makeBoxMesh( + b: { + minX: number + maxX: number + minY: number + maxY: number + z0: number + z1: number + }, + color: number, + wire: boolean, + nodes: CapacityMeshNode[], + opacity = 0.45, + borders = false, + ) { + const dx = b.maxX - b.minX + const dz = b.maxY - b.minY // map board Y -> three Z + const dy = (b.z1 - b.z0) * layerThickness + const cx = -((b.minX + b.maxX) / 2) // negate X to match expected orientation + const cz = (b.minY + b.maxY) / 2 + // Negate Y so z=0 is at top, higher z goes down + const cy = -((b.z0 + b.z1) / 2) * layerThickness + + const geom = new THREE.BoxGeometry(dx, dy, dz) + if (wire) { + const edges = new THREE.EdgesGeometry(geom) + const line = new THREE.LineSegments( + edges, + new THREE.LineBasicMaterial({ color }), + ) + line.position.set(cx, cy, cz) + line.userData = { nodes } + return line + } + const clampedOpacity = clamp01(opacity) + const mat = new THREE.MeshPhongMaterial({ + color, + opacity: clampedOpacity, + transparent: clampedOpacity < 1, + alphaHash: clampedOpacity < 1, + alphaToCoverage: true, + }) + + const mesh = new THREE.Mesh(geom, mat) + mesh.position.set(cx, cy, cz) + mesh.userData = { nodes } + + if (!borders) return mesh + + const edges = new THREE.EdgesGeometry(geom) + const borderColor = darkenColor(color, 0.6) + const line = new THREE.LineSegments( + edges, + new THREE.LineBasicMaterial({ color: borderColor }), + ) + line.position.set(cx, cy, cz) + + const group = new THREE.Group() + group.add(mesh) + group.add(line) + group.userData = { nodes } + return group + } + + // Root wireframe from SRJ bounds + if (srj && showRoot) { + const rootBox = { + minX: srj.bounds.minX, + maxX: srj.bounds.maxX, + minY: srj.bounds.minY, + maxY: srj.bounds.maxY, + z0: 0, + z1: layerCount, + } + rootGroup.add(makeBoxMesh(rootBox, colorRoot, true, [])) + } + + // Obstacles — rectangular only — one slab per declared layer + if (srj && showObstacles) { + for (const ob of srj.obstacles ?? []) { + if (ob.type !== "rect") continue + const minX = ob.center.x - ob.width / 2 + const maxX = ob.center.x + ob.width / 2 + const minY = ob.center.y - ob.height / 2 + const maxY = ob.center.y + ob.height / 2 + + // Prefer explicit zLayers; otherwise map layer names to indices + const zs = + ob.zLayers && ob.zLayers.length + ? Array.from(new Set(ob.zLayers)) + : (ob.layers ?? []) + .map((name) => zIndexByLayerName.get(name)) + .filter((z): z is number => typeof z === "number") + + for (const z of zs) { + if (z < 0 || z >= layerCount) continue + obstaclesGroup.add( + makeBoxMesh( + { minX, maxX, minY, maxY, z0: z, z1: z + 1 }, + colorOb, + false, + [], + 0.35, + false, + ), + ) + } + } + } + + // Output prisms from nodes (wireframe toggle like the experiment) + if (showOutput) { + for (const p of prisms) { + let box = p + if (shrinkBoxes && boxShrinkAmount > 0) { + const s = boxShrinkAmount + + const widthX = p.maxX - p.minX + const widthY = p.maxY - p.minY + + // Never shrink more on a side than allowed by the configured shrink amount + // while ensuring we don't shrink past a minimum dimension of "s" + const maxShrinkEachSideX = Math.max(0, (widthX - s) / 2) + const maxShrinkEachSideY = Math.max(0, (widthY - s) / 2) + + const shrinkX = Math.min(s, maxShrinkEachSideX) + const shrinkY = Math.min(s, maxShrinkEachSideY) + + const minX = p.minX + shrinkX + const maxX = p.maxX - shrinkX + const minY = p.minY + shrinkY + const maxY = p.maxY - shrinkY + + // Guard against any degenerate box + if (minX >= maxX || minY >= maxY) { + continue + } + + box = { ...p, minX, maxX, minY, maxY } + } + + const color = getSpanColor(p.z0, p.z1) + outputGroup.add( + makeBoxMesh( + box, + color, + wireframeOutput, + p.nodes, + meshOpacity, + showBorders && !wireframeOutput, + ), + ) + } + } + + if (controlsStateRef.current) { + camera.position.copy(controlsStateRef.current.position) + controls.target.copy(controlsStateRef.current.target) + if (isOrthographic) { + ;(camera as THREE.OrthographicCamera).zoom = + controlsStateRef.current.zoom + } + camera.updateProjectionMatrix() + controls.update() + } else { + // Fit camera + const target = new THREE.Vector3( + -((fitBox.minX + fitBox.maxX) / 2), // negate X to account for flipped axis + -dy / 2, // center of the inverted Y range + (fitBox.minY + fitBox.maxY) / 2, + ) + controls.target.copy(target) + + const dist = size * 2.0 + if (isOrthographic) { + // Top-down view + camera.position.copy(target).add(new THREE.Vector3(0, dist, 0)) + camera.lookAt(target) + } else { + // Camera looks from above-right-front, with negative Y being "up" (z=0 at top) + camera.position.set( + -(fitBox.maxX + dist * 0.6), // negate X to account for flipped axis + -dy / 2 + dist, // negative Y is up, so position above the center + fitBox.maxY + dist * 0.6, + ) + } + + camera.near = Math.max(0.1, size / 100) + camera.far = dist * 10 + size * 10 + camera.updateProjectionMatrix() + controls.update() + } + + const onResize = () => { + const W = el.clientWidth || w + const H = el.clientHeight || h + if (isOrthographic) { + const aspect = W / H + const frustumSize = size * 1.2 + const ocam = camera as THREE.OrthographicCamera + ocam.left = (frustumSize * aspect) / -2 + ocam.right = (frustumSize * aspect) / 2 + ocam.top = frustumSize / 2 + ocam.bottom = frustumSize / -2 + } else { + ;(camera as THREE.PerspectiveCamera).aspect = W / H + } + camera.updateProjectionMatrix() + renderer.setSize(W, H) + } + window.addEventListener("resize", onResize) + + let raf = 0 + const animate = () => { + raf = requestAnimationFrame(animate) + controls.update() + renderer.render(scene, camera) + } + animate() + + destroyRef.current = () => { + controlsStateRef.current = { + position: camera.position.clone(), + target: controls.target.clone(), + zoom: (camera as any).zoom ?? 1, + } + cancelAnimationFrame(raf) + window.removeEventListener("resize", onResize) + renderer.dispose() + el.innerHTML = "" + sceneRef.current = null + cameraRef.current = null + outputGroupRef.current = null + } + })() + + return () => { + mounted = false + destroyRef.current?.() + } + }, [ + srj, + prisms, + layerCount, + layerThickness, + height, + showObstacles, + wireframeOutput, + zIndexByLayerName, + meshOpacity, + shrinkBoxes, + boxShrinkAmount, + showBorders, + isOrthographic, + ]) + + return ( + <> + {contextMenu && ( +
e.stopPropagation()} // Prevent closing when clicking inside + > + +
+ )} +
+ + ) +} diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..748e830 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,11 @@ +export { CapacityNode3dDebugger } from './CapacityNode3dDebugger' +export type { + CapacityMeshNode, + CapacityMeshEdge, + CapacityMesh, + CapacityMeshNodeId, + SimpleRouteJson, + Obstacle, + SimpleRouteConnection, + TraceId +} from './types' \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..60d15b9 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,71 @@ +export type CapacityMeshNodeId = string + +export interface CapacityMesh { + nodes: CapacityMeshNode[] + edges: CapacityMeshEdge[] +} + +export interface CapacityMeshNode { + capacityMeshNodeId: string + center: { x: number; y: number } + width: number + height: number + layer: string + availableZ: number[] + + _depth?: number + + _completelyInsideObstacle?: boolean + _containsObstacle?: boolean + _containsTarget?: boolean + _targetConnectionName?: string + _strawNode?: boolean + _strawParentCapacityMeshNodeId?: CapacityMeshNodeId + + _adjacentNodeIds?: CapacityMeshNodeId[] + + _parent?: CapacityMeshNode +} + +export interface CapacityMeshEdge { + capacityMeshEdgeId: string + nodeIds: [CapacityMeshNodeId, CapacityMeshNodeId] +} + +export type TraceId = string + +export interface SimpleRouteJson { + layerCount: number + minTraceWidth: number + minViaDiameter?: number + obstacles: Obstacle[] + connections: Array + bounds: { minX: number; maxX: number; minY: number; maxY: number } + outline?: Array<{ x: number; y: number }> +} + +export interface Obstacle { + type: "rect" + layers: string[] + zLayers?: number[] + center: { x: number; y: number } + width: number + height: number + connectedTo: TraceId[] + netIsAssignable?: boolean + offBoardConnectsTo?: TraceId[] +} + +export interface SimpleRouteConnection { + name: string + netConnectionName?: string + nominalTraceWidth?: number + pointsToConnect: Array<{ + x: number + y: number + layer: string + pointId?: string + pcb_port_id?: string + }> + externallyConnectedPointIds?: string[][] +} \ No newline at end of file diff --git a/package.json b/package.json index dbf6892..4465a0d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,25 @@ { - "name": "trace-capacity-visualizer", - "private": true, - "scripts":{ - "start": "cosmos" - }, + "name": "@tscircuit/trace-capacity-visualizer", + "version": "0.0.1", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "cosmos", + "build:site": "cosmos-export", + "build": "tsup-node lib/index.ts --format esm --dts", + "format": "biome format --write .", + "format:check": "biome format ." + }, "devDependencies": { "@types/bun": "latest", + "@types/three": "^0.170.0", + "biome": "^0.3.3", "react": "^19.2.0", "react-cosmos": "^7.0.0", "react-cosmos-plugin-vite": "^7.0.0", "react-dom": "^19.2.0", + "three": "^0.171.0", + "tsup": "^8.5.1", "vite": "^7.2.2" }, "peerDependencies": { diff --git a/pages/example01.page.tsx b/pages/example01.page.tsx index cb57e36..db0f2c2 100644 --- a/pages/example01.page.tsx +++ b/pages/example01.page.tsx @@ -1,3 +1,90 @@ +import { CapacityNode3dDebugger } from "../lib/CapacityNode3dDebugger" +import type { CapacityMeshNode, SimpleRouteJson } from "../lib/types" + +import example01 from "../test-assets/example01.json" + export default function Example01() { - return "hi" + const calculateBoundsFromNodes = (nodes: CapacityMeshNode[]) => { + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity + + nodes.forEach((node) => { + const halfWidth = node.width / 2 + const halfHeight = node.height / 2 + + minX = Math.min(minX, node.center.x - halfWidth) + maxX = Math.max(maxX, node.center.x + halfWidth) + minY = Math.min(minY, node.center.y - halfHeight) + maxY = Math.max(maxY, node.center.y + halfHeight) + }) + + // Add some padding + const padding = 2 + return { + minX: minX - padding, + maxX: maxX + padding, + minY: minY - padding, + maxY: maxY + padding, + } + } + + const nodes = (example01.meshNodes || []) as CapacityMeshNode[] + const bounds = calculateBoundsFromNodes(nodes) + const simpleRouteJson: SimpleRouteJson = { + layerCount: 4, // Based on availableZ values in the data + minTraceWidth: 0.1, + obstacles: [], + connections: [], + bounds: bounds, + } + + return ( +
+
+

+ Capacity Node 3D Debugger - Example 01 +

+ + +
+
+ ) } \ No newline at end of file diff --git a/test-assets/example01.json b/test-assets/example01.json new file mode 100644 index 0000000..8a81179 --- /dev/null +++ b/test-assets/example01.json @@ -0,0 +1,1124 @@ +{ + "meshNodes": [ + { + "capacityMeshNodeId": "cmn_0", + "center": { + "x": 3.7847720000000002, + "y": -1.7996026029034198 + }, + "width": 9.93, + "height": 12.82920520580684, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_1", + "center": { + "x": -2.9227279999999993, + "y": -5.3575 + }, + "width": 3.4849999999999994, + "height": 19.285, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_2", + "center": { + "x": -2.3852279999999992, + "y": 11.145 + }, + "width": 4.56, + "height": 7.709999999999999, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_3", + "center": { + "x": 6.442272, + "y": 9.807500000000001 + }, + "width": 4.615, + "height": 10.385, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_4", + "center": { + "x": -7.321306647149842, + "y": -6.179968050960356 + }, + "width": 5.312157294299686, + "height": 4.025936101920712, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_5", + "center": { + "x": -9.957614, + "y": 7.372841999999999 + }, + "width": 5.084772000000001, + "height": 15.254316000000003, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_6", + "center": { + "x": -7.321306647149842, + "y": -11.903968050960355 + }, + "width": 5.312157294299686, + "height": 6.192063898079288, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_7", + "center": { + "x": 8.469281586933102, + "y": -11.60710260290342 + }, + "width": 8.061436826133797, + "height": 6.78579479419316, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_8", + "center": { + "x": -8.696306647149843, + "y": -2.2106580000000013 + }, + "width": 2.562157294299686, + "height": 3.912683999999997, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_9", + "center": { + "x": 0.8791675869331019, + "y": -11.60710260290342 + }, + "width": 4.118791173866203, + "height": 6.78579479419316, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_10", + "center": { + "x": 2.8747719999999974, + "y": 9.274999999999999 + }, + "width": 2.520000000000005, + "height": 0.8400000000000016, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_11", + "center": { + "x": 10.624886, + "y": -6.19060260290342 + }, + "width": 3.750228, + "height": 4.04720520580684, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_12", + "center": { + "x": 0.7547719999999978, + "y": 9.274999999999999 + }, + "width": 1.7199999999999944, + "height": 0.8400000000000016, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_13", + "center": { + "x": -11.238692647149843, + "y": -13.664726821657975 + }, + "width": 2.522614705700315, + "height": 2.670546356684051, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_14", + "center": { + "x": -11.238692647149843, + "y": -9.829453643315949 + }, + "width": 2.522614705700315, + "height": 2, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_15", + "center": { + "x": -11.238692647149843, + "y": -6.329453643315949 + }, + "width": 2.522614705700315, + "height": 2, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_16", + "center": { + "x": -11.238692647149843, + "y": -2.829453643315949 + }, + "width": 2.522614705700315, + "height": 2, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_17", + "center": { + "x": 3.6885631738662035, + "y": -13.35710260290342 + }, + "width": 1.5, + "height": 3.2857947941931602, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_18", + "center": { + "x": 2.6799999999999997, + "y": 11.815000000000001 + }, + "width": 2.520000000000005, + "height": 0.8400000000000016, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_19", + "center": { + "x": 10.624886, + "y": 14.0365 + }, + "width": 3.750228, + "height": 1.9269999999999996, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_20", + "center": { + "x": -4.172727999999999, + "y": 5.812500000000002 + }, + "width": 0.9849999999999994, + "height": 2.9549999999999983, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_21", + "center": { + "x": -2.4302279999999996, + "y": 5.7875000000000005 + }, + "width": 2.5, + "height": 3.005000000000001, + "layer": "top", + "availableZ": [ + 1, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_22", + "center": { + "x": -6.040227999999999, + "y": 14.0365 + }, + "width": 2.75, + "height": 1.9269999999999996, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_23", + "center": { + "x": -2.4302279999999996, + "y": 5.7875000000000005 + }, + "width": 2.5, + "height": 3.005000000000001, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_24", + "center": { + "x": -0.6427279999999997, + "y": 5.952500000000001 + }, + "width": 1.0749999999999997, + "height": 2.6750000000000007, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_25", + "center": { + "x": 0.6573859999999989, + "y": 11.815000000000001 + }, + "width": 1.5252279999999967, + "height": 0.8400000000000016, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_26", + "center": { + "x": 0.32477200000000095, + "y": 13.6175 + }, + "width": 0.8600000000000008, + "height": 2.7650000000000006, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_27", + "center": { + "x": -11.988692647149843, + "y": -11.579453643315949 + }, + "width": 1.022614705700315, + "height": 1.5, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_28", + "center": { + "x": -11.988692647149843, + "y": -8.079453643315949 + }, + "width": 1.022614705700315, + "height": 1.5, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_29", + "center": { + "x": -11.988692647149843, + "y": -4.579453643315949 + }, + "width": 1.022614705700315, + "height": 1.5, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_30", + "center": { + "x": -11.988692647149843, + "y": -1.041884821657976 + }, + "width": 1.022614705700315, + "height": 1.575137643315946, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_31", + "center": { + "x": 2.0147720000000002, + "y": 7.494999999999999 + }, + "width": 0.8399999999999999, + "height": 2.719999999999999, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_32", + "center": { + "x": 3.2847720000000002, + "y": 6.734999999999999 + }, + "width": 1.6999999999999997, + "height": 4.239999999999998, + "layer": "top", + "availableZ": [ + 1, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_33", + "center": { + "x": 3.2847720000000002, + "y": 6.734999999999999 + }, + "width": 1.6999999999999997, + "height": 4.239999999999998, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_34", + "center": { + "x": 0.7447720000000004, + "y": 6.734999999999999 + }, + "width": 1.6999999999999997, + "height": 4.239999999999998, + "layer": "top", + "availableZ": [ + 1 + ] + }, + { + "capacityMeshNodeId": "cmn_35", + "center": { + "x": 0.7447720000000004, + "y": 6.734999999999999 + }, + "width": 1.6999999999999997, + "height": 4.239999999999998, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_36", + "center": { + "x": 0.7447720000000004, + "y": 6.734999999999999 + }, + "width": 1.6999999999999997, + "height": 4.239999999999998, + "layer": "top", + "availableZ": [ + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_37", + "center": { + "x": 2.0147720000000002, + "y": 10.545 + }, + "width": 0.8399999999999999, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_38", + "center": { + "x": 2.3522720000000006, + "y": 14.467500000000001 + }, + "width": 3.1949999999999985, + "height": 1.0649999999999995, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_39", + "center": { + "x": 1.0873859999999993, + "y": 13.085 + }, + "width": 0.6652279999999959, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_40", + "center": { + "x": 2.0147720000000002, + "y": 13.085 + }, + "width": 0.8399999999999999, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_41", + "center": { + "x": -6.040227999999999, + "y": 0.643 + }, + "width": 2.75, + "height": 0.5399999999999996, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_42", + "center": { + "x": 0.324772000000001, + "y": 14.467500000000001 + }, + "width": 0.8600000000000009, + "height": 1.0650000000000013, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_43", + "center": { + "x": 0.7447720000000002, + "y": 6.734999999999999 + }, + "width": 1.7000000000000002, + "height": 0.8399999999999999, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_44", + "center": { + "x": 11.999886, + "y": 8.463000000000001 + }, + "width": 1.000228, + "height": 9.22, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_45", + "center": { + "x": 11.999886, + "y": -0.09699999999999953 + }, + "width": 1.000228, + "height": 7.9, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_46", + "center": { + "x": 10.124772, + "y": 0.643 + }, + "width": 2.7500000000000018, + "height": 0.5399999999999996, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_47", + "center": { + "x": -6.356893060790263, + "y": -8.500436101920712 + }, + "width": 0.39999999999999947, + "height": 0.615000000000002, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_48", + "center": { + "x": -6.040227999999999, + "y": 10.803 + }, + "width": 2.75, + "height": 0.5399999999999991, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_49", + "center": { + "x": -6.040227999999999, + "y": 8.263000000000002 + }, + "width": 2.75, + "height": 0.5400000000000009, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_50", + "center": { + "x": -6.040227999999999, + "y": 5.723000000000001 + }, + "width": 2.75, + "height": 0.5400000000000009, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_51", + "center": { + "x": -6.040227999999999, + "y": 3.183 + }, + "width": 2.75, + "height": 0.5400000000000009, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_52", + "center": { + "x": -6.040227999999999, + "y": -1.8969999999999998 + }, + "width": 2.75, + "height": 0.54, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_53", + "center": { + "x": 10.124772, + "y": -1.8969999999999998 + }, + "width": 2.75, + "height": 0.54, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_54", + "center": { + "x": 10.124772, + "y": 3.183 + }, + "width": 2.75, + "height": 0.5400000000000009, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_55", + "center": { + "x": 10.124772, + "y": 5.723000000000001 + }, + "width": 2.75, + "height": 0.5400000000000009, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_56", + "center": { + "x": 10.124772, + "y": 8.263000000000002 + }, + "width": 2.75, + "height": 0.5400000000000009, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_57", + "center": { + "x": 10.124772, + "y": 10.803 + }, + "width": 2.75, + "height": 0.5399999999999991, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_58", + "center": { + "x": 2.0147720000000002, + "y": 5.375 + }, + "width": 0.8399999999999999, + "height": 1.5199999999999996, + "layer": "top", + "availableZ": [ + 0, + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_59", + "center": { + "x": 3.6885631738662035, + "y": -9.96420520580684 + }, + "width": 1.5, + "height": 0.5, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_60", + "center": { + "x": -2.4302279999999996, + "y": 5.7875 + }, + "width": 2.5, + "height": 0.8050000000000006, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_61", + "center": { + "x": 3.2847720000000002, + "y": 6.734999999999999 + }, + "width": 1.6999999999999997, + "height": 0.8399999999999999, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_62", + "center": { + "x": -8.567139177544973, + "y": -8.500436101920712 + }, + "width": 2.820492233509423, + "height": 0.615000000000002, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_63", + "center": { + "x": -5.111060530395131, + "y": -8.500436101920712 + }, + "width": 0.8916650607902632, + "height": 0.615000000000002, + "layer": "top", + "availableZ": [ + 0 + ] + }, + { + "capacityMeshNodeId": "cmn_64", + "center": { + "x": 3.6885631738662035, + "y": -9.96420520580684 + }, + "width": 1.5, + "height": 0.5, + "layer": "top", + "availableZ": [ + 1 + ] + }, + { + "capacityMeshNodeId": "cmn_65", + "center": { + "x": -7.321306647149842, + "y": -8.500436101920712 + }, + "width": 5.312157294299686, + "height": 0.615000000000002, + "layer": "top", + "availableZ": [ + 1 + ] + }, + { + "capacityMeshNodeId": "cmn_66", + "center": { + "x": 3.6885631738662035, + "y": -9.96420520580684 + }, + "width": 1.5, + "height": 0.5, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_67", + "center": { + "x": -7.321306647149842, + "y": -8.500436101920712 + }, + "width": 5.312157294299686, + "height": 0.615000000000002, + "layer": "top", + "availableZ": [ + 2 + ] + }, + { + "capacityMeshNodeId": "cmn_68", + "center": { + "x": 3.6885631738662035, + "y": -9.96420520580684 + }, + "width": 1.5, + "height": 0.5, + "layer": "top", + "availableZ": [ + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_69", + "center": { + "x": -7.321306647149842, + "y": -8.500436101920712 + }, + "width": 5.312157294299686, + "height": 0.615000000000002, + "layer": "top", + "availableZ": [ + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_70", + "center": { + "x": 0.7447720000000004, + "y": 10.545 + }, + "width": 1.6999999999999997, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_71", + "center": { + "x": 3.2847720000000002, + "y": 10.545 + }, + "width": 1.6999999999999997, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_72", + "center": { + "x": 3.2847720000000002, + "y": 13.085 + }, + "width": 1.6999999999999997, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_73", + "center": { + "x": -6.040227999999999, + "y": 4.453000000000001 + }, + "width": 2.75, + "height": 17.240000000000002, + "layer": "top", + "availableZ": [ + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_74", + "center": { + "x": 10.124772, + "y": 4.453000000000001 + }, + "width": 2.75, + "height": 17.240000000000002, + "layer": "top", + "availableZ": [ + 1, + 2, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_75", + "center": { + "x": 0.32477200000000095, + "y": 13.617500000000001 + }, + "width": 0.8600000000000008, + "height": 2.7650000000000006, + "layer": "top", + "availableZ": [ + 1, + 3 + ] + }, + { + "capacityMeshNodeId": "cmn_76", + "center": { + "x": 1.1747720000000008, + "y": 13.085 + }, + "width": 0.839999999999999, + "height": 1.6999999999999993, + "layer": "top", + "availableZ": [ + 1, + 3 + ] + } + ] +} \ No newline at end of file diff --git a/test.html b/test.html new file mode 100644 index 0000000..4dd1f02 --- /dev/null +++ b/test.html @@ -0,0 +1,87 @@ + + + + + + CapacityNode3dDebugger Test + + + +
+

Capacity Node 3D Debugger - Test Page

+ +
+

Component Status

+

Status: ✅ Component created successfully

+

Types: ✅ TypeScript compilation successful

+

Dependencies: ✅ Three.js and @types/three installed

+

Features: 3D visualization, interactive controls, layer management

+
+ +
+

Implementation Summary

+
    +
  • CapacityNode3dDebugger.tsx - Main 3D visualization component
  • +
  • types.ts - TypeScript type definitions
  • +
  • index.ts - Export barrel for clean imports
  • +
  • example01.page.tsx - Example page with data loading
  • +
  • README.md - Documentation and usage instructions
  • +
+
+ +
+

Next Steps

+
    +
  1. Start the development server: npm start
  2. +
  3. Navigate to the example page in your browser
  4. +
  5. Click "Show 3D" to render the visualization
  6. +
  7. Use mouse controls to interact with the 3D scene
  8. +
+
+ +
+

Usage Instructions

+
    +
  • Click "Show 3D" to render the 3D visualization
  • +
  • Use mouse to orbit, wheel to zoom, right-drag to pan
  • +
  • Toggle different layers and visualization options using the controls
  • +
  • Adjust opacity and box shrinking for better visualization
  • +
+
+
+ + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..3ec8bb3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", + "lib": ["ESNext", "DOM"], + "target": "ES2015", "module": "Preserve", "moduleDetection": "force", "jsx": "react-jsx", @@ -24,6 +24,10 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + + // Enable ES module interop and iteration + "esModuleInterop": true, + "downlevelIteration": true } } diff --git a/utils/buildPrismsFromNodes.ts b/utils/buildPrismsFromNodes.ts new file mode 100644 index 0000000..b13fdd2 --- /dev/null +++ b/utils/buildPrismsFromNodes.ts @@ -0,0 +1,100 @@ +import type { CapacityMeshNode } from "../lib/types" +import { contiguousRuns } from "./contiguousRuns" + +/** Build prisms by grouping identical XY nodes across contiguous Z */ +export function buildPrismsFromNodes( + nodes: CapacityMeshNode[], + fallbackLayerCount: number, +): Array<{ + minX: number + maxX: number + minY: number + maxY: number + z0: number + z1: number + nodes: CapacityMeshNode[] +}> { + const xyKey = (n: CapacityMeshNode) => + `${n.center.x.toFixed(8)}|${n.center.y.toFixed(8)}|${n.width.toFixed( + 8, + )}|${n.height.toFixed(8)}` + const azKey = (n: CapacityMeshNode) => { + const zs = ( + n.availableZ && n.availableZ.length + ? Array.from(new Set(n.availableZ)) + : [0] + ).sort((a, b) => a - b) + return `zset:${zs.join(",")}` + } + const key = (n: CapacityMeshNode) => `${xyKey(n)}|${azKey(n)}` + + const groups = new Map< + string, + { + cx: number + cy: number + w: number + h: number + zs: number[] + nodes: CapacityMeshNode[] + } + >() + for (const n of nodes) { + const k = key(n) + const zlist = n.availableZ?.length ? n.availableZ : [0] + const g = groups.get(k) + if (g) { + g.zs.push(...zlist) + g.nodes.push(n) + } else + groups.set(k, { + cx: n.center.x, + cy: n.center.y, + w: n.width, + h: n.height, + zs: [...zlist], + nodes: [n], + }) + } + + const prisms: Array<{ + minX: number + maxX: number + minY: number + maxY: number + z0: number + z1: number + nodes: CapacityMeshNode[] + }> = [] + for (const g of Array.from(groups.values())) { + const minX = g.cx - g.w / 2 + const maxX = g.cx + g.w / 2 + const minY = g.cy - g.h / 2 + const maxY = g.cy + g.h / 2 + const runs = contiguousRuns(g.zs) + if (runs.length === 0) { + prisms.push({ + minX, + maxX, + minY, + maxY, + z0: 0, + z1: Math.max(1, fallbackLayerCount), + nodes: g.nodes, + }) + } else { + for (const r of runs) { + prisms.push({ + minX, + maxX, + minY, + maxY, + z0: r[0]!, + z1: r[r.length - 1]! + 1, + nodes: g.nodes, + }) + } + } + } + return prisms +} diff --git a/utils/canonicalizeLayerOrder.ts b/utils/canonicalizeLayerOrder.ts new file mode 100644 index 0000000..1820aa9 --- /dev/null +++ b/utils/canonicalizeLayerOrder.ts @@ -0,0 +1,10 @@ +import { layerSortKey } from "./layerSortKey" + +export function canonicalizeLayerOrder(names: string[]) { + return Array.from(new Set(names)).sort((a, b) => { + const ka = layerSortKey(a) + const kb = layerSortKey(b) + if (ka !== kb) return ka - kb + return a.localeCompare(b) + }) +} diff --git a/utils/clamp01.ts b/utils/clamp01.ts new file mode 100644 index 0000000..4ef2a0e --- /dev/null +++ b/utils/clamp01.ts @@ -0,0 +1,3 @@ +export function clamp01(x: number) { + return Math.max(0, Math.min(1, x)) +} diff --git a/utils/contiguousRuns.ts b/utils/contiguousRuns.ts new file mode 100644 index 0000000..f378f4e --- /dev/null +++ b/utils/contiguousRuns.ts @@ -0,0 +1,15 @@ +export function contiguousRuns(nums: number[]) { + const zs = Array.from(new Set(nums)).sort((a, b) => a - b) + if (zs.length === 0) return [] as number[][] + const groups: number[][] = [] + let run: number[] = [zs[0]!] + for (let i = 1; i < zs.length; i++) { + if (zs[i] === zs[i - 1]! + 1) run.push(zs[i]!) + else { + groups.push(run) + run = [zs[i]!] + } + } + groups.push(run) + return groups +} diff --git a/utils/darkenColor.ts b/utils/darkenColor.ts new file mode 100644 index 0000000..c647db8 --- /dev/null +++ b/utils/darkenColor.ts @@ -0,0 +1,9 @@ +export function darkenColor(hex: number, factor = 0.6): number { + const r = ((hex >> 16) & 0xff) * factor + const g = ((hex >> 8) & 0xff) * factor + const b = (hex & 0xff) * factor + const cr = Math.max(0, Math.min(255, Math.round(r))) + const cg = Math.max(0, Math.min(255, Math.round(g))) + const cb = Math.max(0, Math.min(255, Math.round(b))) + return (cr << 16) | (cg << 8) | cb +} diff --git a/utils/layerSortKey.ts b/utils/layerSortKey.ts new file mode 100644 index 0000000..692e6c4 --- /dev/null +++ b/utils/layerSortKey.ts @@ -0,0 +1,8 @@ +export function layerSortKey(name: string) { + const n = name.toLowerCase() + if (n === "top") return -1_000_000 + if (n === "bottom") return 1_000_000 + const m = /^inner(\d+)$/i.exec(n) + if (m) return parseInt(m[1]!, 10) || 0 + return 100 + n.charCodeAt(0) +}