import { AdvancedBrepShapeRepresentation, Axis2Placement3D, CartesianPoint, Direction, Entity, ManifoldSolidBrep, Ref, Repository, Unknown, parseRepository, stepStr, } from "stepts" import { eid } from "stepts" import { EXCLUDED_ENTITY_TYPES } from "./step-model-merger/excluded-entity-types" import { asVector3, toRadians, transformDirection, transformPoint, rotateVector, } from "./step-model-merger/vector-utils" import { readStepFile } from "./step-model-merger/read-step-file" import type { CadComponent, PcbComponent, MergeStepModelOptions, MergeStepModelResult, MergeTransform, } from "./step-model-merger/types" export type { MergeStepModelOptions, MergeStepModelResult, } from "./step-model-merger/types" export async function mergeExternalStepModels( options: MergeStepModelOptions, ): Promise { const { repo, circuitJson, boardThickness, fsMap } = options const cadComponents = (circuitJson as CadComponent[]).filter( (item) => item?.type === "cad_component" && typeof item.model_step_url === "string", ) const pcbComponentMap = new Map() for (const item of circuitJson as (CadComponent | PcbComponent)[]) { if (item?.type === "pcb_component" && item.pcb_component_id) { pcbComponentMap.set(item.pcb_component_id, item) } } const solids: Ref[] = [] const handledComponentIds = new Set() const handledPcbComponentIds = new Set() const importedModels = new Map() for (const component of cadComponents) { const componentId = component.cad_component_id ?? "" const stepUrl = component.model_step_url! try { let importedModel = importedModels.get(stepUrl) if (!importedModel) { const stepText = fsMap?.[stepUrl] ?? (await readStepFile(stepUrl)) if (!stepText.trim()) { throw new Error("STEP file is empty") } importedModel = importStepModelOnce(repo, stepText, stepUrl) importedModels.set(stepUrl, importedModel) } const pcbComponent = component.pcb_component_id ? pcbComponentMap.get(component.pcb_component_id) : undefined const layer = pcbComponent?.layer?.toLowerCase() const transform: MergeTransform = { translation: asVector3(component.position), rotation: asVector3(component.rotation), } const componentSolids = createMappedStepModelInstance({ repo, importedModel, transform, placement: { layer, boardThickness, }, }) if (componentSolids.length > 0) { if (componentId) { handledComponentIds.add(componentId) } const pcbComponentId = component.pcb_component_id if (pcbComponentId) { handledPcbComponentIds.add(pcbComponentId) } } solids.push(...componentSolids) } catch (error) { console.warn(`Failed to merge STEP model from ${stepUrl}:`, error) } } return { solids, handledComponentIds, handledPcbComponentIds } } type PlacementOptions = { layer?: string boardThickness?: number } type ImportedStepModel = { entries: RepositoryEntry[] representationMap: Ref } class RepresentationMap extends Entity { readonly type = "REPRESENTATION_MAP" constructor( public mappingOrigin: Ref, public mappedRepresentation: Ref, ) { super() } toStep(): string { return `REPRESENTATION_MAP(${this.mappingOrigin},${this.mappedRepresentation})` } } class MappedItem extends Entity { readonly type = "MAPPED_ITEM" constructor( public name: string, public mappingSource: Ref, public mappingTarget: Ref, ) { super() } toStep(): string { return `MAPPED_ITEM(${stepStr(this.name)},${this.mappingSource},${this.mappingTarget})` } } function importStepModelOnce( targetRepo: Repository, stepText: string, modelName: string, ): ImportedStepModel { const sourceRepo = parseRepository(stepText) let entries: RepositoryEntry[] = sourceRepo .entries() .map(([id, entity]) => [Number(id), entity] as const) .filter(([, entity]) => !EXCLUDED_ENTITY_TYPES.has(entity.type)) entries = pruneInvalidEntries(entries) const idMapping = allocateIds(targetRepo, entries) remapReferences(entries, idMapping) for (const [oldId, entity] of entries) { const mappedId = idMapping.get(oldId) if (mappedId === undefined) continue targetRepo.set(eid(mappedId), entity) } const solids: Ref[] = [] for (const [oldId, entity] of entries) { if (entity instanceof ManifoldSolidBrep) { const mappedId = idMapping.get(oldId) if (mappedId !== undefined) { solids.push(new Ref(eid(mappedId))) } } } const originPoint = targetRepo.add(new CartesianPoint("", 0, 0, 0)) const zDirection = targetRepo.add(new Direction("", 0, 0, 1)) const xDirection = targetRepo.add(new Direction("", 1, 0, 0)) const mappingOrigin = targetRepo.add( new Axis2Placement3D("", originPoint, zDirection, xDirection), ) const representation = targetRepo.add( new AdvancedBrepShapeRepresentation( modelName, solids, getGeomContext(targetRepo), ), ) const representationMap = targetRepo.add( new RepresentationMap(mappingOrigin, representation), ) return { entries, representationMap } } function createMappedStepModelInstance({ repo, importedModel, transform, placement, }: { repo: Repository importedModel: ImportedStepModel transform: MergeTransform placement?: PlacementOptions }): Ref[] { adjustTransformForPlacement(importedModel.entries, transform, placement) const placementTarget = createPlacementTarget(repo, transform) const mappedItem = repo.add( new MappedItem("", importedModel.representationMap, placementTarget), ) return [mappedItem] } function createPlacementTarget( repo: Repository, transform: MergeTransform, ): Ref { const rotation = toRadians(transform.rotation) const [refX, refY, refZ] = transformDirection([1, 0, 0], rotation) const [axisX, axisY, axisZ] = transformDirection([0, 0, 1], rotation) const axis = repo.add(new Direction("", axisX, axisY, axisZ)) const refDirection = repo.add(new Direction("", refX, refY, refZ)) const origin = repo.add( new CartesianPoint( "", transform.translation.x, transform.translation.y, transform.translation.z, ), ) return repo.add(new Axis2Placement3D("", origin, axis, refDirection)) } function getGeomContext(repo: Repository): Ref { for (const [id, entity] of repo.entries()) { if (entity.type === "GEOMETRIC_REPRESENTATION_CONTEXT") { return new Ref(id) } if ( entity instanceof Unknown && entity.args.some((arg) => arg.includes("GEOMETRIC_REPRESENTATION_CONTEXT"), ) ) { return new Ref(id) } } throw new Error("GEOMETRIC_REPRESENTATION_CONTEXT is missing") } type RepositoryEntry = readonly [number, Entity] function adjustTransformForPlacement( entries: ReadonlyArray, transform: MergeTransform, placement?: PlacementOptions, ) { if (!placement) return const points: [number, number, number][] = [] for (const [, entity] of entries) { if (entity instanceof CartesianPoint) { points.push([entity.x, entity.y, entity.z]) } } if (!points.length) return const rotationRadians = toRadians(transform.rotation) let minX = Infinity let minY = Infinity let minZ = Infinity let maxX = -Infinity let maxY = -Infinity let maxZ = -Infinity for (const point of points) { const [x, y, z] = rotateVector(point, rotationRadians) if (x < minX) minX = x if (y < minY) minY = y if (z < minZ) minZ = z if (x > maxX) maxX = x if (y > maxY) maxY = y if (z > maxZ) maxZ = z } if (!Number.isFinite(minX)) return const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z: (minZ + maxZ) / 2, } const normalizedLayer = placement.layer?.toLowerCase() === "bottom" ? "bottom" : "top" const boardThickness = placement.boardThickness ?? 0 const targetX = transform.translation.x const targetY = transform.translation.y const targetZ = transform.translation.z // Check if this is a through-hole component (model spans both positive and negative Z) // Through-hole components have their z=0 as the natural reference point (e.g., flange position) // Use a small tolerance to avoid floating point precision issues (e.g., -2.78e-17 being treated as negative) // but not so large that it excludes real geometry (e.g., MachineContactMedium has maxZ = 0.083mm) const THROUGH_HOLE_Z_TOLERANCE = 0.001 // 1 micrometer - filters floating point noise but keeps real geometry const isThroughHoleComponent = minZ < -THROUGH_HOLE_Z_TOLERANCE && maxZ > THROUGH_HOLE_Z_TOLERANCE // Center X/Y by bounding box for all components transform.translation.x = targetX - center.x transform.translation.y = targetY - center.y if (isThroughHoleComponent) { // Place model's z=0 at board top surface. // For through-hole components, the flange should always sit at board top (z=boardThickness/2), // regardless of position.z from circuit JSON (which may include absolute coordinates or offsets // that shouldn't affect the flange placement). transform.translation.z = boardThickness / 2 } if (!isThroughHoleComponent && boardThickness > 0) { const halfThickness = boardThickness / 2 const offsetZ = targetZ if (normalizedLayer === "bottom") { transform.translation.z = -maxZ + offsetZ transform.rotation.x = normalizeDegrees(transform.rotation.x + 180) } else { transform.translation.z = halfThickness - minZ + offsetZ } } else if (!isThroughHoleComponent) { // Only apply center-based Z for non-through-hole components without board thickness transform.translation.z = targetZ - center.z } // For through-hole components, Z is already set above } function normalizeDegrees(value: number): number { const wrapped = value % 360 return wrapped < 0 ? wrapped + 360 : wrapped } function pruneInvalidEntries(entries: ReadonlyArray) { let remaining = entries.slice() let remainingIds = new Set(remaining.map(([id]) => id)) let changed = true while (changed) { changed = false const toRemove = new Set() for (const [entityId, entity] of remaining) { const refs = collectReferencedIds(entity) for (const refId of refs) { if (!remainingIds.has(refId)) { toRemove.add(entityId) break } } } if (toRemove.size > 0) { changed = true remaining = remaining.filter(([id]) => !toRemove.has(id)) remainingIds = new Set(remaining.map(([id]) => id)) } } return remaining } function collectReferencedIds(entity: unknown): Set { const result = new Set() collectReferencedIdsRecursive(entity, result, new Set()) return result } function collectReferencedIdsRecursive( value: unknown, result: Set, seen: Set, ) { if (!value) return if (value instanceof Ref) { result.add(Number(value.id)) return } if (value instanceof Unknown) { for (const arg of value.args) { arg.replace(/#(\d+)/g, (_, num) => { result.add(Number(num)) return _ }) } return } if (Array.isArray(value)) { for (const item of value) { collectReferencedIdsRecursive(item, result, seen) } return } if (typeof value === "object") { if (seen.has(value as object)) { return } seen.add(value as object) for (const entry of Object.values(value as Record)) { collectReferencedIdsRecursive(entry, result, seen) } } } function applyTransform( entries: ReadonlyArray, transform: MergeTransform, ) { const rotation = toRadians(transform.rotation) for (const [, entity] of entries) { if (entity instanceof CartesianPoint) { const [x, y, z] = transformPoint( [entity.x, entity.y, entity.z], rotation, transform.translation, ) entity.x = x entity.y = y entity.z = z } else if (entity instanceof Direction) { const [dx, dy, dz] = transformDirection( [entity.dx, entity.dy, entity.dz], rotation, ) const length = Math.hypot(dx, dy, dz) if (length > 0) { entity.dx = dx / length entity.dy = dy / length entity.dz = dz / length } } } } function allocateIds( targetRepo: Repository, entries: ReadonlyArray, ): Map { let nextId = getNextEntityId(targetRepo) const idMapping = new Map() for (const [oldId] of entries) { idMapping.set(oldId, nextId) nextId += 1 } return idMapping } function getNextEntityId(repo: Repository): number { let maxId = 0 for (const [id] of repo.entries()) { const numericId = Number(id) if (numericId > maxId) { maxId = numericId } } return maxId + 1 } function remapReferences( entries: ReadonlyArray, idMapping: Map, ) { for (const [, entity] of entries) { remapValue(entity, idMapping, new Set()) } } function remapValue( value: unknown, idMapping: Map, seen: Set, ) { if (!value) return if (value instanceof Ref) { const mapped = idMapping.get(Number(value.id)) if (mapped !== undefined) { value.id = eid(mapped) } return } if (value instanceof Unknown) { value.args = value.args.map((arg) => arg.replace(/#(\d+)/g, (match, num) => { const mapped = idMapping.get(Number(num)) return mapped !== undefined ? `#${mapped}` : match }), ) return } if (Array.isArray(value)) { for (const item of value) { remapValue(item, idMapping, seen) } return } if (typeof value === "object") { if (seen.has(value as object)) return seen.add(value as object) for (const key of Object.keys(value as Record)) { remapValue((value as Record)[key], idMapping, seen) } } }