import { Box2 } from 'vecks' import denormalise from './denormalise' import dimensionToSVG, { getDimensionGeometryBBox } from './dimensionToSVG' import entityToPolyline from './entityToPolyline' import getRGBForEntity from './getRGBForEntity' import escapeXmlText from './util/escapeXmlText' import logger from './util/logger' import rgbToColorAttribute from './util/rgbToColorAttribute' import rotate from './util/rotate' import toPiecewiseBezier, { multiplicity } from './util/toPiecewiseBezier' import transformBoundingBoxAndElement from './util/transformBoundingBoxAndElement' import type { ArcEntity, CircleEntity, DimensionEntity, EllipseEntity, Entity, LeaderEntity, MTextEntity, ParsedDXF, ShapeEntity, SplineEntity, TextEntity, ToleranceEntity, ToSVGOptions, } from './types' import type { BoundsAndElement } from './types/svg' const addFlipXIfApplicable = ( entity: Entity, { bbox, element }: BoundsAndElement, ): BoundsAndElement => { if (entity.extrusionZ === -1) { return { bbox: new Box2() .expandByPoint({ x: -bbox.min.x, y: bbox.min.y }) .expandByPoint({ x: -bbox.max.x, y: bbox.max.y }), element: ` ${element} `, } } else { return { bbox, element } } } /** * Create a element. Interpolates curved entities. */ const polyline = (entity: Entity): BoundsAndElement => { const vertices = entityToPolyline(entity as any) const bbox = vertices.reduce( (acc, [x, y]) => acc.expandByPoint({ x, y }), new Box2(), ) const d = vertices.reduce((acc, point, i) => { acc += i === 0 ? 'M' : 'L' acc += point[0] + ',' + point[1] return acc }, '') // Empirically it appears that flipping horizontally does not apply to polyline return transformBoundingBoxAndElement( bbox, ``, entity.transforms ?? [], ) } /** * Create a element. Interpolates curved entities. * lwpolyline is the same as polyline but addFlipXIfApplicable does apply */ const lwpolyline = (entity: Entity): BoundsAndElement => { const vertices = entityToPolyline(entity as any) const bbox0 = vertices.reduce( (acc, [x, y]) => acc.expandByPoint({ x, y }), new Box2(), ) const d = vertices.reduce((acc, point, i) => { acc += i === 0 ? 'M' : 'L' acc += point[0] + ',' + point[1] return acc }, '') const element0 = `` const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement( bbox, element, entity.transforms ?? [], ) } const leader = (entity: LeaderEntity): BoundsAndElement | null => { if (!entity.vertices || entity.vertices.length < 2) return null const bbox0 = entity.vertices.reduce( (acc, p) => acc.expandByPoint({ x: p.x, y: p.y }), new Box2(), ) const d = entity.vertices.reduce((acc, p, i) => { acc += i === 0 ? 'M' : 'L' acc += p.x + ',' + p.y return acc }, '') return transformBoundingBoxAndElement( bbox0, ``, entity.transforms ?? [], ) } /** * Create a element for the CIRCLE entity. */ const circle = (entity: CircleEntity): BoundsAndElement => { const bbox0 = new Box2() .expandByPoint({ x: entity.x + entity.r, y: entity.y + entity.r, }) .expandByPoint({ x: entity.x - entity.r, y: entity.y - entity.r, }) const element0 = `` const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } interface EllipticArcParams { cx: number cy: number majorX: number majorY: number axisRatio: number startAngle: number endAngle: number flipX?: boolean } /** * Create a a or element for the ARC or ELLIPSE * DXF entity ( if start and end point are the same). */ const ellipseOrArc = (params: EllipticArcParams): BoundsAndElement => { const { cx, cy, majorX, majorY, axisRatio, startAngle, endAngle } = params const rx = Math.hypot(majorX, majorY) const ry = axisRatio * rx const rotationAngle = -Math.atan2(-majorY, majorX) const bbox = bboxEllipseOrArc(params) if ( Math.abs(startAngle - endAngle) < 1e-9 || Math.abs(startAngle - endAngle + Math.PI * 2) < 1e-9 ) { // Use a native when start and end angles are the same, and // arc paths with same start and end points don't render (at least on Safari) const element = ` ` return { bbox, element } } else { const startOffset = rotate( { x: Math.cos(startAngle) * rx, y: Math.sin(startAngle) * ry, }, rotationAngle, ) const startPoint = { x: cx + startOffset.x, y: cy + startOffset.y, } const endOffset = rotate( { x: Math.cos(endAngle) * rx, y: Math.sin(endAngle) * ry, }, rotationAngle, ) const endPoint = { x: cx + endOffset.x, y: cy + endOffset.y, } const adjustedEndAngle = endAngle < startAngle ? endAngle + Math.PI * 2 : endAngle const largeArcFlag = adjustedEndAngle - startAngle < Math.PI ? 0 : 1 const d = `M ${startPoint.x} ${startPoint.y} A ${rx} ${ry} ${(rotationAngle / Math.PI) * 180 } ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y}` const element = `` return { bbox, element } } } /** * Compute the bounding box of an elliptical arc, given the DXF entity parameters */ const bboxEllipseOrArc = (params: EllipticArcParams): Box2 => { const { cx, cy, majorX, majorY, axisRatio } = params let { startAngle, endAngle } = params // The bounding box will be defined by the starting point of the ellipse, and ending point, // and any extrema on the ellipse that are between startAngle and endAngle. // The extrema are found by setting either the x or y component of the ellipse's // tangent vector to zero and solving for the angle. // Ensure start and end angles are > 0 and well-ordered while (startAngle < 0) startAngle += Math.PI * 2 while (endAngle <= startAngle) endAngle += Math.PI * 2 // When rotated, the extrema of the ellipse will be found at these angles const angles = [] if (Math.abs(majorX) < 1e-12 || Math.abs(majorY) < 1e-12) { // Special case for majorX or majorY = 0 for (let i = 0; i < 4; i++) { angles.push((i / 2) * Math.PI) } } else { // reference https://github.com/bjnortier/dxf/issues/47#issuecomment-545915042 angles[0] = Math.atan((-majorY * axisRatio) / majorX) - Math.PI // Ensure angles < 0 angles[1] = Math.atan((majorX * axisRatio) / majorY) - Math.PI angles[2] = angles[0] - Math.PI angles[3] = angles[1] - Math.PI } // Remove angles not falling between start and end for (let i = 4; i >= 0; i--) { while (angles[i] < startAngle) angles[i] += Math.PI * 2 if (angles[i] > endAngle) { angles.splice(i, 1) } } // Also to consider are the starting and ending points: angles.push(startAngle, endAngle) // Compute points lying on the unit circle at these angles const pts = angles.map((a) => ({ x: Math.cos(a), y: Math.sin(a), })) // Transformation matrix, formed by the major and minor axes const M = [ [majorX, -majorY * axisRatio], [majorY, majorX * axisRatio], ] // Rotate, scale, and translate points const rotatedPts = pts.map((p) => ({ x: p.x * M[0][0] + p.y * M[0][1] + cx, y: p.x * M[1][0] + p.y * M[1][1] + cy, })) // Compute extents of bounding box const bbox = rotatedPts.reduce((acc, p) => { acc.expandByPoint(p) return acc }, new Box2()) return bbox } /** * An ELLIPSE is defined by the major axis, convert to X and Y radius with * a rotation angle */ const ellipse = (entity: EllipseEntity): BoundsAndElement => { const { bbox: bbox0, element: element0 } = ellipseOrArc({ cx: entity.x, cy: entity.y, majorX: entity.majorX, majorY: entity.majorY, axisRatio: entity.axisRatio, startAngle: entity.startAngle, endAngle: entity.endAngle, }) const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * An ARC is an ellipse with equal radii */ const arc = (entity: ArcEntity): BoundsAndElement => { const { bbox: bbox0, element: element0 } = ellipseOrArc({ cx: entity.x, cy: entity.y, majorX: entity.r, majorY: 0, axisRatio: 1, startAngle: entity.startAngle, endAngle: entity.endAngle, flipX: entity.extrusionZ === -1, }) const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * Create a element for TEXT entity */ const text = (entity: TextEntity): BoundsAndElement => { const x = entity.x ?? 0 const y = entity.y ?? 0 const height = entity.textHeight ?? 1 const rotation = entity.rotation ?? 0 const content = entity.string ?? '' // Estimate text bounding box (approximate) const textWidth = content.length * height * 0.6 const bbox0 = new Box2() .expandByPoint({ x, y }) .expandByPoint({ x: x + textWidth, y: y + height }) const rotationDegrees = (rotation * 180) / Math.PI const element0 = `${escapeXmlText(content)}` const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * Create a element for MTEXT entity */ const mtext = (entity: MTextEntity): BoundsAndElement => { const x = entity.x ?? 0 const y = entity.y ?? 0 const height = entity.nominalTextHeight ?? entity.textHeight ?? 1 const content = entity.string ?? '' // Estimate text bounding box (approximate) const textWidth = (entity.refRectangleWidth ?? content.length * height * 0.6) const bbox0 = new Box2() .expandByPoint({ x, y }) .expandByPoint({ x: x + textWidth, y: y + height }) // Calculate rotation from x-axis direction const rotation = entity.xAxisX !== undefined && entity.xAxisY !== undefined ? Math.atan2(entity.xAxisY, entity.xAxisX) : 0 const rotationDegrees = (rotation * 180) / Math.PI const element0 = `${escapeXmlText(content)}` const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * Minimal fallback for TOLERANCE entities. * DXF uses special control codes; we preserve the raw string. */ const tolerance = (entity: ToleranceEntity): BoundsAndElement => { const x = entity.insertionPoint?.x ?? 0 const y = entity.insertionPoint?.y ?? 0 const height = 1 const content = entity.text ?? '' const rotation = entity.xAxisDirection ? Math.atan2(entity.xAxisDirection.y, entity.xAxisDirection.x) : 0 const rotationDegrees = (rotation * 180) / Math.PI const textWidth = content.length * height * 0.6 const bbox0 = new Box2() .expandByPoint({ x, y }) .expandByPoint({ x: x + textWidth, y: y + height }) const element0 = `${escapeXmlText(content)}` const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * Minimal fallback for SHAPE entities. * Rendering SHX-based shapes is out of scope; we render the name as text. */ const shape = (entity: ShapeEntity): BoundsAndElement => { const x = entity.insertionPoint?.x ?? 0 const y = entity.insertionPoint?.y ?? 0 const height = entity.size ?? 1 const rotation = entity.rotation ?? 0 const content = entity.name ?? '' const textWidth = content.length * height * 0.6 const bbox0 = new Box2() .expandByPoint({ x, y }) .expandByPoint({ x: x + textWidth, y: y + height }) const rotationDegrees = (rotation * 180) / Math.PI const element0 = `${escapeXmlText(content)}` const { bbox, element } = addFlipXIfApplicable(entity, { bbox: bbox0, element: element0, }) return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * Create dimension visualization with DIMSTYLE support */ const dimension = ( entity: DimensionEntity, dimStyle?: any, options?: ToSVGOptions, viewport?: { width: number; height: number }, ): BoundsAndElement => { const result = dimensionToSVG(entity, dimStyle, options, viewport) return transformBoundingBoxAndElement( result.bbox, result.element, entity.transforms ?? [], ) } export const piecewiseToPaths = ( k: number, knots: number[], controlPoints: Array<{ x: number; y: number }>, ): string[] => { const paths: string[] = [] let controlPointIndex = 0 let knotIndex = k while (knotIndex < knots.length - k + 1) { const m = multiplicity(knots, knotIndex) const cp = controlPoints.slice(controlPointIndex, controlPointIndex + k) if (k === 4) { paths.push( ``, ) } else if (k === 3) { paths.push( ``, ) } controlPointIndex += m knotIndex += m } return paths } const bezier = (entity: SplineEntity): BoundsAndElement => { let bbox = new Box2() for (const p of entity.controlPoints) { bbox = bbox.expandByPoint(p) } const k = entity.degree + 1 const piecewise = toPiecewiseBezier(k, entity.controlPoints, entity.knots) const paths = piecewiseToPaths(k, piecewise.knots, piecewise.controlPoints) const element = `${paths.join('')}` return transformBoundingBoxAndElement(bbox, element, entity.transforms ?? []) } /** * Switch the appropriate function on entity type. CIRCLE, ARC and ELLIPSE * produce native SVG elements, the rest produce interpolated polylines. */ const entityToBoundsAndElement = ( entity: Entity, dimStyles?: { [name: string]: any }, options?: ToSVGOptions, viewport?: { width: number; height: number }, ): BoundsAndElement | null => { switch (entity.type) { case 'CIRCLE': return circle(entity as CircleEntity) case 'ELLIPSE': return ellipse(entity as EllipseEntity) case 'ARC': return arc(entity as ArcEntity) case 'TEXT': return text(entity as TextEntity) case 'MTEXT': return mtext(entity as MTextEntity) case 'DIMENSION': { const dimEntity = entity as DimensionEntity const styleName = typeof dimEntity.styleName === 'string' ? dimEntity.styleName : undefined const dimStyle = styleName && dimStyles ? dimStyles[styleName] : undefined return dimension(dimEntity, dimStyle, options, viewport) } case 'SPLINE': { const splineEntity = entity as SplineEntity const hasWeights = splineEntity.weights?.some((w: number) => w !== 1) if ((splineEntity.degree === 2 || splineEntity.degree === 3) && !hasWeights) { try { return bezier(splineEntity) } catch (err) { const error = err as Error logger.warn('bezier conversion failed, using polyline:', error.message) return polyline(entity) } } else { return polyline(entity) } } case 'LINE': case 'RAY': case 'XLINE': case 'POLYLINE': { return polyline(entity) } case 'SOLID': case 'TRACE': { return polyline(entity) } case 'LWPOLYLINE': { return lwpolyline(entity) } case 'WIPEOUT': { return polyline(entity) } case 'LEADER': { return leader(entity as LeaderEntity) } case 'TOLERANCE': { return tolerance(entity as ToleranceEntity) } case 'SHAPE': { return shape(entity as ShapeEntity) } default: logger.warn('entity type not supported in SVG rendering:', entity.type) return null } } export default function toSVG(parsed: ParsedDXF, options: ToSVGOptions = {}): string { const entities = denormalise(parsed) const dimStyles = parsed.tables.dimStyles const geometryBBox = entities.reduce((acc: Box2, entity: Entity): Box2 => { if (entity.type === 'DIMENSION') { const bbox = getDimensionGeometryBBox(entity as DimensionEntity) if (bbox.valid) { acc.expandByPoint(bbox.min) acc.expandByPoint(bbox.max) } return acc } const boundsAndElement = entityToBoundsAndElement(entity, dimStyles, options) if (boundsAndElement?.bbox.valid) { acc.expandByPoint(boundsAndElement.bbox.min) acc.expandByPoint(boundsAndElement.bbox.max) } return acc }, new Box2()) const viewport = geometryBBox.valid ? { width: geometryBBox.max.x - geometryBBox.min.x, height: geometryBBox.max.y - geometryBBox.min.y, } : { width: 0, height: 0, } const { bbox, elements } = entities.reduce( ( acc: { bbox: Box2; elements: string[] }, entity: Entity, ): { bbox: Box2; elements: string[] } => { const rgb = getRGBForEntity(parsed.tables.layers, entity) const boundsAndElement = entityToBoundsAndElement(entity, dimStyles, options, viewport) // Ignore entities that don't produce SVG elements or have unsupported types if (boundsAndElement) { const { bbox, element } = boundsAndElement // Ignore invalid bounding boxes if (bbox.valid) { acc.bbox.expandByPoint(bbox.min) acc.bbox.expandByPoint(bbox.max) } const color = rgbToColorAttribute(rgb) const handleAttr = options?.includeHandles ? ` data-handle="${entity.handle}" data-type="${entity.type}" data-layer="${entity.layer}" ` : '' if (entity.type === 'SOLID' || entity.type === 'TRACE') { acc.elements.push(`${element}`) } else { acc.elements.push(`${element}`) } } return acc }, { bbox: new Box2(), elements: [], }, ) const viewBox = bbox.valid ? { x: bbox.min.x, y: -bbox.max.y, width: bbox.max.x - bbox.min.x, height: bbox.max.y - bbox.min.y, } : { x: 0, y: 0, width: 0, height: 0, } return ` ${elements.join('\n')} ` }