diff --git a/lib/components/primitive-components/Constraint.ts b/lib/components/primitive-components/Constraint.ts index 913e283ca..6be20a211 100644 --- a/lib/components/primitive-components/Constraint.ts +++ b/lib/components/primitive-components/Constraint.ts @@ -1,4 +1,5 @@ import { constraintProps } from "@tscircuit/props" +import { distance } from "circuit-json" import { z } from "zod" import { PrimitiveComponent } from "../base-components/PrimitiveComponent" @@ -12,15 +13,54 @@ const edgeSpecifiers = [ export type EdgeSpecifier = (typeof edgeSpecifiers)[number] -export class Constraint extends PrimitiveComponent { +/** + * Extended constraint props that add centerX/centerY for absolute positioning + * of constraint clusters. These will be upstreamed to @tscircuit/props. + */ +export const extendedConstraintProps = z.union([ + z.object({ + pcb: z.literal(true).optional(), + xDist: distance, + left: z.string(), + right: z.string(), + edgeToEdge: z.literal(true).optional(), + centerToCenter: z.literal(true).optional(), + centerX: distance.optional(), + centerY: distance.optional(), + }), + z.object({ + pcb: z.literal(true).optional(), + yDist: distance, + top: z.string(), + bottom: z.string(), + edgeToEdge: z.literal(true).optional(), + centerToCenter: z.literal(true).optional(), + centerX: distance.optional(), + centerY: distance.optional(), + }), + z.object({ + pcb: z.literal(true).optional(), + sameY: z.literal(true).optional(), + for: z.array(z.string()), + }), + z.object({ + pcb: z.literal(true).optional(), + sameX: z.literal(true).optional(), + for: z.array(z.string()), + }), +]) + +export class Constraint extends PrimitiveComponent< + typeof extendedConstraintProps +> { get config() { return { componentName: "Constraint", - zodProps: constraintProps, + zodProps: extendedConstraintProps, } } - constructor(props: z.input) { + constructor(props: z.input) { super(props) if ("xdist" in props || "ydist" in props) { if (!("edgeToEdge" in props) && !("centerToCenter" in props)) { diff --git a/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyComponentConstraintClusters.ts b/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyComponentConstraintClusters.ts index 3097dd93f..2be435f5b 100644 --- a/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyComponentConstraintClusters.ts +++ b/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyComponentConstraintClusters.ts @@ -1,12 +1,17 @@ import type { Group } from "../Group" import type { Constraint } from "../../Constraint" import type { PackInput } from "calculate-packing" +import { length } from "circuit-json" import * as kiwi from "@lume/kiwi" export type ClusterInfo = { componentIds: string[] constraints: Constraint[] relativeCenters?: Record + /** Absolute center X position for the cluster (from centerX prop) */ + absoluteCenterX?: number + /** Absolute center Y position for the cluster (from centerY prop) */ + absoluteCenterY?: number } export const applyComponentConstraintClusters = ( @@ -36,7 +41,13 @@ export const applyComponentConstraintClusters = ( const getIdFromSelector = (sel: string): string | undefined => { const name = sel.startsWith(".") ? sel.slice(1) : sel const child = group.children.find((c) => (c as any).name === name) - return child?.pcb_component_id ?? undefined + if (!child) return undefined + // For regular components, use pcb_component_id + if (child.pcb_component_id) return child.pcb_component_id + // For groups, use source_group_id (matches pack input componentId for groups) + if ((child as Group).source_group_id) + return (child as Group).source_group_id! + return undefined } for (const constraint of constraints) { @@ -245,6 +256,18 @@ export const applyComponentConstraintClusters = ( }) info.relativeCenters = relCenters + + // Extract absolute center positioning from constraints (centerX/centerY) + for (const constraint of info.constraints) { + const props = constraint._parsedProps as any + if ("centerX" in props && props.centerX != null) { + info.absoluteCenterX = length.parse(props.centerX) + } + if ("centerY" in props && props.centerY != null) { + info.absoluteCenterY = length.parse(props.centerY) + } + } + clusterMap[info.componentIds[0]] = info } diff --git a/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyPackOutput.ts b/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyPackOutput.ts index dac5317c8..fc00a5273 100644 --- a/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyPackOutput.ts +++ b/lib/components/primitive-components/Group/Group_doInitialPcbLayoutPack/applyPackOutput.ts @@ -66,40 +66,123 @@ export const applyPackOutput = ( const cluster = clusterMap[componentId] if (cluster) { + // Use absolute center from centerX/centerY if specified, otherwise + // use the position determined by the pack solver + const clusterCenter = { + x: cluster.absoluteCenterX ?? center.x, + y: cluster.absoluteCenterY ?? center.y, + } const rotationDegrees = ccwRotationDegrees ?? ccwRotationOffset ?? 0 const angleRad = (rotationDegrees * Math.PI) / 180 for (const memberId of cluster.componentIds) { const rel = cluster.relativeCenters![memberId] if (!rel) continue - db.pcb_component.update(memberId, { - position_mode: "packed", - }) const rotatedRel = { x: rel.x * Math.cos(angleRad) - rel.y * Math.sin(angleRad), y: rel.x * Math.sin(angleRad) + rel.y * Math.cos(angleRad), } + + // Try as a regular pcb_component first const member = db.pcb_component.get(memberId) - if (!member) continue - const originalCenter = member.center - const transformMatrix = compose( - group._computePcbGlobalTransformBeforeLayout(), - translate(center.x + rotatedRel.x, center.y + rotatedRel.y), - rotate(angleRad), - translate(-originalCenter.x, -originalCenter.y), - ) - const related = db - .toArray() - .filter( - (elm) => - "pcb_component_id" in elm && elm.pcb_component_id === memberId, + if (member) { + db.pcb_component.update(memberId, { + position_mode: "packed", + }) + const originalCenter = member.center + const transformMatrix = compose( + group._computePcbGlobalTransformBeforeLayout(), + translate( + clusterCenter.x + rotatedRel.x, + clusterCenter.y + rotatedRel.y, + ), + rotate(angleRad), + translate(-originalCenter.x, -originalCenter.y), ) - transformPCBElements(related as any, transformMatrix) - updateCadRotation({ - db, - pcbComponentId: memberId, - rotationDegrees, - layer: member.layer, - }) + const related = db + .toArray() + .filter( + (elm) => + "pcb_component_id" in elm && elm.pcb_component_id === memberId, + ) + transformPCBElements(related as any, transformMatrix) + updateCadRotation({ + db, + pcbComponentId: memberId, + rotationDegrees, + layer: member.layer, + }) + continue + } + + // Try as a group (memberId is source_group_id) + const pcbGroup = db.pcb_group + .list() + .find((g) => g.source_group_id === memberId) + if (pcbGroup) { + const originalCenter = pcbGroup.center + const transformMatrix = compose( + group._computePcbGlobalTransformBeforeLayout(), + translate( + clusterCenter.x + rotatedRel.x, + clusterCenter.y + rotatedRel.y, + ), + rotate(angleRad), + translate(-originalCenter.x, -originalCenter.y), + ) + const relatedElements = db.toArray().filter((elm) => { + if ("source_group_id" in elm && elm.source_group_id) { + if (elm.source_group_id === memberId) return true + if (isDescendantGroup(db, elm.source_group_id, memberId)) + return true + } + if ("source_component_id" in elm && elm.source_component_id) { + const sourceComponent = db.source_component.get( + elm.source_component_id, + ) + if (sourceComponent?.source_group_id) { + if (sourceComponent.source_group_id === memberId) return true + if ( + isDescendantGroup( + db, + sourceComponent.source_group_id, + memberId, + ) + ) + return true + } + } + if ("pcb_component_id" in elm && elm.pcb_component_id) { + const pcbComp = db.pcb_component.get(elm.pcb_component_id) + if (pcbComp?.source_component_id) { + const sourceComp = db.source_component.get( + pcbComp.source_component_id, + ) + if (sourceComp?.source_group_id) { + if (sourceComp.source_group_id === memberId) return true + if ( + isDescendantGroup(db, sourceComp.source_group_id, memberId) + ) + return true + } + } + } + return false + }) + for (const elm of relatedElements) { + if (elm.type === "pcb_component") { + db.pcb_component.update(elm.pcb_component_id, { + position_mode: "packed", + }) + } + } + transformPCBElements(relatedElements as any, transformMatrix) + db.pcb_group.update(pcbGroup.pcb_group_id, { + center: { + x: clusterCenter.x + rotatedRel.x, + y: clusterCenter.y + rotatedRel.y, + }, + }) + } } continue } diff --git a/lib/fiber/intrinsic-jsx.ts b/lib/fiber/intrinsic-jsx.ts index 714e8e0e8..dc4177feb 100644 --- a/lib/fiber/intrinsic-jsx.ts +++ b/lib/fiber/intrinsic-jsx.ts @@ -72,7 +72,10 @@ export interface TscircuitElements { fabricationnotetext: Props.FabricationNoteTextProps fabricationnotepath: Props.FabricationNotePathProps fabricationnotedimension: Props.FabricationNoteDimensionProps - constraint: Props.ConstraintProps + constraint: Props.ConstraintProps & { + centerX?: number | string + centerY?: number | string + } constrainedlayout: Props.ConstrainedLayoutProps battery: Props.BatteryProps pinheader: Props.PinHeaderProps diff --git a/tests/features/component-constraints/__snapshots__/component-constraints02-pcb.snap.svg b/tests/features/component-constraints/__snapshots__/component-constraints02-pcb.snap.svg new file mode 100644 index 000000000..15965ee9c --- /dev/null +++ b/tests/features/component-constraints/__snapshots__/component-constraints02-pcb.snap.svg @@ -0,0 +1 @@ +R1R2R3R4 \ No newline at end of file diff --git a/tests/features/component-constraints/__snapshots__/component-constraints03-pcb.snap.svg b/tests/features/component-constraints/__snapshots__/component-constraints03-pcb.snap.svg new file mode 100644 index 000000000..5ebe8aa6d --- /dev/null +++ b/tests/features/component-constraints/__snapshots__/component-constraints03-pcb.snap.svg @@ -0,0 +1 @@ +R1R2 \ No newline at end of file diff --git a/tests/features/component-constraints/component-constraints02.test.tsx b/tests/features/component-constraints/component-constraints02.test.tsx new file mode 100644 index 000000000..fe9f49735 --- /dev/null +++ b/tests/features/component-constraints/component-constraints02.test.tsx @@ -0,0 +1,59 @@ +import { test, expect } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("pcb constraint xDist between groups with centerX", async () => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + + + + + + + , + ) + + await circuit.renderUntilSettled() + + // Get pcb_groups for group1 and group2 + const sourceGroups = circuit.db.source_group.list() + const group1Source = sourceGroups.find((g) => g.name === "group1")! + const group2Source = sourceGroups.find((g) => g.name === "group2")! + + expect(group1Source).toBeTruthy() + expect(group2Source).toBeTruthy() + + const pcbGroups = circuit.db.pcb_group.list() + const group1Pcb = pcbGroups.find( + (g) => g.source_group_id === group1Source.source_group_id, + )! + const group2Pcb = pcbGroups.find( + (g) => g.source_group_id === group2Source.source_group_id, + )! + + expect(group1Pcb).toBeTruthy() + expect(group2Pcb).toBeTruthy() + + // The center of the pair should be at x=0 + // group1 should be at x=-10mm and group2 at x=+10mm (or vice versa) + const midpointX = (group1Pcb.center.x + group2Pcb.center.x) / 2 + expect(Math.abs(midpointX)).toBeLessThan(0.5) // Close to 0 + + // The distance between group centers should be ~20mm + const xDistance = Math.abs(group2Pcb.center.x - group1Pcb.center.x) + expect(xDistance).toBeCloseTo(20, 0) + + expect(circuit).toMatchPcbSnapshot(import.meta.path) +}) diff --git a/tests/features/component-constraints/component-constraints03.test.tsx b/tests/features/component-constraints/component-constraints03.test.tsx new file mode 100644 index 000000000..144aefc35 --- /dev/null +++ b/tests/features/component-constraints/component-constraints03.test.tsx @@ -0,0 +1,43 @@ +import { test, expect } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("pcb constraint xDist with centerX for individual components", async () => { + const { circuit } = getTestFixture() + + circuit.add( + + + + + , + ) + + await circuit.renderUntilSettled() + + const r1Source = circuit.db.source_component.getWhere({ name: "R1" })! + const r2Source = circuit.db.source_component.getWhere({ name: "R2" })! + + const r1Pcb = circuit.db.pcb_component.getWhere({ + source_component_id: r1Source.source_component_id, + })! + const r2Pcb = circuit.db.pcb_component.getWhere({ + source_component_id: r2Source.source_component_id, + })! + + // The center of the pair should be at x=0 + const midpointX = (r1Pcb.center.x + r2Pcb.center.x) / 2 + expect(Math.abs(midpointX)).toBeLessThan(0.5) + + // The distance between centers should be 10mm + const xDistance = Math.abs(r2Pcb.center.x - r1Pcb.center.x) + expect(xDistance).toBeCloseTo(10, 0) + + expect(circuit).toMatchPcbSnapshot(import.meta.path) +})