Files
Web_FreeCAD_Bitbybit/src/facade/fcstd.ts
wangdequan 97967041e2
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled
feat: prepare FreeCAD private naming worker linkage
2026-08-11 23:16:22 -04:00

2518 lines
189 KiB
TypeScript

import { unzipSync, zipSync } from 'fflate'
import { XMLParser } from 'fast-xml-parser'
import { ATTACHMENT_MAP_MODES, validateAttachmentOffset, validateAttachmentSupport } from './attachment'
import { validateSketchSnapshot } from './sketcher'
import { migrateElementMap2Schema, parseElementMap2, writeElementMap2 } from './elementMap2'
import type { AnyElementMap2Document, ElementMap2Document } from './elementMap2'
import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from './stringHasher'
import type { AnyStringHasherTable, StringHasherTable, StringHasherValidationReport } from './stringHasher'
import type { SketchConstraint, SketchExternalGeometry, SketchExternalMode, SketchGeometry, SketchPointRef, SketchSnapshot } from './sketcher'
import type { AttachmentSupportValue, DocumentSnapshot, DocumentObjectSnapshot, ElementMapSnapshot, ModelTreeItem, ObjectPropertySnapshot, PathCommandValue, PathPropertyValue, PlacementValue, PropertyValue, TopoRefValue } from './types'
export type FcstdArchiveLimits = {
maxArchiveBytes: number
maxEntries: number
maxEntryBytes: number
maxTotalUncompressedBytes: number
maxCompressionRatio: number
maxXmlDepth: number
maxXmlNodes: number
}
export type FcstdEntryRole = 'document' | 'gui-document' | 'shape' | 'topology-map' | 'thumbnail' | 'script' | 'resource'
export type FcstdEntryMetadata = {
path: string
compressedBytes: number
uncompressedBytes: number
compressionMethod: number
role: FcstdEntryRole
}
export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked'
export type FcstdPropertySummary = {
name: string
typeId: string
element: string
value: string
subElements?: string[]
links?: string[]
linkSubs?: Array<{ objectId: string; subElement: string }>
enumOptions?: string[]
shapeResource?: { path: string; hasherIndex?: number; elementMap?: string; elementMapEntries?: Array<{ key: string; value: string }>; elementMapResource?: string }
expression?: string
}
export type FcstdDecodedPropertyValue = { value: unknown; decoded: boolean; error?: string }
export type FcstdPathAccessOptions = { allowFeaturePython?: boolean }
export type FcstdPathEdit = { objectName: string; propertyName?: string; value: PathPropertyValue; allowFeaturePython?: boolean }
export type FcstdObjectSummary = {
name: string
label: string
typeId: string
nativeObjectTag?: number
propertyCount: number
properties: FcstdPropertySummary[]
support: FcstdObjectSupport
extensions: string[]
sketch?: SketchSnapshot
}
export type FcstdShapeResource = {
path: string
format: 'brep'
mediaType: 'application/x-freecad-brep'
byteLength: number
contentHash: string
status: 'available' | 'empty'
}
export type FcstdShapeResourcePayload = FcstdShapeResource & { bytes: Uint8Array }
export type FcstdStoredShapeResource = FcstdShapeResource & { hash: string }
export type FcstdShapeResourceReference = {
objectName: string
propertyName: string
hasherIndex?: number
elementMap?: string
elementMapResource?: string
}
export type FcstdElementMapResource = {
path: string
format: 'element-map-v1'
byteLength: number
contentHash: string
status: 'available' | 'empty'
postfixCount?: number
mapCount?: number
sections: Array<{ name: 'Edge' | 'Face' | 'Vertex'; nameCount: number }>
document?: ElementMap2Document
}
export type FcstdStringHasherResource = {
path: string
format: 'string-hasher-v1'
byteLength: number
contentHash: string
status: 'available' | 'empty'
entryCount: number
validation: StringHasherValidationReport
document?: StringHasherTable
}
export type FcstdInstantiatedShape<T> = {
resource: FcstdShapeResource
references: FcstdShapeResourceReference[]
shape: T
}
export type FcstdShapeInstantiationOptions<T> = {
limits?: Partial<FcstdArchiveLimits>
signal?: AbortSignal
release?: (shape: T) => Promise<void> | void
}
export type FcstdCompatibilityReport = {
level: 'metadata-compatible' | 'partial' | 'blocked'
readOnly: true
codeExecutionBlocked: true
recognizedObjects: number
proxyObjects: number
blockedObjects: number
unknownTypeIds: string[]
warnings: string[]
}
export type FcstdGuiInspection = {
present: boolean
rootName: 'Document' | 'GuiDocument' | 'none'
schemaVersion: string
viewCount: number
contentHash: string
views: Array<{ name: string; type: string; visibility: string }>
viewProviders: FcstdViewProviderSummary[]
}
export type FcstdMaterialAppearance = {
ambientColor: string
diffuseColor: string
specularColor: string
emissiveColor: string
shininess: number
transparency: number
image: string
imagePath: string
uuid: string
}
export type FcstdViewProviderSummary = {
objectName: string
expanded: boolean | null
treeRank: number | null
propertyCount: number
visibility?: boolean
transparency?: number
lineColor?: string
pointColor?: string
shapeColor?: string
deviation?: number
angularDeflection?: number
displayMode?: number
shapeAppearanceResource?: string
shapeAppearance: FcstdMaterialAppearance[]
}
export type FcstdInspection = {
format: 'FCStd'
schemaVersion: string
label: string
entries: FcstdEntryMetadata[]
objects: FcstdObjectSummary[]
guiDocument: FcstdGuiInspection
shapeResources: FcstdShapeResource[]
elementMapResources: FcstdElementMapResource[]
stringHasherResource?: FcstdStringHasherResource
compatibility: FcstdCompatibilityReport
proxyDocument: DocumentSnapshot
}
export type FcstdWriteOptions = {
guiDocumentXml?: string
guiViews?: Array<{ name: string; type?: string; visibility?: string }>
opaqueEntries?: Record<string, Uint8Array>
stringHasherTable?: AnyStringHasherTable
}
export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = {
maxArchiveBytes: 256 * 1024 * 1024,
maxEntries: 20_000,
maxEntryBytes: 128 * 1024 * 1024,
maxTotalUncompressedBytes: 512 * 1024 * 1024,
maxCompressionRatio: 200,
maxXmlDepth: 128,
maxXmlNodes: 250_000,
}
const recognizedTypeIds = new Set([
'App::DocumentObjectGroup',
'App::FeaturePython',
'Part::Feature',
'Part::FeaturePython',
'Part::Box',
'Part::Cylinder',
'Part::Sphere',
'Part::Ellipsoid',
'Part::Cone',
'Part::Torus',
'Part::Prism',
'Part::Wedge',
'Part::Helix',
'Part::Fuse',
'Part::Cut',
'Part::Common',
'Part::Extrusion',
'Part::Revolution',
'Part::Loft',
'Part::Sweep',
'Part::Fillet',
'Part::Chamfer',
'PartDesign::Body',
'PartDesign::Feature',
'PartDesign::Pad',
'PartDesign::Pocket',
'PartDesign::Fillet',
'PartDesign::Chamfer',
'PartDesign::Revolution',
'PartDesign::Groove',
'PartDesign::AdditiveLoft',
'PartDesign::SubtractiveLoft',
'PartDesign::AdditivePipe',
'PartDesign::SubtractivePipe',
'PartDesign::Draft',
'PartDesign::Thickness',
'PartDesign::Mirrored',
'PartDesign::MultiTransform',
'PartDesign::LinearPattern',
'PartDesign::PolarPattern',
'PartDesign::Hole',
'PartDesign::Plane',
'PartDesign::Line',
'PartDesign::Point',
'PartDesign::ShapeBinder',
'PartDesign::SubShapeBinder',
'Sketcher::SketchObject',
'Path::Feature',
])
const nativeTypeIdAliases = new Map([
['Part::Extrude', 'Part::Extrusion'],
])
const nativeFcstdTypeId = (typeId: string) => nativeTypeIdAliases.get(typeId) ?? typeId
const hashString = (value: string) => {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) hash = Math.imul(hash ^ value.charCodeAt(index), 16777619)
return (hash >>> 0).toString(16).padStart(8, '0')
}
const hashBytes = (bytes: Uint8Array) => {
let hash = 2166136261
for (const byte of bytes) hash = Math.imul(hash ^ byte, 16777619)
return (hash >>> 0).toString(16).padStart(8, '0')
}
const xmlEscape = (value: string) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const colorToPacked = (value: unknown, name: string) => {
if (typeof value !== 'string' || !/^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(value)) throw new TypeError(`FCStd Color property ${name} requires #RRGGBB or #RRGGBBAA.`)
return Number.parseInt(`${value.slice(1, 7)}${value.length === 9 ? value.slice(7, 9) : 'ff'}`, 16) >>> 0
}
const packedToColor = (value: number) => `#${(value >>> 0).toString(16).padStart(8, '0')}`
const materialAppearanceBytes = (diffuseColor: unknown, transparency: unknown, name: string) => {
const diffuse = colorToPacked(diffuseColor, name)
const transparencyValue = transparency === undefined ? 0 : finiteComponent(transparency, `${name}.Transparency`) / 100
if (transparencyValue < 0 || transparencyValue > 1) throw new RangeError(`FCStd Shape appearance ${name} transparency must be between 0 and 100.`)
const bytes = new Uint8Array(40)
const view = new DataView(bytes.buffer)
let offset = 0
const uint32 = (value: number) => { view.setUint32(offset, value >>> 0, true); offset += 4 }
const float32 = (value: number) => { view.setFloat32(offset, value, true); offset += 4 }
uint32(1)
uint32(0x333333ff)
uint32(diffuse)
uint32(0x000000ff)
uint32(0x000000ff)
float32(0.2)
float32(transparencyValue)
uint32(0)
uint32(0)
uint32(0)
return bytes
}
const finiteComponent = (value: unknown, name: string) => {
if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`FCStd ${name} must be finite.`)
return value
}
const guiPropertyXml = (property: ObjectPropertySnapshot) => {
const nativeType = property.type === 'App::PropertyFloat' && ['Deviation', 'LineWidth', 'PointSize'].includes(property.name) ? 'App::PropertyFloatConstraint' : property.type
const header = `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(nativeType)}" status="1">`
if (property.type === 'App::PropertyColor') return `${header}<PropertyColor value="${colorToPacked(property.value, property.name)}"/></Property>`
if (property.type === 'App::PropertyBool') {
if (typeof property.value !== 'boolean') throw new TypeError(`FCStd GUI Bool property ${property.name} requires a boolean.`)
return `${header}<Bool value="${property.value}"/></Property>`
}
if (property.type === 'App::PropertyPercent' || property.type === 'App::PropertyInteger') {
const value = finiteComponent(property.value, property.name)
if (!Number.isSafeInteger(value) || (property.type === 'App::PropertyPercent' && (value < 0 || value > 100))) throw new RangeError(`FCStd GUI property ${property.name} requires a valid integer.`)
return `${header}<Integer value="${value}"/></Property>`
}
if (property.type === 'App::PropertyFloat' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance') return `${header}<Float value="${finiteComponent(property.value, property.name)}"/></Property>`
if (property.type === 'App::PropertyEnumeration') {
const index = property.options?.indexOf(String(property.value)) ?? -1
if (index < 0) throw new TypeError(`FCStd GUI Enumeration property ${property.name} requires a listed option.`)
return `${header}<Integer value="${index}"/></Property>`
}
if (property.type === 'App::PropertyString') return `${header}<String value="${xmlEscape(String(property.value))}"/></Property>`
return ''
}
const nativeDataPropertyNames = new Map<string, ReadonlySet<string>>([
['Path::Feature', new Set(['Path'])],
['Part::Box', new Set(['Length', 'Width', 'Height'])],
['Part::Cylinder', new Set(['Radius', 'Height', 'Angle'])],
['Part::Sphere', new Set(['Radius', 'Angle1', 'Angle2', 'Angle3'])],
['Part::Ellipsoid', new Set(['Radius1', 'Radius2', 'Radius3', 'Angle1', 'Angle2', 'Angle3'])],
['Part::Cone', new Set(['Radius1', 'Radius2', 'Height', 'Angle'])],
['Part::Torus', new Set(['Radius1', 'Radius2', 'Angle1', 'Angle2', 'Angle3'])],
['Part::Prism', new Set(['Polygon', 'Circumradius', 'Height', 'FirstAngle', 'SecondAngle'])],
['Part::Wedge', new Set(['Xmin', 'Ymin', 'Zmin', 'Z2min', 'X2min', 'Xmax', 'Ymax', 'Zmax', 'Z2max', 'X2max'])],
['Part::Extrusion', new Set(['Base', 'Dir', 'DirMode', 'DirLink', 'LengthFwd', 'LengthRev', 'Solid', 'Reversed', 'Symmetric', 'TaperAngle', 'TaperAngleRev', 'FaceMakerClass', 'FaceMakerMode', 'InnerWireTaper'])],
['Part::Helix', new Set(['Pitch', 'Height', 'Radius', 'Angle', 'LeftHanded', 'Reversed', 'SegmentLength'])],
['Part::Revolution', new Set(['Source', 'Base', 'Axis', 'AxisLink', 'Angle', 'Symmetric', 'Solid', 'FaceMakerClass'])],
['Part::Loft', new Set(['Sections', 'Solid', 'Ruled', 'Closed', 'MaxDegree', 'Linearize'])],
['Part::Sweep', new Set(['Sections', 'Spine', 'Solid', 'Frenet', 'Transition', 'Linearize'])],
['PartDesign::Plane', new Set(['MapMode', 'AttachmentOffset'])],
['PartDesign::Line', new Set(['MapMode', 'AttachmentOffset'])],
['PartDesign::Point', new Set(['MapMode', 'AttachmentOffset'])],
['PartDesign::ShapeBinder', new Set(['Support', 'TraceSupport'])],
['PartDesign::Pad', new Set(['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'])],
['PartDesign::Pocket', new Set(['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'])],
['PartDesign::Revolution', new Set(['Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'Midplane', 'Reversed'])],
['PartDesign::Groove', new Set(['Base', 'Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'Midplane', 'Reversed'])],
['PartDesign::AdditiveLoft', new Set(['Profile', 'Sections', 'Ruled', 'Closed'])],
['PartDesign::SubtractiveLoft', new Set(['Profile', 'Sections', 'Ruled', 'Closed'])],
['PartDesign::AdditivePipe', new Set(['Profile', 'Spine', 'Transition', 'Mode', 'Transformation'])],
['PartDesign::SubtractivePipe', new Set(['Profile', 'Spine', 'Transition', 'Mode', 'Transformation'])],
['PartDesign::Fillet', new Set(['Base', 'Radius', 'UseAllEdges'])],
['PartDesign::Chamfer', new Set(['Base', 'Size', 'Size2', 'Angle', 'ChamferType', 'FlipDirection', 'UseAllEdges'])],
['PartDesign::Draft', new Set(['Base', 'Angle', 'Reversed'])],
['PartDesign::Thickness', new Set(['Base', 'Value', 'Join', 'Mode', 'Reversed'])],
['PartDesign::Mirrored', new Set(['Originals', 'TransformMode'])],
['PartDesign::MultiTransform', new Set(['Originals', 'TransformMode', 'Transformations'])],
['PartDesign::LinearPattern', new Set(['Originals', 'TransformMode', 'Occurrences', 'Length', 'Offset', 'Direction', 'Mode', 'Reversed', 'Spacings', 'SpacingPattern', 'Direction2', 'Mode2', 'Length2', 'Offset2', 'Occurrences2', 'Reversed2', 'Spacings2', 'SpacingPattern2'])],
['PartDesign::PolarPattern', new Set(['Originals', 'TransformMode', 'Occurrences', 'Angle', 'Axis', 'Mode', 'Offset', 'Spacings', 'SpacingPattern', 'Reversed'])],
['PartDesign::Hole', new Set(['Threaded', 'ModelThread', 'ThreadType', 'ThreadSize', 'ThreadClass', 'ThreadFit', 'Diameter', 'ThreadDiameter', 'ThreadDirection', 'HoleCutType', 'HoleCutCustomValues', 'HoleCutDiameter', 'HoleCutDepth', 'HoleCutCountersinkAngle', 'DepthType', 'Depth', 'DrillPoint', 'DrillPointAngle', 'DrillForDepth', 'Tapered', 'TaperedAngle', 'ThreadDepthType', 'ThreadDepth', 'UseCustomThreadClearance', 'CustomThreadClearance', 'BaseProfileType'])],
['Part::Fuse', new Set(['Base', 'Tool', 'Refine'])],
['Part::Cut', new Set(['Base', 'Tool', 'Refine'])],
['Part::Common', new Set(['Base', 'Tool', 'Refine'])],
])
// These indexes are a persistent FCStd contract. Keep this in the same order as
// Attacher::AttachEngine::eMapModeStrings in the locked FreeCAD oracle.
const freecadAttachmentMapModes = [
'Deactivated', 'Translate', 'ObjectXY', 'ObjectXZ', 'ObjectYZ', 'FlatFace', 'TangentPlane', 'NormalToEdge',
'FrenetNB', 'FrenetTN', 'FrenetTB', 'Concentric', 'SectionOfRevolution', 'ThreePointsPlane', 'ThreePointsNormal', 'Folding',
'ObjectX', 'ObjectY', 'ObjectZ', 'AxisOfCurvature', 'Directrix1', 'Directrix2', 'Asymptote1', 'Asymptote2', 'Tangent', 'Normal',
'Binormal', 'TangentU', 'TangentV', 'TwoPointLine', 'IntersectionLine', 'ProximityLine', 'ObjectOrigin', 'Focus1', 'Focus2',
'OnEdge', 'CenterOfCurvature', 'CenterOfMass', 'IntersectionPoint', 'Vertex', 'ProximityPoint1', 'ProximityPoint2',
'AxisOfInertia1', 'AxisOfInertia2', 'AxisOfInertia3', 'InertialCS', 'FaceNormal', 'OZX', 'OZY', 'OXY', 'OXZ', 'OYZ', 'OYX',
'ParallelPlane', 'MidPoint',
] as const
const sketchAttachmentPropertyNames = new Set(['Support', 'MapMode', 'AttachmentOffset'])
const sketchReservedAttachmentPropertyNames = new Set(['AttachmentSupport', 'WebSupportValue'])
const sketchConstraintType = {
coincident: 1,
horizontal: 2,
vertical: 3,
parallel: 4,
tangent: 5,
distance: 6,
distanceX: 7,
distanceY: 8,
angle: 9,
perpendicular: 10,
radius: 11,
equal: 12,
pointOnObject: 13,
symmetric: 14,
internalAlignment: 15,
snellsLaw: 16,
block: 17,
diameter: 18,
weight: 19,
} as const
const sketchPointPosition = { start: 1, end: 2, center: 3, position: 1 } as const
const sketchGeometryType = {
point: 'Part::GeomPoint',
line: 'Part::GeomLineSegment',
circle: 'Part::GeomCircle',
arc: 'Part::GeomArcOfCircle',
ellipse: 'Part::GeomEllipse',
arcEllipse: 'Part::GeomArcOfEllipse',
arcHyperbola: 'Part::GeomArcOfHyperbola',
arcParabola: 'Part::GeomArcOfParabola',
bspline: 'Part::GeomBSplineCurve',
} as const
const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
if (geometry.type === 'point') return `<GeomPoint X="${geometry.position.x}" Y="${geometry.position.y}" Z="0"/>`
if (geometry.type === 'line') return `<LineSegment StartX="${geometry.start.x}" StartY="${geometry.start.y}" StartZ="0" EndX="${geometry.end.x}" EndY="${geometry.end.y}" EndZ="0"/>`
if (geometry.type === 'circle') return `<Circle CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" AngleXU="0" Radius="${geometry.radius}"/>`
if (geometry.type === 'arc') return `<ArcOfCircle CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" AngleXU="0" Radius="${geometry.radius}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
if (geometry.type === 'ellipse') return `<Ellipse CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}"/>`
if (geometry.type === 'arcEllipse') return `<ArcOfEllipse CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
if (geometry.type === 'arcHyperbola') return `<ArcOfHyperbola CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" MajorRadius="${geometry.majorRadius}" MinorRadius="${geometry.minorRadius}" AngleXU="${geometry.rotation}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
if (geometry.type === 'arcParabola') return `<ArcOfParabola CenterX="${geometry.center.x}" CenterY="${geometry.center.y}" CenterZ="0" NormalX="0" NormalY="0" NormalZ="1" Focal="${geometry.focal}" AngleXU="${geometry.rotation}" StartAngle="${geometry.startAngle}" EndAngle="${geometry.endAngle}"/>`
const weights = geometry.weights ?? geometry.controlPoints.map(() => 1)
const knotCount = geometry.controlPoints.length + geometry.degree + 1
const knots = geometry.knots ?? Array.from({ length: knotCount }, (_, index) => {
if (index <= geometry.degree) return 0
if (index >= knotCount - geometry.degree - 1) return 1
return (index - geometry.degree) / (knotCount - 2 * geometry.degree - 1)
})
const uniqueKnots: Array<{ value: number; multiplicity: number }> = []
for (const knot of knots) {
const previous = uniqueKnots.at(-1)
if (previous?.value === knot) previous.multiplicity += 1
else uniqueKnots.push({ value: knot, multiplicity: 1 })
}
const poles = geometry.controlPoints.map((point, index) => `<Pole X="${point.x}" Y="${point.y}" Z="0" Weight="${weights[index]}"/>`).join('')
const knotXml = uniqueKnots.map((knot) => `<Knot Value="${knot.value}" Mult="${knot.multiplicity}"/>`).join('')
return `<BSplineCurve PolesCount="${geometry.controlPoints.length}" KnotsCount="${uniqueKnots.length}" Degree="${geometry.degree}" IsPeriodic="${geometry.periodic ? 1 : 0}">${poles}${knotXml}</BSplineCurve>`
}
type SketchInternalAlignmentType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11
type SketchInternalGeometryHelper = {
key: string
nativeIndex: number
nativeId: number
targetGeometryId: string
alignmentType: SketchInternalAlignmentType
internalAlignmentIndex: number
webInternalGeometryIndex: number
firstPosition: number
geometry: Extract<SketchGeometry, { type: 'point' | 'line' | 'circle' }>
explicitAlignmentId?: string
syntheticAlignmentId?: string
weightConstraintId?: string
}
const bsplineExpandedKnots = (geometry: Extract<SketchGeometry, { type: 'bspline' }>) => {
if (geometry.knots) return geometry.knots
const knotCount = geometry.controlPoints.length + geometry.degree + 1
return Array.from({ length: knotCount }, (_, index) => {
if (index <= geometry.degree) return 0
if (index >= knotCount - geometry.degree - 1) return 1
return (index - geometry.degree) / (knotCount - 2 * geometry.degree - 1)
})
}
const bsplinePointAtParameter = (geometry: Extract<SketchGeometry, { type: 'bspline' }>, parameter: number) => {
const knots = bsplineExpandedKnots(geometry)
const degree = geometry.degree
const lastPole = geometry.controlPoints.length - 1
const lower = knots[degree]
const upper = knots[lastPole + 1]
if (!Number.isFinite(parameter) || parameter < lower || parameter > upper) throw new RangeError(`B-spline ${geometry.id} parameter is outside its knot domain.`)
let span = lastPole
if (parameter < upper) {
span = degree
while (span < lastPole && parameter >= knots[span + 1]) span += 1
}
const weights = geometry.weights ?? geometry.controlPoints.map(() => 1)
const values = Array.from({ length: degree + 1 }, (_, index) => {
const poleIndex = span - degree + index
const weight = weights[poleIndex]
const pole = geometry.controlPoints[poleIndex]
return { x: pole.x * weight, y: pole.y * weight, weight }
})
for (let level = 1; level <= degree; level += 1) {
for (let index = degree; index >= level; index -= 1) {
const knotIndex = span - degree + index
const denominator = knots[knotIndex + degree - level + 1] - knots[knotIndex]
const alpha = denominator === 0 ? 0 : (parameter - knots[knotIndex]) / denominator
values[index] = {
x: (1 - alpha) * values[index - 1].x + alpha * values[index].x,
y: (1 - alpha) * values[index - 1].y + alpha * values[index].y,
weight: (1 - alpha) * values[index - 1].weight + alpha * values[index].weight,
}
}
}
const result = values[degree]
if (!Number.isFinite(result.weight) || result.weight <= 0) throw new RangeError(`B-spline ${geometry.id} evaluated to an invalid rational weight.`)
return { x: result.x / result.weight, y: result.y / result.weight }
}
const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeometryHelper[] => {
const geometryById = new Map(sketch.geometry.map((geometry) => [geometry.id, geometry]))
const constraintIds = new Set(sketch.constraints.map((constraint) => constraint.id))
const helpers = new Map<string, SketchInternalGeometryHelper>()
const addHelper = (helper: Omit<SketchInternalGeometryHelper, 'nativeIndex' | 'nativeId'>) => {
const helperIndex = helpers.size
const value = { ...helper, nativeIndex: sketch.geometry.length + helperIndex, nativeId: sketch.geometry.length + helperIndex + 1 }
helpers.set(value.key, value)
return value
}
const ensureControlPointHelper = (geometryId: string, controlPointIndex: number, constraintId: string) => {
const geometry = geometryById.get(geometryId)
if (!geometry || geometry.type !== 'bspline') throw new Error(`FCStd native Sketch constraint '${constraintId}' requires B-spline geometry '${geometryId}'.`)
if (!Number.isSafeInteger(controlPointIndex) || controlPointIndex < 0 || controlPointIndex >= geometry.controlPoints.length) throw new RangeError(`FCStd native Sketch constraint '${constraintId}' control point index is outside '${geometryId}'.`)
const key = `${geometryId}\0bspline-control-point\0${controlPointIndex}`
let helper = helpers.get(key)
if (!helper) {
const weight = geometry.weights?.[controlPointIndex] ?? 1
helper = addHelper({
key,
targetGeometryId: geometryId,
alignmentType: 9,
internalAlignmentIndex: controlPointIndex,
webInternalGeometryIndex: controlPointIndex,
firstPosition: 3,
geometry: { id: `Internal${helpers.size}`, type: 'circle', center: { ...geometry.controlPoints[controlPointIndex] }, radius: weight, construction: true },
})
}
return helper
}
for (const constraint of sketch.constraints) {
if (constraint.type === 'weight') {
const helper = ensureControlPointHelper(constraint.geometryId, constraint.controlPointIndex, constraint.id)
if (helper.weightConstraintId) throw new Error(`FCStd native Sketch has duplicate Weight constraints for '${constraint.geometryId}' control point ${constraint.controlPointIndex}.`)
const radius = finiteComponent(constraint.value, `Sketch constraint ${constraint.id}.value`)
if (radius <= 0) throw new RangeError(`FCStd native Sketch Weight constraint '${constraint.id}' requires a positive value.`)
if (helper.geometry.type !== 'circle') throw new Error(`FCStd native Sketch Weight constraint '${constraint.id}' has an invalid helper geometry.`)
helper.geometry.radius = radius
helper.weightConstraintId = constraint.id
}
if (constraint.type === 'internalAlignment') {
const target = geometryById.get(constraint.geometryId)
if (!target) throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' references unknown geometry '${constraint.geometryId}'.`)
let helper: SketchInternalGeometryHelper
if (constraint.alignmentType === 'bspline-control-point') helper = ensureControlPointHelper(constraint.geometryId, constraint.internalGeometryIndex, constraint.id)
else if (constraint.alignmentType === 'bspline-knot') {
if (target.type !== 'bspline') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires B-spline geometry '${constraint.geometryId}'.`)
const uniqueKnots = bsplineExpandedKnots(target).filter((knot, index, knots) => index === 0 || knot !== knots[index - 1])
if (!Number.isSafeInteger(constraint.internalGeometryIndex) || constraint.internalGeometryIndex < 0 || constraint.internalGeometryIndex >= uniqueKnots.length) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' knot index is outside '${constraint.geometryId}'.`)
const key = `${constraint.geometryId}\0bspline-knot\0${constraint.internalGeometryIndex}`
helper = helpers.get(key) ?? addHelper({
key,
targetGeometryId: constraint.geometryId,
alignmentType: 10,
internalAlignmentIndex: constraint.internalGeometryIndex,
webInternalGeometryIndex: constraint.internalGeometryIndex,
firstPosition: 1,
geometry: { id: `Internal${helpers.size}`, type: 'point', position: bsplinePointAtParameter(target, uniqueKnots[constraint.internalGeometryIndex]), construction: true },
})
} else if (constraint.alignmentType.startsWith('ellipse-')) {
if (target.type !== 'ellipse' && target.type !== 'arcEllipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`)
const focusIndex = constraint.alignmentType === 'ellipse-focus' ? constraint.internalGeometryIndex : 0
if ((constraint.alignmentType === 'ellipse-focus' && focusIndex !== 0 && focusIndex !== 1) || (constraint.alignmentType !== 'ellipse-focus' && constraint.internalGeometryIndex !== 0)) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid ellipse internal index.`)
const alignmentType = constraint.alignmentType === 'ellipse-major' ? 1 : constraint.alignmentType === 'ellipse-minor' ? 2 : focusIndex === 0 ? 3 : 4
const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${focusIndex}`
const majorDirection = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) }
const minorDirection = { x: -majorDirection.y, y: majorDirection.x }
const focalDistance = Math.sqrt(target.majorRadius * target.majorRadius - target.minorRadius * target.minorRadius)
const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 1
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.majorRadius * majorDirection.x, y: target.center.y + target.majorRadius * majorDirection.y }, end: { x: target.center.x - target.majorRadius * majorDirection.x, y: target.center.y - target.majorRadius * majorDirection.y }, construction: true }
: alignmentType === 2
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.minorRadius * minorDirection.x, y: target.center.y + target.minorRadius * minorDirection.y }, end: { x: target.center.x - target.minorRadius * minorDirection.x, y: target.center.y - target.minorRadius * minorDirection.y }, construction: true }
: { id: `Internal${helpers.size}`, type: 'point', position: { x: target.center.x + (alignmentType === 3 ? 1 : -1) * focalDistance * majorDirection.x, y: target.center.y + (alignmentType === 3 ? 1 : -1) * focalDistance * majorDirection.y }, construction: true }
helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: constraint.internalGeometryIndex, firstPosition: alignmentType <= 2 ? 0 : 1, geometry })
} else if (constraint.alignmentType.startsWith('hyperbola-')) {
if (target.type !== 'arcHyperbola') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires hyperbola geometry '${constraint.geometryId}'.`)
if (constraint.internalGeometryIndex !== 0) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid hyperbola internal index.`)
const alignmentType = constraint.alignmentType === 'hyperbola-major' ? 5 : constraint.alignmentType === 'hyperbola-minor' ? 6 : 7
const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${0}`
const majorDirection = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) }
const minorDirection = { x: -majorDirection.y, y: majorDirection.x }
const focalDistance = Math.sqrt(target.majorRadius * target.majorRadius + target.minorRadius * target.minorRadius)
const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 5
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.majorRadius * majorDirection.x, y: target.center.y + target.majorRadius * majorDirection.y }, end: { x: target.center.x - target.majorRadius * majorDirection.x, y: target.center.y - target.majorRadius * majorDirection.y }, construction: true }
: alignmentType === 6
? { id: `Internal${helpers.size}`, type: 'line', start: { x: target.center.x + target.minorRadius * minorDirection.x, y: target.center.y + target.minorRadius * minorDirection.y }, end: { x: target.center.x - target.minorRadius * minorDirection.x, y: target.center.y - target.minorRadius * minorDirection.y }, construction: true }
: { id: `Internal${helpers.size}`, type: 'point', position: { x: target.center.x + focalDistance * majorDirection.x, y: target.center.y + focalDistance * majorDirection.y }, construction: true }
helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: 0, firstPosition: alignmentType <= 6 ? 0 : 1, geometry })
} else {
if (target.type !== 'arcParabola' || (constraint.alignmentType !== 'parabola-focus' && constraint.alignmentType !== 'parabola-focal-axis')) throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires parabola geometry '${constraint.geometryId}'.`)
if (constraint.internalGeometryIndex !== 0) throw new RangeError(`FCStd native Sketch InternalAlignment '${constraint.id}' has an invalid parabola internal index.`)
const alignmentType = constraint.alignmentType === 'parabola-focus' ? 8 : 11
const key = `${constraint.geometryId}\0${constraint.alignmentType}\0${0}`
const direction = { x: Math.cos(target.rotation), y: Math.sin(target.rotation) }
const focus = { x: target.center.x + target.focal * direction.x, y: target.center.y + target.focal * direction.y }
const geometry: SketchInternalGeometryHelper['geometry'] = alignmentType === 8
? { id: `Internal${helpers.size}`, type: 'point', position: focus, construction: true }
: { id: `Internal${helpers.size}`, type: 'line', start: { ...target.center }, end: focus, construction: true }
helper = helpers.get(key) ?? addHelper({ key, targetGeometryId: constraint.geometryId, alignmentType, internalAlignmentIndex: -1, webInternalGeometryIndex: 0, firstPosition: alignmentType === 8 ? 1 : 0, geometry })
}
if (helper.explicitAlignmentId) throw new Error(`FCStd native Sketch has duplicate InternalAlignment constraints for '${constraint.geometryId}' internal geometry ${constraint.internalGeometryIndex}.`)
helper.explicitAlignmentId = constraint.id
}
}
let syntheticIndex = 0
for (const helper of helpers.values()) {
if (helper.explicitAlignmentId) continue
let id: string
do { id = `__WebSyntheticAlignment${syntheticIndex++}` } while (constraintIds.has(id))
constraintIds.add(id)
helper.syntheticAlignmentId = id
}
return [...helpers.values()]
}
const sketchGeometryListXml = (sketch: SketchSnapshot, internalHelpers: SketchInternalGeometryHelper[]) => {
const geometries = sketch.geometry.map((geometry, index) => {
const nativeId = index + 1
const flags = geometry.construction ? '00000000000000000000000000000010' : '00000000000000000000000000000000'
return `<Geometry type="${sketchGeometryType[geometry.type]}" id="${nativeId}" migrated="1"><GeoExtensions count="1"><GeoExtension type="Sketcher::SketchGeometryExtension" id="${nativeId}" internalGeometryType="0" geometryModeFlags="${flags}" geometryLayer="0"/></GeoExtensions>${sketchGeometryPayloadXml(geometry)}<Construction value="${geometry.construction ? 1 : 0}"/></Geometry>`
}).join('')
const helpers = internalHelpers.map((helper) => `<Geometry type="${sketchGeometryType[helper.geometry.type]}" id="${helper.nativeId}" migrated="1"><GeoExtensions count="1"><GeoExtension type="Sketcher::SketchGeometryExtension" id="${helper.nativeId}" internalGeometryType="${helper.alignmentType}" geometryModeFlags="00000000000000000000000000000010" geometryLayer="0"/></GeoExtensions>${sketchGeometryPayloadXml(helper.geometry)}<Construction value="1"/></Geometry>`).join('')
return `<Property name="Geometry" type="Part::PropertyGeometryList" status="8192"><GeometryList count="${sketch.geometry.length + internalHelpers.length}">${geometries}${helpers}</GeometryList></Property>`
}
const sketchAxisGeometryXml = (nativeId: -1 | -2) => {
const payload = nativeId === -1
? '<LineSegment StartX="0" StartY="0" StartZ="0" EndX="1" EndY="0" EndZ="0"/>'
: '<LineSegment StartX="0" StartY="0" StartZ="0" EndX="0" EndY="1" EndZ="0"/>'
return `<Geometry type="Part::GeomLineSegment" id="${nativeId}" migrated="1"><GeoExtensions count="2"><GeoExtension type="Sketcher::SketchGeometryExtension" id="${nativeId}" internalGeometryType="0" geometryModeFlags="00000000000000000000000000000010" geometryLayer="0"/><GeoExtension type="Sketcher::ExternalGeometryExtension" Ref="" Flags="0"/></GeoExtensions>${payload}<Construction value="1"/></Geometry>`
}
const nativeExternalSubElement = (source: TopoRefValue, sketchId: string, objectIds: ReadonlySet<string>) => {
if (source.schemaVersion !== 1 || !source.objectId.trim() || !source.persistentId.trim()) throw new TypeError(`FCStd native Sketch ${sketchId} external geometry requires a valid TopoRef.`)
if (!Number.isSafeInteger(source.topologyVersion) || source.topologyVersion < 0 || !Number.isSafeInteger(source.generation) || source.generation < 0) throw new TypeError(`FCStd native Sketch ${sketchId} external geometry requires non-negative integer topology versions.`)
if (source.signature !== undefined && typeof source.signature !== 'string') throw new TypeError(`FCStd native Sketch ${sketchId} external geometry signature must be a string.`)
if (source.candidates !== undefined && (!Array.isArray(source.candidates) || source.candidates.some((candidate) => typeof candidate !== 'string' || !candidate.trim()))) throw new TypeError(`FCStd native Sketch ${sketchId} external geometry candidates must be non-empty strings.`)
if (!objectIds.has(source.objectId)) throw new Error(`FCStd native Sketch ${sketchId} external geometry references undeclared object '${source.objectId}'.`)
if (source.objectId === sketchId) throw new Error(`FCStd native Sketch ${sketchId} cannot reference itself as external geometry.`)
if (source.status !== 'stable') throw new Error(`FCStd native Sketch ${sketchId} external geometry '${source.persistentId}' must have stable topology status.`)
const prefix = source.kind === 'face' ? 'Face' : source.kind === 'edge' ? 'Edge' : 'Vertex'
if (!new RegExp(`^${prefix}[1-9]\\d*$`).test(source.persistentId)) throw new Error(`FCStd native Sketch ${sketchId} external geometry '${source.persistentId}' is not a lossless native ${prefix}N reference.`)
return source.persistentId
}
const topoRefMetadataKey = (source: TopoRefValue) => JSON.stringify({
schemaVersion: source.schemaVersion,
objectId: source.objectId,
kind: source.kind,
persistentId: source.persistentId,
topologyVersion: source.topologyVersion,
generation: source.generation,
status: source.status,
signature: source.signature ?? null,
candidates: source.candidates ?? null,
})
const nativeExternalTypeByMode: Record<SketchExternalMode, number> = { projection: 0, intersection: 1, both: 2 }
const externalModeByNativeType = ['projection', 'intersection', 'both'] as const satisfies readonly SketchExternalMode[]
const nativeExternalFlags = (external: SketchExternalGeometry) =>
(external.defining ? 1 : 0)
| (external.frozen ? 2 : 0)
| (external.detached ? 4 : 0)
| (external.missing ? 8 : 0)
| (external.sync ? 16 : 0)
const webExternalStateFromNativeFlags = (flags: number) => ({
...(flags & 1 ? { defining: true as const } : {}),
...(flags & 2 ? { frozen: true as const } : {}),
...(flags & 4 ? { detached: true as const } : {}),
...(flags & 8 ? { missing: true as const } : {}),
...(flags & 16 ? { sync: true as const } : {}),
})
const sketchExternalPropertiesXml = (sketch: SketchSnapshot, objectIds: ReadonlySet<string>) => {
type ExternalGroup = { source: TopoRefValue; subElement: string; records: number[]; mode: SketchExternalMode; flags: number }
const groups: ExternalGroup[] = []
const groupIndexByReference = new Map<string, number>()
const externalRecords = sketch.externalGeometry.map((external, index) => {
const subElement = nativeExternalSubElement(external.source, sketch.id, objectIds)
const mode = external.mode ?? 'projection'
const flags = nativeExternalFlags(external)
if (mode === 'projection' && external.source.kind === 'vertex' && external.projection.type !== 'point') throw new Error(`FCStd native Sketch ${sketch.id} external vertex '${subElement}' requires a point projection.`)
if (mode === 'projection' && (external.source.kind === 'edge' || external.source.kind === 'face') && external.projection.type === 'point') throw new Error(`FCStd native Sketch ${sketch.id} external ${external.source.kind} '${subElement}' requires a curve projection.`)
const groupKey = `${external.source.objectId}\0${subElement}`
let groupIndex = groupIndexByReference.get(groupKey)
if (groupIndex === undefined) {
groupIndex = groups.length
groupIndexByReference.set(groupKey, groupIndex)
groups.push({ source: external.source, subElement, records: [], mode, flags })
} else {
if (topoRefMetadataKey(groups[groupIndex].source) !== topoRefMetadataKey(external.source)) throw new Error(`FCStd native Sketch ${sketch.id} external projections for '${external.source.objectId}.${subElement}' have conflicting TopoRef metadata.`)
if (groups[groupIndex].mode !== mode || groups[groupIndex].flags !== flags) throw new Error(`FCStd native Sketch ${sketch.id} external projections for '${external.source.objectId}.${subElement}' have conflicting mode or state flags.`)
}
groups[groupIndex].records.push(index)
const nativeId = sketch.geometry.length + index + 1
const ref = `${external.source.objectId}.${subElement}`
const geometry = `<Geometry type="${sketchGeometryType[external.projection.type]}" id="${nativeId}" migrated="1"><GeoExtensions count="2"><GeoExtension type="Sketcher::SketchGeometryExtension" id="${nativeId}" internalGeometryType="0" geometryModeFlags="00000000000000000000000000000010" geometryLayer="0"/><GeoExtension type="Sketcher::ExternalGeometryExtension" Ref="${xmlEscape(ref)}" Flags="${flags}" RefIndex="${groupIndex}"/></GeoExtensions>${sketchGeometryPayloadXml(external.projection)}<Construction value="1"/></Geometry>`
return { external, subElement, groupIndex, geometry }
})
for (const group of groups) if (group.mode === 'projection' && group.source.kind !== 'face' && group.records.length !== 1) throw new Error(`FCStd native Sketch ${sketch.id} external ${group.source.kind} '${group.subElement}' must have exactly one projection.`)
const axes = `${sketchAxisGeometryXml(-1)}${sketchAxisGeometryXml(-2)}`
const links = groups.map(({ source, subElement }) => `<Link obj="${xmlEscape(source.objectId)}" sub="${xmlEscape(subElement)}"/>`).join('')
const types = groups.map(({ mode }) => `<I v="${nativeExternalTypeByMode[mode]}"/>`).join('')
return [
`<Property name="ExternalGeo" type="Part::PropertyGeometryList" status="67108864"><GeometryList count="${externalRecords.length + 2}">${axes}${externalRecords.map((record) => record.geometry).join('')}</GeometryList></Property>`,
`<Property name="ExternalGeometry" type="App::PropertyLinkSubList" status="16777216"><LinkSubList count="${groups.length}">${links}</LinkSubList></Property>`,
`<Property name="ExternalTypes" type="App::PropertyIntegerList" status="67108864"><IntegerList count="${groups.length}">${types}</IntegerList></Property>`,
propertyXml({ name: 'WebExternalIds', label: 'Web external geometry IDs', group: 'Web', scope: 'data', type: 'App::PropertyStringList', value: externalRecords.map(({ external }) => external.id), hidden: true }, false),
propertyXml({ name: 'WebExternalProjectionIds', label: 'Web external projection IDs', group: 'Web', scope: 'data', type: 'App::PropertyStringList', value: externalRecords.map(({ external }) => external.projection.id), hidden: true }, false),
propertyXml({ name: 'WebExternalSources', label: 'Web external TopoRefs', group: 'Web', scope: 'data', type: 'App::PropertyStringList', value: externalRecords.map(({ external }) => encodeURIComponent(JSON.stringify(external.source))), hidden: true }, false),
]
}
const nativeAttachmentSupport = (object: DocumentObjectSnapshot, property: ObjectPropertySnapshot, objectIds: ReadonlySet<string>) => {
if (property.type !== 'App::PropertyLink' && property.type !== 'App::PropertyLinkSub') throw new TypeError(`FCStd native Sketch ${object.id} Support must be an App::PropertyLink or App::PropertyLinkSub.`)
if (property.expression) throw new Error(`FCStd native Sketch ${object.id} Support expressions are not lossless.`)
const value = property.value
if (value === null) return { value, objectId: '', subElement: '', topoRef: undefined }
let support: AttachmentSupportValue
if (typeof value === 'string') {
if (!value.trim()) throw new TypeError(`FCStd native Sketch ${object.id} Support requires a non-empty object id.`)
support = { objectId: value }
} else {
validateAttachmentSupport(value)
support = value
}
if (!objectIds.has(support.objectId)) {
if (typeof value === 'string') return { value, objectId: '', subElement: '', topoRef: undefined, unresolved: true }
throw new Error(`FCStd native Sketch ${object.id} Support references undeclared object '${support.objectId}'.`)
}
if (support.objectId === object.id) throw new Error(`FCStd native Sketch ${object.id} cannot use itself as Support.`)
const rawSubElement = support.subElement
if (rawSubElement === undefined || rawSubElement === null) return { value, objectId: support.objectId, subElement: '', topoRef: undefined }
if (typeof rawSubElement === 'string') {
if (!/^(?:Face|Edge|Vertex)[1-9]\d*$/.test(rawSubElement)) throw new Error(`FCStd native Sketch ${object.id} Support '${rawSubElement}' is not a lossless native FaceN/EdgeN/VertexN reference.`)
return { value, objectId: support.objectId, subElement: rawSubElement, topoRef: undefined }
}
const topoRef = rawSubElement as TopoRefValue
if (topoRef.schemaVersion !== 1 || topoRef.objectId !== support.objectId || !topoRef.persistentId.trim()) throw new TypeError(`FCStd native Sketch ${object.id} Support TopoRef is invalid or targets a different object.`)
if (!Number.isSafeInteger(topoRef.topologyVersion) || topoRef.topologyVersion < 0 || !Number.isSafeInteger(topoRef.generation) || topoRef.generation < 0) throw new TypeError(`FCStd native Sketch ${object.id} Support TopoRef requires non-negative integer topology versions.`)
if (topoRef.status !== 'stable') throw new Error(`FCStd native Sketch ${object.id} Support '${topoRef.persistentId}' must have stable topology status.`)
const prefix = topoRef.kind === 'face' ? 'Face' : topoRef.kind === 'edge' ? 'Edge' : 'Vertex'
if (!new RegExp(`^${prefix}[1-9]\\d*$`).test(topoRef.persistentId)) throw new Error(`FCStd native Sketch ${object.id} Support '${topoRef.persistentId}' is not a lossless native ${prefix}N reference.`)
return { value, objectId: support.objectId, subElement: topoRef.persistentId, topoRef }
}
const sketchAttachmentPropertiesXml = (object: DocumentObjectSnapshot, objectIds: ReadonlySet<string>) => {
if (object.typeId !== 'Sketcher::SketchObject') return []
const duplicate = object.properties.find((property) => sketchReservedAttachmentPropertyNames.has(property.name))
if (duplicate) throw new Error(`FCStd native Sketch ${object.id} property ${duplicate.name} is reserved for the native attachment codec.`)
const supportProperty = object.properties.find((property) => property.name === 'Support')
const mapModeProperty = object.properties.find((property) => property.name === 'MapMode')
const offsetProperty = object.properties.find((property) => property.name === 'AttachmentOffset')
const support = supportProperty ? nativeAttachmentSupport(object, supportProperty, objectIds) : undefined
let mapMode = 'Deactivated'
let mapModeXml = ''
if (mapModeProperty) {
if (mapModeProperty.type !== 'App::PropertyEnumeration' || mapModeProperty.expression) throw new TypeError(`FCStd native Sketch ${object.id} MapMode must be a non-expression Enumeration.`)
mapMode = String(mapModeProperty.value)
if (!(ATTACHMENT_MAP_MODES as readonly string[]).includes(mapMode)) throw new Error(`FCStd native Sketch ${object.id} MapMode '${mapMode}' is not implemented by the Web attachment model.`)
const index = (freecadAttachmentMapModes as readonly string[]).indexOf(mapMode)
if (index < 0) throw new Error(`FCStd native Sketch ${object.id} MapMode '${mapMode}' has no native FreeCAD index.`)
mapModeXml = `<Property name="MapMode" type="App::PropertyEnumeration"><Integer value="${index}"/></Property>`
}
if (mapMode !== 'Deactivated' && (!support || !support.objectId)) throw new Error(`FCStd native Sketch ${object.id} MapMode '${mapMode}' requires Support.`)
if (support?.objectId) {
if (mapMode === 'FlatFace' && !/^Face[1-9]\d*$/.test(support.subElement)) throw new Error(`FCStd native Sketch ${object.id} FlatFace Support requires a native FaceN sub-element.`)
if (mapMode === 'NormalToEdge' && !/^Edge[1-9]\d*$/.test(support.subElement)) throw new Error(`FCStd native Sketch ${object.id} NormalToEdge Support requires a native EdgeN sub-element.`)
if (['ObjectXY', 'ObjectXZ', 'ObjectYZ'].includes(mapMode) && support.subElement) throw new Error(`FCStd native Sketch ${object.id} ${mapMode} Support requires a whole object without a sub-element.`)
}
const result: string[] = []
if (offsetProperty) {
if (offsetProperty.type !== 'App::PropertyPlacement' || offsetProperty.expression) throw new TypeError(`FCStd native Sketch ${object.id} AttachmentOffset must be a non-expression Placement.`)
validateAttachmentOffset(offsetProperty.value)
result.push(propertyXml(offsetProperty, true))
}
if (supportProperty && support) {
const links = support.objectId ? `<Link obj="${xmlEscape(support.objectId)}" sub="${xmlEscape(support.subElement)}"/>` : ''
if (support.objectId) result.push(`<Property name="AttachmentSupport" type="App::PropertyLinkSubList"><LinkSubList count="1">${links}</LinkSubList></Property>`)
result.push(propertyXml({ name: 'WebSupportValue', label: 'Web Support value', group: 'Web', scope: 'data', type: 'App::PropertyString', value: encodeURIComponent(JSON.stringify(support.value)), hidden: true }, false))
}
if (mapModeXml) result.push(mapModeXml)
return result
}
const sketchConstraintElement = (geometryIndex: Map<string, number>, geometryById: Map<string, SketchGeometry>, geometryId: string, position = 0, context = 'constraint') => {
const index = geometryIndex.get(geometryId)
if (index === undefined) throw new RangeError(`FCStd Sketch ${context} references unknown geometry '${geometryId}'.`)
return { index, position }
}
const sketchConstraintPoint = (geometryIndex: Map<string, number>, geometryById: Map<string, SketchGeometry>, reference: SketchPointRef, context: string) => {
const geometry = geometryById.get(reference.geometryId)
if (!geometry) throw new RangeError(`FCStd Sketch ${context} references unknown geometry '${reference.geometryId}'.`)
const valid = geometry.type === 'line' ? reference.point === 'start' || reference.point === 'end'
: geometry.type === 'point' ? reference.point === 'position'
: geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola' ? reference.point === 'center'
: false
if (!valid) throw new RangeError(`FCStd Sketch ${context} point '${reference.point}' is invalid for ${geometry.type} '${geometry.id}'.`)
return sketchConstraintElement(geometryIndex, geometryById, reference.geometryId, sketchPointPosition[reference.point], context)
}
const sketchConstraintListXml = (sketch: SketchSnapshot, internalHelpers: SketchInternalGeometryHelper[]) => {
const geometryIndex = new Map<string, number>(sketch.geometry.map((geometry, index) => [geometry.id, index]))
const geometryById = new Map<string, SketchGeometry>(sketch.geometry.map((geometry) => [geometry.id, geometry]))
const helperByKey = new Map(internalHelpers.map((helper) => [helper.key, helper]))
sketch.externalGeometry.forEach((external, index) => {
geometryIndex.set(external.projection.id, -index - 3)
geometryById.set(external.projection.id, external.projection)
})
const internalAlignmentXml = (helper: SketchInternalGeometryHelper, id: string, driving = true) => {
const targetIndex = geometryIndex.get(helper.targetGeometryId)
if (targetIndex === undefined) throw new Error(`FCStd native Sketch internal helper references unknown geometry '${helper.targetGeometryId}'.`)
return `<Constrain Name="${xmlEscape(id)}" Type="15" InternalAlignmentType="${helper.alignmentType}" InternalAlignmentIndex="${helper.internalAlignmentIndex}" Value="0" LabelDistance="10" LabelPosition="0" IsDriving="${driving ? 1 : 0}" IsInVirtualSpace="0" IsVisible="1" IsActive="1" First="${helper.nativeIndex}" FirstPos="${helper.firstPosition}" Second="${targetIndex}" SecondPos="0" Third="-2000" ThirdPos="0" ElementIds="${helper.nativeIndex} ${targetIndex} -2000" ElementPositions="${helper.firstPosition} 0 0"/>`
}
const syntheticConstraints = internalHelpers.filter((helper) => helper.syntheticAlignmentId).map((helper) => internalAlignmentXml(helper, helper.syntheticAlignmentId!))
const constraints = sketch.constraints.map((constraint) => {
if (constraint.type === 'snellsLaw' && !('boundaryGeometryId' in constraint)) throw new Error(`FCStd native Sketch SnellsLaw constraint '${constraint.id}' requires two endpoint references and a boundary geometry for lossless export.`)
if (constraint.driving === false && !('value' in constraint)) throw new Error(`FCStd native Sketch reference constraint '${constraint.id}' must be dimensional.`)
const elements: Array<{ index: number; position: number }> = []
if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') {
elements.push(sketchConstraintPoint(geometryIndex, geometryById, constraint.first, constraint.id), sketchConstraintPoint(geometryIndex, geometryById, constraint.second, constraint.id))
} else if (constraint.type === 'horizontal' || constraint.type === 'vertical' || constraint.type === 'radius' || constraint.type === 'diameter' || constraint.type === 'angle' || constraint.type === 'block') {
elements.push(sketchConstraintElement(geometryIndex, geometryById, constraint.geometryId, 0, constraint.id))
} else if (constraint.type === 'parallel' || constraint.type === 'perpendicular' || constraint.type === 'equal' || constraint.type === 'tangent') {
elements.push(sketchConstraintElement(geometryIndex, geometryById, constraint.firstGeometryId, 0, constraint.id), sketchConstraintElement(geometryIndex, geometryById, constraint.secondGeometryId, 0, constraint.id))
} else if (constraint.type === 'pointOnObject') {
elements.push(sketchConstraintPoint(geometryIndex, geometryById, constraint.point, constraint.id), sketchConstraintElement(geometryIndex, geometryById, constraint.geometryId, 0, constraint.id))
} else if (constraint.type === 'symmetric') {
elements.push(sketchConstraintPoint(geometryIndex, geometryById, constraint.first, constraint.id), sketchConstraintPoint(geometryIndex, geometryById, constraint.second, constraint.id), sketchConstraintPoint(geometryIndex, geometryById, constraint.center, constraint.id))
} else if (constraint.type === 'snellsLaw' && 'boundaryGeometryId' in constraint) {
if (geometryById.get(constraint.first.geometryId)?.type === 'point' || geometryById.get(constraint.second.geometryId)?.type === 'point' || geometryById.get(constraint.boundaryGeometryId)?.type === 'point') throw new Error(`FCStd native Sketch SnellsLaw constraint '${constraint.id}' requires curve geometries.`)
elements.push(sketchConstraintPoint(geometryIndex, geometryById, constraint.first, constraint.id), sketchConstraintPoint(geometryIndex, geometryById, constraint.second, constraint.id), sketchConstraintElement(geometryIndex, geometryById, constraint.boundaryGeometryId, 0, constraint.id))
} else if (constraint.type === 'internalAlignment') {
const helper = internalHelpers.find((candidate) => candidate.explicitAlignmentId === constraint.id)
if (!helper) throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' has no internal helper geometry.`)
return internalAlignmentXml(helper, constraint.id, constraint.driving !== false)
} else if (constraint.type === 'weight') {
const helper = helperByKey.get(`${constraint.geometryId}\0bspline-control-point\0${constraint.controlPointIndex}`)
if (!helper) throw new Error(`FCStd native Sketch Weight '${constraint.id}' has no internal helper geometry.`)
elements.push({ index: helper.nativeIndex, position: 0 })
}
while (elements.length < 3) elements.push({ index: -2000, position: 0 })
const value = 'value' in constraint ? finiteComponent(constraint.value, `Sketch constraint ${constraint.id}.value`) : 0
const indices = elements.map((element) => element.index).join(' ')
const positions = elements.map((element) => element.position).join(' ')
return `<Constrain Name="${xmlEscape(constraint.id)}" Type="${sketchConstraintType[constraint.type]}" Value="${value}" LabelDistance="10" LabelPosition="0" IsDriving="${constraint.driving === false ? 0 : 1}" IsInVirtualSpace="0" IsVisible="1" IsActive="1" First="${elements[0].index}" FirstPos="${elements[0].position}" Second="${elements[1].index}" SecondPos="${elements[1].position}" Third="${elements[2].index}" ThirdPos="${elements[2].position}" ElementIds="${indices}" ElementPositions="${positions}"/>`
}).join('')
return `<Property name="Constraints" type="Sketcher::PropertyConstraintList"><ConstraintList count="${sketch.constraints.length + syntheticConstraints.length}">${syntheticConstraints.join('')}${constraints}</ConstraintList></Property>`
}
const sketchNativePropertiesXml = (object: DocumentObjectSnapshot, objectIds: ReadonlySet<string>) => {
if (!object.sketch) return []
if (object.typeId !== 'Sketcher::SketchObject') throw new TypeError(`FCStd object ${object.id} has Sketch data but is not a Sketcher::SketchObject.`)
validateSketchSnapshot(object.sketch)
if (object.sketch.id !== object.id) throw new Error(`FCStd native Sketch ${object.id} snapshot id must match its object id.`)
const internalHelpers = sketchInternalGeometryPlan(object.sketch)
return [
...sketchAttachmentPropertiesXml(object, objectIds),
sketchGeometryListXml(object.sketch, internalHelpers),
sketchConstraintListXml(object.sketch, internalHelpers),
...sketchExternalPropertiesXml(object.sketch, objectIds),
propertyXml({ name: 'WebGeometryIds', label: 'Web geometry IDs', group: 'Web', scope: 'data', type: 'App::PropertyStringList', value: object.sketch.geometry.map((geometry) => geometry.id), hidden: true }, false),
propertyXml({ name: 'WebSyntheticConstraintIds', label: 'Web synthetic constraint IDs', group: 'Web', scope: 'data', type: 'App::PropertyStringList', value: internalHelpers.flatMap((helper) => helper.syntheticAlignmentId ? [helper.syntheticAlignmentId] : []), hidden: true }, false),
]
}
const isNativeDataProperty = (object: DocumentObjectSnapshot, property: ObjectPropertySnapshot) => {
const typeId = nativeFcstdTypeId(object.typeId)
if (property.name === 'Label') return recognizedTypeIds.has(typeId)
if (property.name === 'Placement' && /^(?:Part|PartDesign|Sketcher)::/.test(typeId)) return true
return nativeDataPropertyNames.get(typeId)?.has(property.name) ?? false
}
const pathCommandName = (value: unknown, context: string) => {
if (typeof value !== 'string' || !/^[A-Z][A-Z0-9]*$/i.test(value.trim())) throw new TypeError(`FCStd Path command ${context} requires an alphanumeric name.`)
return value.trim().toUpperCase()
}
const pathCommands = (value: unknown, context: string): PathCommandValue[] => {
if (!Array.isArray(value) || value.length === 0) throw new TypeError(`FCStd Path property ${context} requires a non-empty command list.`)
return value.map((candidate, index) => {
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) throw new TypeError(`FCStd Path command ${context}[${index}] is malformed.`)
const record = candidate as Record<string, unknown>
const name = pathCommandName(record.name, `${context}[${index}].name`)
if (!record.parameters || typeof record.parameters !== 'object' || Array.isArray(record.parameters)) throw new TypeError(`FCStd Path command ${context}[${index}] parameters are malformed.`)
const parameters: Record<string, number> = {}
for (const [rawKey, rawValue] of Object.entries(record.parameters as Record<string, unknown>)) {
const key = rawKey.trim().toUpperCase()
if (!/^[A-Z]$/.test(key) || typeof rawValue !== 'number' || !Number.isFinite(rawValue)) throw new TypeError(`FCStd Path command ${context}[${index}] contains an invalid parameter.`)
if (Object.prototype.hasOwnProperty.call(parameters, key)) throw new TypeError(`FCStd Path command ${context}[${index}] contains duplicate parameter ${key}.`)
parameters[key] = Object.is(rawValue, -0) ? 0 : rawValue
}
return { name, parameters }
})
}
const pathNumber = (value: number) => {
if (!Number.isFinite(value)) throw new TypeError('FCStd Path coordinates must be finite.')
return value.toFixed(6).replace(/0+$/, '').replace(/\.$/, '') || '0'
}
const pathResourceText = (commands: PathCommandValue[]) => commands.map((command) => {
const words = Object.entries(command.parameters).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${key}${pathNumber(value)}`)
return `${command.name}${words.length > 0 ? ` ${words.join(' ')}` : ''}`
}).join('\n') + '\n'
const pathPropertyValue = (value: unknown, context: string): PathPropertyValue => {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`FCStd Path property ${context} requires a structured value.`)
const record = value as Record<string, unknown>
if (record.schemaVersion !== 1) throw new TypeError(`FCStd Path property ${context} schemaVersion must be 1.`)
const commands = pathCommands(record.commands, `${context}.commands`)
const resourcePath = record.resourcePath === undefined ? undefined : String(record.resourcePath)
if (resourcePath !== undefined) validateEntryPath(resourcePath)
const version = record.version === undefined ? 2 : record.version
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version <= 0) throw new TypeError(`FCStd Path property ${context}.version must be a positive integer.`)
const center = record.center === undefined ? undefined : record.center
if (center !== undefined) {
if (!center || typeof center !== 'object' || Array.isArray(center)) throw new TypeError(`FCStd Path property ${context}.center is malformed.`)
for (const axis of ['x', 'y', 'z']) if (typeof (center as Record<string, unknown>)[axis] !== 'number' || !Number.isFinite((center as Record<string, unknown>)[axis])) throw new TypeError(`FCStd Path property ${context}.center.${axis} must be finite.`)
}
return { schemaVersion: 1, commands, ...(resourcePath === undefined ? {} : { resourcePath }), version, ...(center === undefined ? {} : { center: center as PathPropertyValue['center'] }) }
}
const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId?: string, topologyElementMap?: ElementMapSnapshot) => {
const attributes = native ? property.type === 'App::PropertyPlacement' ? ' status="8388608"' : '' : ` group="${xmlEscape(property.group ?? '')}" doc="${xmlEscape(property.label ?? property.name)}" attr="0" ro="${property.readOnly ? 1 : 0}" hide="${property.hidden ? 1 : 0}"`
if (property.type === 'Path::PropertyPath') {
const value = pathPropertyValue(property.value, property.name)
const resourcePath = value.resourcePath ?? `${objectId ?? property.name}.nc`
validateEntryPath(resourcePath)
const center = value.center ?? { x: 0, y: 0, z: 0 }
return `<Property name="${xmlEscape(property.name)}" type="Path::PropertyPath"${attributes}><Path file="${xmlEscape(resourcePath)}" version="${value.version ?? 2}"><Center x="${pathNumber(center.x)}" y="${pathNumber(center.y)}" z="${pathNumber(center.z)}"/></Path></Property>`
}
if (property.type === 'App::PropertyEnumeration') {
if (!Array.isArray(property.options) || property.options.length === 0 || property.options.some((option) => typeof option !== 'string') || new Set(property.options).size !== property.options.length) throw new TypeError(`FCStd Enumeration property ${property.name} requires unique string options.`)
const index = property.options.indexOf(String(property.value))
if (index < 0) throw new TypeError(`FCStd Enumeration property ${property.name} requires a value from its options.`)
const custom = native ? '' : ' CustomEnum="true"'
const customList = native ? '' : `<CustomEnumList count="${property.options.length}">${property.options.map((option) => `<Enum value="${xmlEscape(option)}"/>`).join('')}</CustomEnumList>`
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Integer value="${index}"${custom}/>${customList}</Property>`
}
if (property.type === 'App::PropertyVector') {
if (!property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('x' in property.value)) throw new TypeError(`FCStd Vector property ${property.name} requires a vector.`)
const x = finiteComponent(property.value.x, `${property.name}.x`)
const y = finiteComponent(property.value.y, `${property.name}.y`)
const z = finiteComponent(property.value.z, `${property.name}.z`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><PropertyVector valueX="${x}" valueY="${y}" valueZ="${z}"/></Property>`
}
if (property.type === 'App::PropertyPlacement') {
if (!property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('position' in property.value) || !('rotation' in property.value)) throw new TypeError(`FCStd Placement property ${property.name} requires a placement.`)
const px = finiteComponent(property.value.position.x, `${property.name}.position.x`)
const py = finiteComponent(property.value.position.y, `${property.name}.position.y`)
const pz = finiteComponent(property.value.position.z, `${property.name}.position.z`)
const ox = finiteComponent(property.value.rotation.axis.x, `${property.name}.rotation.axis.x`)
const oy = finiteComponent(property.value.rotation.axis.y, `${property.name}.rotation.axis.y`)
const oz = finiteComponent(property.value.rotation.axis.z, `${property.name}.rotation.axis.z`)
const axisLength = Math.hypot(ox, oy, oz)
if (axisLength === 0) throw new RangeError(`FCStd Placement property ${property.name} rotation axis must be non-zero.`)
const angle = finiteComponent(property.value.rotation.angle, `${property.name}.rotation.angle`) * Math.PI / 180
const axis = [ox / axisLength, oy / axisLength, oz / axisLength]
const sine = Math.sin(angle / 2)
const quaternion = [axis[0] * sine, axis[1] * sine, axis[2] * sine, Math.cos(angle / 2)]
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><PropertyPlacement Px="${px}" Py="${py}" Pz="${pz}" Q0="${quaternion[0]}" Q1="${quaternion[1]}" Q2="${quaternion[2]}" Q3="${quaternion[3]}" A="${angle}" Ox="${axis[0]}" Oy="${axis[1]}" Oz="${axis[2]}"/></Property>`
}
if (property.type === 'App::PropertyLink') {
if (property.value !== null && typeof property.value !== 'string') throw new TypeError(`FCStd Link property ${property.name} requires an object name.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Link value="${xmlEscape(property.value ?? '')}"/></Property>`
}
if (property.type === 'App::PropertyLinkList') {
if (!Array.isArray(property.value) || property.value.some((entry) => typeof entry !== 'string')) throw new TypeError(`FCStd LinkList property ${property.name} requires an object-name list.`)
const links = (property.value as string[]).map((entry) => `<Link value="${xmlEscape(entry)}"/>`).join('')
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><LinkList count="${property.value.length}">${links}</LinkList></Property>`
}
if (property.type === 'App::PropertyLinkSubList') {
if (!property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('entries' in property.value) || property.value.schemaVersion !== 1 || !Array.isArray(property.value.entries)) throw new TypeError(`FCStd LinkSubList property ${property.name} requires a structured value.`)
const links = property.value.entries.map((entry) => {
if (!entry || typeof entry.objectId !== 'string' || !entry.objectId.trim()) throw new TypeError(`FCStd LinkSubList property ${property.name} requires non-empty object ids.`)
const subElement = entry.subElement === null ? '' : typeof entry.subElement === 'string' ? entry.subElement : entry.subElement.persistentId
if (entry.subElement && typeof entry.subElement === 'object' && entry.subElement.objectId !== entry.objectId) throw new RangeError(`FCStd LinkSubList property ${property.name} TopoRef targets a different object.`)
if (entry.subElement !== null && !subElement.trim()) throw new TypeError(`FCStd LinkSubList property ${property.name} contains an empty sub-element.`)
return `<Link obj="${xmlEscape(entry.objectId)}" sub="${xmlEscape(subElement)}"/>`
}).join('')
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><LinkSubList count="${property.value.entries.length}">${links}</LinkSubList></Property>`
}
if (property.type === 'Part::PropertyPartShape') {
if (!property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('path' in property.value) || typeof property.value.path !== 'string' || !property.value.path.trim()) throw new TypeError(`FCStd Shape property ${property.name} requires a resource path.`)
validateEntryPath(property.value.path)
if (!/\.(?:brp|brep)$/i.test(property.value.path)) throw new TypeError(`FCStd Shape property ${property.name} requires a .brp or .brep resource.`)
const hasherIndex = property.value.hasherIndex
if (hasherIndex !== undefined && (!Number.isSafeInteger(hasherIndex) || hasherIndex < 0)) throw new RangeError(`FCStd Shape property ${property.name} requires a non-negative HasherIndex.`)
const hasherIndexAttribute = hasherIndex === undefined ? '' : ` HasherIndex="${hasherIndex}"`
const elementMap = property.value.elementMap ? ` ElementMap="${xmlEscape(property.value.elementMap)}"` : ' ElementMap=""'
const generatedMapEntries = objectId && topologyElementMap ? topologyElementMap.entries.filter((entry) => entry.objectId === objectId && (entry.status === 'stable' || entry.status === 'new')).map((entry) => ({ key: entry.candidates?.length === 1 && entry.candidates[0].name ? entry.candidates[0].name : entry.name, value: entry.name })) : undefined
const mapEntries = property.value.elementMapEntries ?? generatedMapEntries
if (mapEntries !== undefined && (!Array.isArray(mapEntries) || mapEntries.some((entry) => !entry || typeof entry.key !== 'string' || !entry.key.trim() || typeof entry.value !== 'string'))) throw new TypeError(`FCStd Shape property ${property.name} ElementMap entries require non-empty keys and string values.`)
const elementMapXml = mapEntries === undefined ? '<ElementMap/>' : `<ElementMap new="1" count="${mapEntries.length}">${mapEntries.map((entry) => `<Element key="${xmlEscape(entry.key)}" value="${xmlEscape(entry.value)}"/>`).join('')}</ElementMap>`
const mapResource = property.value.elementMapResource
if (mapResource !== undefined) {
if (typeof mapResource !== 'string' || !mapResource.trim()) throw new TypeError(`FCStd Shape property ${property.name} requires a non-empty ElementMap2 resource path.`)
validateEntryPath(mapResource)
if (!/\.Map\.txt$/i.test(mapResource)) throw new TypeError(`FCStd Shape property ${property.name} requires an ElementMap2 .Map.txt resource.`)
}
const elementMap2 = mapResource === undefined ? '' : `<ElementMap2 file="${xmlEscape(mapResource)}"/>`
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}" status="1"><Part${hasherIndexAttribute}${elementMap} file="${xmlEscape(property.value.path)}"/>${elementMapXml}${elementMap2}</Property>`
}
if (property.type === 'App::PropertyLinkSub') {
let objectId = ''
let subElements: string[] = []
const value = property.value
if (value !== null && typeof value === 'object' && !Array.isArray(value) && 'objectId' in value && typeof value.objectId === 'string') {
objectId = value.objectId
if ('subElements' in value && Array.isArray(value.subElements)) subElements = value.subElements.map((entry) => typeof entry === 'string' ? entry : entry.persistentId)
else if ('persistentId' in value && typeof value.persistentId === 'string') subElements = [value.persistentId]
else if ('subElement' in value && value.subElement) subElements = [typeof value.subElement === 'string' ? value.subElement : value.subElement.persistentId]
} else if (value !== null) throw new TypeError(`FCStd LinkSub property ${property.name} requires a structured value.`)
if (subElements.some((entry) => !entry.trim())) throw new TypeError(`FCStd LinkSub property ${property.name} contains an empty sub-element.`)
const subs = subElements.map((entry) => `<Sub value="${xmlEscape(entry)}"/>`).join('')
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><LinkSub value="${xmlEscape(objectId)}" count="${subElements.length}">${subs}</LinkSub></Property>`
}
if (property.type === 'App::PropertyFloatList') {
if (!Array.isArray(property.value) || !property.value.every((entry) => typeof entry === 'number' && Number.isFinite(entry))) throw new TypeError(`FCStd FloatList property ${property.name} requires a finite numeric list.`)
const values = (property.value as number[]).map((entry) => `<Float value="${entry}"/>`).join('')
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><FloatList count="${property.value.length}">${values}</FloatList></Property>`
}
if (property.type === 'App::PropertyIntegerList') {
if (!Array.isArray(property.value) || !property.value.every((entry) => typeof entry === 'number' && Number.isSafeInteger(entry))) throw new TypeError(`FCStd IntegerList property ${property.name} requires a safe-integer list.`)
const values = (property.value as number[]).map((entry) => `<I v="${entry}"/>`).join('')
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><IntegerList count="${property.value.length}">${values}</IntegerList></Property>`
}
if (property.type === 'App::PropertyStringList') {
if (!Array.isArray(property.value) || !property.value.every((entry) => typeof entry === 'string')) throw new TypeError(`FCStd StringList property ${property.name} requires a string list.`)
const values = (property.value as string[]).map((entry) => `<String value="${xmlEscape(entry)}"/>`).join('')
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><StringList count="${property.value.length}">${values}</StringList></Property>`
}
if (property.type === 'App::PropertyString') {
if (typeof property.value !== 'string') throw new TypeError(`FCStd String property ${property.name} requires a string.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><String value="${xmlEscape(property.value)}"/></Property>`
}
if (property.type === 'App::PropertyBool') {
if (typeof property.value !== 'boolean') throw new TypeError(`FCStd Bool property ${property.name} requires a boolean.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Bool value="${property.value ? 'true' : 'false'}"/></Property>`
}
if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyFloat') {
if (typeof property.value !== 'number' || !Number.isFinite(property.value)) throw new TypeError(`FCStd numeric property ${property.name} requires a finite number.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Float value="${property.value}"/></Property>`
}
if (property.type === 'App::PropertyInteger' || property.type === 'App::PropertyIntegerConstraint' || property.type === 'App::PropertyPercent') {
if (typeof property.value !== 'number' || !Number.isSafeInteger(property.value)) throw new TypeError(`FCStd integer property ${property.name} requires a safe integer.`)
if (property.type === 'App::PropertyPercent' && (property.value < 0 || property.value > 100)) throw new RangeError(`FCStd Percent property ${property.name} must be between 0 and 100.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Integer value="${property.value}"/></Property>`
}
const value = typeof property.value === 'string' ? property.value : JSON.stringify(property.value)
const element = ({
'App::PropertyLink': 'Link',
'App::PropertyLinkSub': 'LinkSub',
'App::PropertyLinkList': 'LinkList',
'App::PropertyVector': 'PropertyVector',
'App::PropertyPlacement': 'PropertyPlacement',
'App::PropertyMultiTransform': 'MultiTransform',
} as Record<string, string>)[property.type] ?? 'String'
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><${element} value="${xmlEscape(value)}"/></Property>`
}
const expressionEngineXml = (object: DocumentObjectSnapshot) => {
const expressions = object.properties.filter((property) => property.scope !== 'view' && typeof property.expression === 'string' && property.expression.trim()).map((property) => ({ path: property.name, expression: property.expression as string }))
if (expressions.length === 0) return ''
const entries = expressions.map((entry) => `<Expression path="${xmlEscape(entry.path)}" expression="${xmlEscape(entry.expression)}"/>`).join('')
return `<Property name="ExpressionEngine" type="App::PropertyExpressionEngine"><ExpressionEngine count="${expressions.length}">${entries}</ExpressionEngine></Property>`
}
const shapeResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('path' in property.value) || typeof property.value.path !== 'string') return []
return [property.value.path]
}))
const validateNativeShapeResourceName = (objectName: string, resourcePath: string) => {
const basename = resourcePath.slice(resourcePath.lastIndexOf('/') + 1)
if (/\.Shape\.(?:brp|brep)$/i.test(basename) && basename.slice(0, basename.lastIndexOf('.Shape.')) !== objectName) throw new Error(`FCStd Shape resource ${resourcePath} must use ${objectName}.Shape.brp or ${objectName}.Shape.brep for native FreeCAD restore.`)
}
const elementMapResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('elementMapResource' in property.value) || typeof property.value.elementMapResource !== 'string' || !property.value.elementMapResource.trim()) return []
return [property.value.elementMapResource]
}))
const pathResourceReferences = (document: DocumentSnapshot): Array<{ objectId: string; path: string; value: PathPropertyValue }> => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (property.type !== 'Path::PropertyPath') return []
const value = pathPropertyValue(property.value, `${object.id}.${property.name}`)
const path = value.resourcePath ?? `${object.id}.nc`
validateEntryPath(path)
return [{ objectId: object.id, path, value }]
}))
/**
* Writes the metadata portion of an FCStd archive while keeping unknown files opaque.
* Shape/BRep payloads and FreeCAD feature-specific serializers remain separate gates.
*/
export const serializeFcstdMetadataArchive = (document: DocumentSnapshot, options: FcstdWriteOptions = {}): Uint8Array => {
if (!document.id.trim() || !document.label.trim()) throw new TypeError('FCStd metadata writer requires document id and label.')
const objectIds = document.objects.map((object) => object.id.trim())
if (objectIds.some((objectId) => !objectId)) throw new TypeError('FCStd metadata writer requires non-empty object ids.')
if (new Set(objectIds).size !== objectIds.length) throw new TypeError('FCStd metadata writer requires unique object ids.')
const explicitObjectTags = new Set<number>()
for (const object of document.objects) {
if (object.nativeObjectTag === undefined) continue
if (!Number.isSafeInteger(object.nativeObjectTag) || object.nativeObjectTag <= 0) throw new RangeError(`FCStd object ${object.id} nativeObjectTag must be a positive safe integer.`)
if (explicitObjectTags.has(object.nativeObjectTag)) throw new RangeError(`FCStd nativeObjectTag ${object.nativeObjectTag} is assigned to more than one object.`)
explicitObjectTags.add(object.nativeObjectTag)
}
let nextObjectTag = 1
const objectTags = new Map(document.objects.map((object) => {
if (object.nativeObjectTag !== undefined) return [object.id, object.nativeObjectTag] as const
while (explicitObjectTags.has(nextObjectTag)) nextObjectTag += 1
const tag = nextObjectTag
explicitObjectTags.add(tag)
nextObjectTag += 1
return [object.id, tag] as const
}))
const objectIdSet = new Set(objectIds)
const dependencies = new Map(objectIds.map((objectId) => [objectId, [] as string[]]))
for (const edge of document.dependencies ?? []) {
if (!objectIdSet.has(edge.sourceId) || !objectIdSet.has(edge.targetId)) throw new Error(`FCStd dependency ${edge.sourceId} -> ${edge.targetId} references an undeclared object.`)
if (edge.relation !== 'view') dependencies.get(edge.sourceId)?.push(edge.targetId)
}
const objectDeclarations = document.objects.map((object) => {
const targets = dependencies.get(object.id) ?? []
const dependencyXml = targets.length === 0 ? `<ObjectDeps Name="${xmlEscape(object.id)}" Count="0"/>` : `<ObjectDeps Name="${xmlEscape(object.id)}" Count="${targets.length}">${targets.map((target) => `<Dep Name="${xmlEscape(target)}"/>`).join('')}</ObjectDeps>`
return `${dependencyXml}<Object name="${xmlEscape(object.id)}" type="${xmlEscape(nativeFcstdTypeId(object.typeId))}" id="${objectTags.get(object.id)}" Touched="1"/>`
}).join('')
const objectData = document.objects.map((object) => {
const sketchGeneratedPropertyNames = object.sketch ? new Set(['Geometry', 'Constraints', 'ExternalGeo', 'ExternalGeometry', 'ExternalTypes', 'WebGeometryIds', 'WebSyntheticConstraintIds', 'WebExternalIds', 'WebExternalProjectionIds', 'WebExternalSources']) : new Set<string>()
if (object.properties.some((property) => sketchGeneratedPropertyNames.has(property.name))) throw new Error(`FCStd native Sketch ${object.id} properties must come from object.sketch or its attachment codec, not duplicate property snapshots.`)
const properties = object.properties.filter((property) => property.scope !== 'view' && property.name !== 'ExpressionEngine' && !(object.sketch && sketchAttachmentPropertyNames.has(property.name)))
const sketchProperties = sketchNativePropertiesXml(object, objectIdSet)
const expressionEngine = expressionEngineXml(object)
const hasShapeProperty = properties.some((property) => property.type === 'Part::PropertyPartShape')
const shapeMetadata = hasShapeProperty ? '<_Property name="_ElementMapVersion" type="App::PropertyString" status="234881025"/>' : ''
const propertyCount = properties.length + sketchProperties.length + (expressionEngine ? 1 : 0)
return `<Object name="${xmlEscape(object.id)}"><Properties Count="${propertyCount}" TransientCount="${hasShapeProperty ? 1 : 0}">${shapeMetadata}${properties.map((property) => propertyXml(property, isNativeDataProperty(object, property), object.id, object.topology?.elementMap)).join('')}${sketchProperties.join('')}${expressionEngine}</Properties></Object>`
}).join('')
const hasShapeResources = shapeResourceReferences(document).length > 0
const usesStringHasher = document.objects.some((object) => object.properties.some((property) => property.type === 'Part::PropertyPartShape' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'hasherIndex' in property.value && property.value.hasherIndex !== undefined))
const stringHasherTable = options.stringHasherTable ? migrateStringHasherSchema(options.stringHasherTable) : undefined
if (stringHasherTable) {
if (!usesStringHasher) throw new Error('FCStd StringHasher table requires at least one Shape property with HasherIndex.')
const validation = validateStringHasherTable(stringHasherTable)
if (!validation.valid) throw new Error(`FCStd StringHasher table is invalid: ${validation.issues[0].path}: ${validation.issues[0].message}`)
}
const stringHasherXml = hasShapeResources ? usesStringHasher
? '<StringHasher saveall="0" threshold="0" count="0" new="1"/><StringHasher2 file="StringHasher.Table.txt"/>'
: '<StringHasher saveall="0" threshold="0" count="0"></StringHasher>'
: ''
const useHasherXml = hasShapeResources ? '<Property name="UseHasher" type="App::PropertyBool" status="67108865"><Bool value="true"/></Property>' : ''
const documentXml = `<Document SchemaVersion="4" ProgramVersion="1.1R14556 (Git)" FileVersion="1"${hasShapeResources ? ' StringHasher="1"' : ''}>${stringHasherXml}<Properties Count="${hasShapeResources ? 2 : 1}" TransientCount="0"><Property name="Label" type="App::PropertyString"><String value="${xmlEscape(document.label)}"/></Property>${useHasherXml}</Properties><Objects Count="${document.objects.length}" Dependencies="1">${objectDeclarations}</Objects><ObjectData Count="${document.objects.length}">${objectData}</ObjectData></Document>`
if (options.guiDocumentXml && options.guiViews) throw new TypeError('FCStd writer accepts either guiDocumentXml or guiViews, not both.')
const guiViewsXml = options.guiViews?.map((view) => {
if (!view.name.trim()) throw new TypeError('FCStd GuiDocument view names must be non-empty.')
const type = view.type ? ` type="${xmlEscape(view.type)}"` : ''
const visibility = view.visibility ? ` visibility="${xmlEscape(view.visibility)}"` : ''
return `<View name="${xmlEscape(view.name)}"${type}${visibility}/>`
}).join('')
const generatedGuiEntries: Record<string, Uint8Array> = {}
const nativeViewProviders = document.objects.map((object, objectIndex) => {
const viewProperties = object.properties.filter((property) => property.scope === 'view')
const properties = viewProperties.filter((property) => property.name !== 'ShapeColor').map(guiPropertyXml).filter(Boolean)
const shapeColor = viewProperties.find((property) => property.name === 'ShapeColor')
if (shapeColor) {
const resourcePath = `WebShapeAppearance${objectIndex}`
const transparency = viewProperties.find((property) => property.name === 'Transparency')?.value
generatedGuiEntries[resourcePath] = materialAppearanceBytes(shapeColor.value, transparency, object.id)
properties.push(`<Property name="ShapeAppearance" type="App::PropertyMaterialList" status="1"><MaterialList file="${resourcePath}" version="3"/></Property>`)
}
return properties.length === 0 ? '' : `<ViewProvider name="${xmlEscape(object.id)}" expanded="0" treeRank="-1"><Properties Count="${properties.length}" TransientCount="0">${properties.join('')}</Properties></ViewProvider>`
}).filter(Boolean)
const nativeGuiXml = `<Document SchemaVersion="1"><Expand/><ViewProviderData Count="${nativeViewProviders.length}">${nativeViewProviders.join('')}</ViewProviderData><Camera settings=""/></Document>`
const guiXml = options.guiDocumentXml ?? (options.guiViews ? `<GuiDocument SchemaVersion="4"><Views>${guiViewsXml ?? ''}</Views></GuiDocument>` : nativeGuiXml)
if (!/^\s*<(?:GuiDocument|Document)(?:\s|>)/.test(guiXml)) throw new TypeError('FCStd GuiDocument payload root must be GuiDocument or Document.')
const shapePaths = shapeResourceReferences(document)
const mapPaths = elementMapResourceReferences(document)
const pathResources = pathResourceReferences(document)
const pathPaths = pathResources.map((resource) => resource.path)
if (new Set(pathPaths).size !== pathPaths.length) throw new Error('FCStd Path properties must use unique resource paths.')
const opaqueEntries: Record<string, Uint8Array> = { ...(options.opaqueEntries ?? {}) }
if (stringHasherTable) {
if (Object.prototype.hasOwnProperty.call(opaqueEntries, 'StringHasher.Table.txt')) throw new Error('FCStd writer accepts StringHasher.Table.txt either as structured table evidence or as an opaque entry, not both.')
opaqueEntries['StringHasher.Table.txt'] = new TextEncoder().encode(writeStringHasherTable(stringHasherTable))
}
for (const path of shapePaths) {
validateEntryPath(path)
if (!Object.prototype.hasOwnProperty.call(opaqueEntries, path)) throw new Error(`FCStd Shape property references missing resource: ${path}`)
}
for (const object of document.objects) for (const property of object.properties) if (property.type === 'Part::PropertyPartShape' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'path' in property.value && typeof property.value.path === 'string') validateNativeShapeResourceName(object.id, property.value.path)
for (const path of mapPaths) {
validateEntryPath(path)
if (!/\.Map\.txt$/i.test(path)) throw new Error(`FCStd ElementMap2 resource path is not a .Map.txt entry: ${path}`)
if (!Object.prototype.hasOwnProperty.call(opaqueEntries, path)) throw new Error(`FCStd Shape property references missing ElementMap2 resource: ${path}`)
}
for (const path of pathPaths) {
validateEntryPath(path)
if (!/\.nc$/i.test(path)) throw new TypeError(`FCStd Path property resource path is not an .nc entry: ${path}`)
if (!Object.prototype.hasOwnProperty.call(opaqueEntries, path)) {
const resource = pathResources.find((candidate) => candidate.path === path)
if (!resource) throw new Error(`FCStd Path property references missing resource: ${path}`)
opaqueEntries[path] = new TextEncoder().encode(pathResourceText(resource.value.commands))
}
}
if (usesStringHasher && !Object.prototype.hasOwnProperty.call(opaqueEntries, 'StringHasher.Table.txt')) throw new Error('FCStd HasherIndex requires StringHasher.Table.txt in the archive.')
for (const path of Object.keys(opaqueEntries)) {
validateEntryPath(path)
if (path.toLowerCase() === 'document.xml' || path.toLowerCase() === 'guidocument.xml') throw new Error(`Opaque FCStd entry collides with reserved path: ${path}`)
if (Object.prototype.hasOwnProperty.call(generatedGuiEntries, path)) throw new Error(`Opaque FCStd entry collides with generated path: ${path}`)
}
const entries: Record<string, Uint8Array> = { 'Document.xml': new TextEncoder().encode(documentXml) }
const emittedOpaque = new Set<string>()
const emitOpaque = (path: string) => {
if (emittedOpaque.has(path)) return
entries[path] = new Uint8Array(opaqueEntries[path])
emittedOpaque.add(path)
}
for (const path of [...(usesStringHasher ? ['StringHasher.Table.txt'] : []), ...shapePaths, ...mapPaths, ...pathPaths]) emitOpaque(path)
entries['GuiDocument.xml'] = new TextEncoder().encode(guiXml)
if (!options.guiDocumentXml && !options.guiViews) for (const [path, bytes] of Object.entries(generatedGuiEntries)) entries[path] = bytes
for (const path of Object.keys(opaqueEntries)) if (!emittedOpaque.has(path)) emitOpaque(path)
return zipSync(entries, { level: 6 })
}
export const rewriteFcstdMetadataArchive = (archive: Uint8Array, document: DocumentSnapshot, options: Omit<FcstdWriteOptions, 'opaqueEntries'> = {}): Uint8Array => {
if (document.readOnly) {
if (options.guiDocumentXml !== undefined || options.guiViews !== undefined) throw new Error('Read-only FCStd proxy archives cannot override GuiDocument metadata.')
const proxyDocument = inspectFcstdArchive(archive).proxyDocument
if (JSON.stringify(document) !== JSON.stringify(proxyDocument)) throw new Error('Read-only FCStd proxy archives cannot be modified during byte-preserving rewrite.')
return new Uint8Array(archive)
}
const entries = inspectZipDirectory(archive, DEFAULT_FCSTD_LIMITS)
const files = unzipSync(archive)
const opaqueEntries: Record<string, Uint8Array> = {}
for (const entry of entries) {
const lower = entry.path.toLowerCase()
if (lower === 'document.xml' || lower === 'guidocument.xml') continue
const bytes = files[entry.path]
if (!bytes) throw new Error(`FCStd entry ${entry.path} is missing after decompression.`)
opaqueEntries[entry.path] = new Uint8Array(bytes)
}
const guiDocumentXml = options.guiDocumentXml ?? (files['GuiDocument.xml'] ? new TextDecoder('utf-8', { fatal: true }).decode(files['GuiDocument.xml']) : undefined)
if (options.stringHasherTable) delete opaqueEntries['StringHasher.Table.txt']
return serializeFcstdMetadataArchive(document, { guiDocumentXml, opaqueEntries, stringHasherTable: options.stringHasherTable })
}
const blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId)
const entryRole = (path: string): FcstdEntryRole => {
const lower = path.toLowerCase()
if (lower === 'document.xml') return 'document'
if (lower === 'guidocument.xml') return 'gui-document'
if (lower === 'thumbnails/thumbnail.png') return 'thumbnail'
if (lower.endsWith('.brp') || lower.endsWith('.brep')) return 'shape'
if (lower.endsWith('.map.txt')) return 'topology-map'
if (lower.endsWith('.py') || lower.endsWith('.fcmacro') || lower.includes('/macro')) return 'script'
return 'resource'
}
const validateEntryPath = (path: string) => {
if (!path || path.includes('\0') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) throw new Error(`Unsafe FCStd entry path: ${path || '<empty>'}`)
const segments = path.split('/')
if (segments.some((segment) => segment === '..' || segment === '.' || ['__proto__', 'prototype', 'constructor'].includes(segment))) throw new Error(`Unsafe FCStd entry path: ${path}`)
}
const findEndOfCentralDirectory = (bytes: Uint8Array) => {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const minimum = Math.max(0, bytes.byteLength - 65_557)
for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) {
if (view.getUint32(offset, true) === 0x06054b50) return offset
}
throw new Error('FCStd is not a valid ZIP archive: end-of-central-directory record was not found.')
}
const inspectZipDirectory = (bytes: Uint8Array, limits: FcstdArchiveLimits): FcstdEntryMetadata[] => {
if (bytes.byteLength > limits.maxArchiveBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxArchiveBytes} bytes.`)
if (bytes.byteLength < 22) throw new Error('FCStd is not a valid ZIP archive.')
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const eocd = findEndOfCentralDirectory(bytes)
const diskNumber = view.getUint16(eocd + 4, true)
const centralDirectoryDisk = view.getUint16(eocd + 6, true)
const entriesOnDisk = view.getUint16(eocd + 8, true)
const entryCount = view.getUint16(eocd + 10, true)
const directorySize = view.getUint32(eocd + 12, true)
const directoryOffset = view.getUint32(eocd + 16, true)
const commentLength = view.getUint16(eocd + 20, true)
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) throw new Error('Multi-disk FCStd archives are not supported.')
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) throw new Error('ZIP64 FCStd archives are outside the supported import boundary.')
if (entryCount > limits.maxEntries) throw new RangeError(`FCStd archive exceeds ${limits.maxEntries} entries.`)
if (eocd + 22 + commentLength > bytes.byteLength || directoryOffset + directorySize > eocd) throw new Error('FCStd central directory is truncated or inconsistent.')
const decoder = new TextDecoder('utf-8', { fatal: true })
const entries: FcstdEntryMetadata[] = []
const paths = new Set<string>()
let cursor = directoryOffset
let totalUncompressed = 0
for (let index = 0; index < entryCount; index += 1) {
if (cursor + 46 > eocd || view.getUint32(cursor, true) !== 0x02014b50) throw new Error('FCStd central directory contains an invalid file header.')
const flags = view.getUint16(cursor + 8, true)
const compressionMethod = view.getUint16(cursor + 10, true)
const compressedBytes = view.getUint32(cursor + 20, true)
const uncompressedBytes = view.getUint32(cursor + 24, true)
const nameLength = view.getUint16(cursor + 28, true)
const extraLength = view.getUint16(cursor + 30, true)
const entryCommentLength = view.getUint16(cursor + 32, true)
const diskStart = view.getUint16(cursor + 34, true)
const next = cursor + 46 + nameLength + extraLength + entryCommentLength
if (next > eocd) throw new Error('FCStd central-directory entry is truncated.')
if ((flags & 0x1) !== 0) throw new Error('Encrypted FCStd entries are not supported.')
if (diskStart !== 0) throw new Error('Multi-disk FCStd entries are not supported.')
if (compressionMethod !== 0 && compressionMethod !== 8) throw new Error(`Unsupported FCStd ZIP compression method: ${compressionMethod}.`)
if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error('ZIP64 FCStd entries are outside the supported import boundary.')
const path = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength))
validateEntryPath(path)
if (paths.has(path)) throw new Error(`Duplicate FCStd entry path: ${path}`)
paths.add(path)
if (uncompressedBytes > limits.maxEntryBytes) throw new RangeError(`FCStd entry ${path} exceeds ${limits.maxEntryBytes} bytes.`)
const ratio = uncompressedBytes === 0 ? 0 : compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes
if (ratio > limits.maxCompressionRatio) throw new RangeError(`FCStd entry ${path} exceeds the maximum compression ratio.`)
totalUncompressed += uncompressedBytes
if (totalUncompressed > limits.maxTotalUncompressedBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxTotalUncompressedBytes} uncompressed bytes.`)
entries.push({ path, compressedBytes, uncompressedBytes, compressionMethod, role: entryRole(path) })
cursor = next
}
if (cursor !== directoryOffset + directorySize) throw new Error('FCStd central-directory size does not match its entries.')
return entries
}
const asArray = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
const attribute = (node: unknown, name: string): string => {
if (!node || typeof node !== 'object') return ''
const record = node as Record<string, unknown>
const value = record[`@_${name}`] ?? record[name]
return value === undefined || value === null ? '' : String(value)
}
const propertyValue = (property: Record<string, unknown>): string => {
for (const value of Object.values(property)) {
if (!value || typeof value !== 'object') continue
const candidate = attribute(value, 'value')
if (candidate) return candidate
}
return ''
}
const propertyElement = (property: Record<string, unknown>): string => {
const entry = Object.entries(property).find(([name, value]) => !name.startsWith('@_') && Boolean(value && typeof value === 'object'))
return entry?.[0] || ''
}
const finiteAttribute = (node: unknown, name: string, context: string) => {
const text = attribute(node, name)
const value = Number(text)
if (!text || !Number.isFinite(value)) throw new Error(`FCStd ${context} ${name} must be finite.`)
return value
}
const parsedShapeResource = (element: string, elementValue: unknown, property?: Record<string, unknown>): { path: string; hasherIndex?: number; elementMap?: string; elementMapEntries?: Array<{ key: string; value: string }>; elementMapResource?: string } | undefined => {
if (element !== 'Part' || !elementValue || typeof elementValue !== 'object') return undefined
const path = attribute(elementValue, 'file')
if (!path.trim()) throw new Error('FCStd Part shape resource path is missing.')
validateEntryPath(path)
if (!/\.(?:brp|brep)$/i.test(path)) throw new Error(`FCStd Part shape resource path is not BRep: ${path}`)
const hasherIndexText = attribute(elementValue, 'HasherIndex')
const hasherIndex = hasherIndexText === '' ? undefined : Number(hasherIndexText)
if (hasherIndex !== undefined && (!Number.isSafeInteger(hasherIndex) || hasherIndex < 0)) throw new Error(`FCStd Part HasherIndex is invalid: ${hasherIndexText}`)
const elementMap = attribute(elementValue, 'ElementMap')
const mapNodes = property?.ElementMap2 === undefined ? [] : asArray(property.ElementMap2 as Record<string, unknown> | Record<string, unknown>[])
if (mapNodes.length > 1) throw new Error('FCStd Part shape property contains duplicate ElementMap2 resources.')
const mapResource = mapNodes[0] ? attribute(mapNodes[0], 'file') : ''
if (mapNodes[0] && (!mapResource.trim() || !/\.Map\.txt$/i.test(mapResource))) throw new Error(`FCStd ElementMap2 resource path is invalid: ${mapResource || '<empty>'}`)
if (mapResource) validateEntryPath(mapResource)
const mapEntriesNode = property?.ElementMap === undefined ? [] : asArray(property.ElementMap as Record<string, unknown> | Record<string, unknown>[])
if (mapEntriesNode.length > 1) throw new Error('FCStd Part shape property contains duplicate ElementMap nodes.')
const mapEntriesValue = mapEntriesNode[0]
const mapEntries = mapEntriesValue ? asArray((mapEntriesValue as Record<string, unknown>).Element as Record<string, unknown> | Record<string, unknown>[] | undefined).map((entry) => {
const key = attribute(entry, 'key')
if (!key.trim()) throw new Error('FCStd ElementMap entry key must be non-empty.')
return { key, value: attribute(entry, 'value') }
}) : []
if (mapEntriesValue) {
const countText = attribute(mapEntriesValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== mapEntries.length) throw new Error('FCStd ElementMap count does not match its child elements.')
}
return { path, ...(hasherIndex === undefined ? {} : { hasherIndex }), ...(elementMap ? { elementMap } : {}), ...(mapEntriesValue ? { elementMapEntries: mapEntries } : {}), ...(mapResource ? { elementMapResource: mapResource } : {}) }
}
const structuredPropertyValue = (element: string, elementValue: unknown, property?: Record<string, unknown>): string | undefined => {
if (element === 'PropertyVector') return JSON.stringify({
x: finiteAttribute(elementValue, 'valueX', element),
y: finiteAttribute(elementValue, 'valueY', element),
z: finiteAttribute(elementValue, 'valueZ', element),
})
if (element === 'FloatList' && elementValue && typeof elementValue === 'object') {
const values = asArray((elementValue as Record<string, unknown>).Float as Record<string, unknown> | Record<string, unknown>[] | undefined).map((entry) => finiteAttribute(entry, 'value', element))
const countText = attribute(elementValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== values.length) throw new Error(`FCStd FloatList count does not match its child elements.`)
return JSON.stringify(values)
}
if (element === 'IntegerList' && elementValue && typeof elementValue === 'object') {
const values = asArray((elementValue as Record<string, unknown>).I as Record<string, unknown> | Record<string, unknown>[] | undefined).map((entry, index) => {
const text = attribute(entry, 'v') || attribute(entry, 'value')
const value = Number(text)
if (!/^-?\d+$/.test(text) || !Number.isSafeInteger(value)) throw new Error(`FCStd IntegerList item ${index} must be a safe integer.`)
return value
})
const countText = attribute(elementValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== values.length) throw new Error(`FCStd IntegerList count does not match its child elements.`)
return JSON.stringify(values)
}
if (element === 'StringList' && elementValue && typeof elementValue === 'object') {
const values = asArray((elementValue as Record<string, unknown>).String as Record<string, unknown> | Record<string, unknown>[] | undefined).map((entry) => attribute(entry, 'value'))
const countText = attribute(elementValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== values.length) throw new Error(`FCStd StringList count does not match its child elements.`)
return JSON.stringify(values)
}
if (element === 'GeometryList' && elementValue && typeof elementValue === 'object') {
const records = asArray((elementValue as Record<string, unknown>).Geometry as Record<string, unknown> | Record<string, unknown>[] | undefined).map((geometry, index) => {
const type = attribute(geometry, 'type')
const nativeIdText = attribute(geometry, 'id')
const nativeId = Number(nativeIdText)
if (!/^-?\d+$/.test(nativeIdText) || !Number.isSafeInteger(nativeId) || nativeId === 0) throw new Error(`FCStd Sketch Geometry ${index} has an invalid native id.`)
const constructionNode = geometry.Construction && typeof geometry.Construction === 'object' ? geometry.Construction as Record<string, unknown> : undefined
const constructionText = constructionNode ? attribute(constructionNode, 'value') : ''
const construction = constructionText === '1' || constructionText.toLowerCase() === 'true' || attribute(geometry, 'geometryModeFlags').endsWith('10')
const payload = Object.entries(geometry).find(([name, value]) => !name.startsWith('@_') && name !== 'GeoExtensions' && name !== 'Construction' && value && typeof value === 'object')?.[1]
const finite = (node: unknown, name: string) => finiteAttribute(node, name, `Sketch Geometry ${index}`)
let value: SketchGeometry
if (type === 'Part::GeomPoint') value = { id: String(nativeId), type: 'point', position: { x: finite(payload, 'X'), y: finite(payload, 'Y') } }
else if (type === 'Part::GeomLineSegment') value = { id: String(nativeId), type: 'line', start: { x: finite(payload, 'StartX'), y: finite(payload, 'StartY') }, end: { x: finite(payload, 'EndX'), y: finite(payload, 'EndY') } }
else if (type === 'Part::GeomCircle') value = { id: String(nativeId), type: 'circle', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, radius: finite(payload, 'Radius') }
else if (type === 'Part::GeomArcOfCircle') value = { id: String(nativeId), type: 'arc', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, radius: finite(payload, 'Radius'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
else if (type === 'Part::GeomEllipse') value = { id: String(nativeId), type: 'ellipse', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU') }
else if (type === 'Part::GeomArcOfEllipse') value = { id: String(nativeId), type: 'arcEllipse', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
else if (type === 'Part::GeomArcOfHyperbola') value = { id: String(nativeId), type: 'arcHyperbola', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, majorRadius: finite(payload, 'MajorRadius'), minorRadius: finite(payload, 'MinorRadius'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
else if (type === 'Part::GeomArcOfParabola') value = { id: String(nativeId), type: 'arcParabola', center: { x: finite(payload, 'CenterX'), y: finite(payload, 'CenterY') }, focal: finite(payload, 'Focal'), rotation: finite(payload, 'AngleXU'), startAngle: finite(payload, 'StartAngle'), endAngle: finite(payload, 'EndAngle') }
else if (type === 'Part::GeomBSplineCurve') {
const curve = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
const poles = asArray(curve.Pole as Record<string, unknown> | Record<string, unknown>[] | undefined)
const knots = asArray(curve.Knot as Record<string, unknown> | Record<string, unknown>[] | undefined)
const degreeText = attribute(curve, 'Degree')
const degree = Number(degreeText)
if (!/^\d+$/.test(degreeText) || !Number.isSafeInteger(degree) || degree < 1) throw new Error(`FCStd Sketch Geometry ${index} has an invalid B-spline degree.`)
const controlPoints = poles.map((pole, poleIndex) => ({ x: finiteAttribute(pole, 'X', `Sketch Geometry ${index} Pole ${poleIndex}`), y: finiteAttribute(pole, 'Y', `Sketch Geometry ${index} Pole ${poleIndex}`) }))
const weights = poles.map((pole, poleIndex) => finiteAttribute(pole, 'Weight', `Sketch Geometry ${index} Pole ${poleIndex}`))
const expandedKnots = knots.flatMap((knot, knotIndex) => {
const multiplicityText = attribute(knot, 'Mult')
const multiplicity = Number(multiplicityText)
if (!/^\d+$/.test(multiplicityText) || !Number.isSafeInteger(multiplicity) || multiplicity <= 0) throw new Error(`FCStd Sketch Geometry ${index} Knot ${knotIndex} has an invalid multiplicity.`)
return Array.from({ length: multiplicity }, () => finiteAttribute(knot, 'Value', `Sketch Geometry ${index} Knot ${knotIndex}`))
})
value = { id: String(nativeId), type: 'bspline', degree, controlPoints, weights, knots: expandedKnots, periodic: attribute(curve, 'IsPeriodic') === '1' }
} else {
if (!type) throw new Error(`FCStd Sketch Geometry ${index} type is missing.`)
value = { id: String(nativeId), type: 'unsupported', freecadType: type } as unknown as SketchGeometry
}
if (construction) value.construction = true
const extensions = geometry.GeoExtensions && typeof geometry.GeoExtensions === 'object'
? asArray((geometry.GeoExtensions as Record<string, unknown>).GeoExtension as Record<string, unknown> | Record<string, unknown>[] | undefined)
: []
const sketchExtension = extensions.find((extension) => attribute(extension, 'type') === 'Sketcher::SketchGeometryExtension')
if (sketchExtension) {
const internalTypeText = attribute(sketchExtension, 'internalGeometryType') || '0'
const internalType = Number(internalTypeText)
if (!/^\d+$/.test(internalTypeText) || !Number.isSafeInteger(internalType) || internalType < 0 || internalType > 11) throw new Error(`FCStd Sketch Geometry ${index} has an invalid internal geometry type.`)
if (internalType !== 0) Object.assign(value, { freecadInternalType: internalType })
}
const externalExtension = extensions.find((extension) => attribute(extension, 'type') === 'Sketcher::ExternalGeometryExtension')
if (!externalExtension) return value
const flagsText = attribute(externalExtension, 'Flags') || '0'
const flags = Number(flagsText)
const refIndexText = attribute(externalExtension, 'RefIndex')
const refIndex = refIndexText === '' ? undefined : Number(refIndexText)
if (!/^\d+$/.test(flagsText) || !Number.isSafeInteger(flags) || flags < 0) throw new Error(`FCStd Sketch Geometry ${index} has invalid external flags.`)
if (refIndex !== undefined && (!/^\d+$/.test(refIndexText) || !Number.isSafeInteger(refIndex) || refIndex < 0)) throw new Error(`FCStd Sketch Geometry ${index} has an invalid external reference index.`)
return Object.assign(value, { freecadExternal: { ref: attribute(externalExtension, 'Ref'), flags, ...(refIndex === undefined ? {} : { refIndex }) } })
})
const countText = attribute(elementValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== records.length) throw new Error('FCStd GeometryList count does not match its Geometry children.')
return JSON.stringify(records)
}
if (element === 'ConstraintList' && elementValue && typeof elementValue === 'object') {
const records = asArray((elementValue as Record<string, unknown>).Constrain as Record<string, unknown> | Record<string, unknown>[] | undefined).map((constraint, index) => {
const typeText = attribute(constraint, 'Type')
const type = Number(typeText)
if (!/^\d+$/.test(typeText) || !Number.isSafeInteger(type) || type < 0 || type > 19) throw new Error(`FCStd Sketch Constraint ${index} has an invalid type.`)
const valueText = attribute(constraint, 'Value')
const value = valueText === '' ? 0 : Number(valueText)
if (!Number.isFinite(value)) throw new Error(`FCStd Sketch Constraint ${index} has a non-finite value.`)
const ids = attribute(constraint, 'ElementIds').trim().split(/\s+/).filter(Boolean).map(Number)
const positions = attribute(constraint, 'ElementPositions').trim().split(/\s+/).filter(Boolean).map(Number)
if (ids.length !== positions.length || ids.length < 3 || ids.some((id) => !Number.isSafeInteger(id)) || positions.some((position) => !Number.isSafeInteger(position))) throw new Error(`FCStd Sketch Constraint ${index} element references are malformed.`)
const drivingText = attribute(constraint, 'IsDriving')
if (drivingText !== '0' && drivingText !== '1') throw new Error(`FCStd Sketch Constraint ${index} IsDriving is invalid.`)
let internalAlignmentType: number | undefined
let internalAlignmentIndex: number | undefined
if (type === 15) {
const alignmentTypeText = attribute(constraint, 'InternalAlignmentType')
const alignmentIndexText = attribute(constraint, 'InternalAlignmentIndex')
internalAlignmentType = Number(alignmentTypeText)
internalAlignmentIndex = Number(alignmentIndexText)
if (!/^\d+$/.test(alignmentTypeText) || !Number.isSafeInteger(internalAlignmentType) || internalAlignmentType < 1 || internalAlignmentType > 11 || !/^-?\d+$/.test(alignmentIndexText) || !Number.isSafeInteger(internalAlignmentIndex)) throw new Error(`FCStd Sketch Constraint ${index} has invalid internal-alignment metadata.`)
}
return { id: attribute(constraint, 'Name'), type, value, driving: drivingText !== '0', ids, positions, ...(internalAlignmentType === undefined ? {} : { internalAlignmentType, internalAlignmentIndex }) }
})
const countText = attribute(elementValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== records.length) throw new Error('FCStd ConstraintList count does not match its Constrain children.')
return JSON.stringify(records)
}
if (element === 'Path' && elementValue && typeof elementValue === 'object') {
const resourcePath = attribute(elementValue, 'file')
if (!resourcePath.trim()) throw new Error('FCStd Path property resource path is missing.')
validateEntryPath(resourcePath)
if (!/\.nc$/i.test(resourcePath)) throw new Error(`FCStd Path property resource path is not an .nc entry: ${resourcePath}`)
const versionText = attribute(elementValue, 'version') || '2'
const version = Number(versionText)
if (!/^\d+$/.test(versionText) || !Number.isSafeInteger(version) || version <= 0) throw new Error(`FCStd Path property version is invalid: ${versionText}`)
const centerNode = (elementValue as Record<string, unknown>).Center
const center = centerNode && typeof centerNode === 'object' ? {
x: finiteAttribute(centerNode, 'x', 'Path Center'),
y: finiteAttribute(centerNode, 'y', 'Path Center'),
z: finiteAttribute(centerNode, 'z', 'Path Center'),
} : { x: 0, y: 0, z: 0 }
return JSON.stringify({ schemaVersion: 1, commands: [], resourcePath, version, center })
}
const shapeResource = parsedShapeResource(element, elementValue, property)
if (shapeResource) return JSON.stringify({ ...shapeResource, format: 'brep' })
if (element === 'LinkSubList' && elementValue && typeof elementValue === 'object') {
const links = asArray((elementValue as Record<string, unknown>).Link as Record<string, unknown> | Record<string, unknown>[] | undefined).map((entry) => {
const objectId = attribute(entry, 'obj')
if (!objectId.trim()) throw new Error('FCStd LinkSubList object id must be non-empty.')
return { objectId, subElement: attribute(entry, 'sub') }
})
const countText = attribute(elementValue, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== links.length) throw new Error('FCStd LinkSubList count does not match its child elements.')
return JSON.stringify({ schemaVersion: 1, entries: links })
}
if (element !== 'PropertyPlacement') return undefined
const position = {
x: finiteAttribute(elementValue, 'Px', element),
y: finiteAttribute(elementValue, 'Py', element),
z: finiteAttribute(elementValue, 'Pz', element),
}
let axis: { x: number; y: number; z: number }
let angleRadians: number
if (attribute(elementValue, 'A')) {
axis = { x: finiteAttribute(elementValue, 'Ox', element), y: finiteAttribute(elementValue, 'Oy', element), z: finiteAttribute(elementValue, 'Oz', element) }
angleRadians = finiteAttribute(elementValue, 'A', element)
} else {
const quaternion = ['Q0', 'Q1', 'Q2', 'Q3'].map((name) => finiteAttribute(elementValue, name, element))
const length = Math.hypot(...quaternion)
if (length === 0) throw new Error('FCStd PropertyPlacement quaternion must be non-zero.')
const [x, y, z, rawW] = quaternion.map((value) => value / length)
const w = Math.max(-1, Math.min(1, rawW))
angleRadians = 2 * Math.acos(w)
const sine = Math.sqrt(Math.max(0, 1 - w * w))
axis = sine < 1e-12 ? { x: 0, y: 0, z: 1 } : { x: x / sine, y: y / sine, z: z / sine }
}
const axisLength = Math.hypot(axis.x, axis.y, axis.z)
if (axisLength === 0) throw new Error('FCStd PropertyPlacement axis must be non-zero.')
return JSON.stringify({ position, rotation: { axis: { x: axis.x / axisLength, y: axis.y / axisLength, z: axis.z / axisLength }, angle: angleRadians * 180 / Math.PI } })
}
const propertySummaries = (properties: Record<string, unknown>[]): FcstdPropertySummary[] => properties.map((property) => {
const expression = attribute(property, 'expression')
const element = propertyElement(property)
const elementValue = property[element]
const subElements = element === 'LinkSub' && elementValue && typeof elementValue === 'object'
? asArray((elementValue as Record<string, unknown>).Sub as Record<string, unknown> | Record<string, unknown>[] | undefined).map((sub) => attribute(sub, 'value'))
: undefined
const links = element === 'LinkList' && elementValue && typeof elementValue === 'object'
? asArray((elementValue as Record<string, unknown>).Link as Record<string, unknown> | Record<string, unknown>[] | undefined).map((link) => attribute(link, 'value'))
: undefined
const linkSubs = element === 'LinkSubList' && elementValue && typeof elementValue === 'object'
? asArray((elementValue as Record<string, unknown>).Link as Record<string, unknown> | Record<string, unknown>[] | undefined).map((link) => ({ objectId: attribute(link, 'obj'), subElement: attribute(link, 'sub') }))
: undefined
const customEnumList = property.CustomEnumList && typeof property.CustomEnumList === 'object' ? property.CustomEnumList as Record<string, unknown> : undefined
const enumOptions = customEnumList ? asArray(customEnumList.Enum as Record<string, unknown> | Record<string, unknown>[] | undefined).map((entry) => attribute(entry, 'value')) : undefined
if (customEnumList) {
const countText = attribute(customEnumList, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== enumOptions?.length) throw new Error('FCStd CustomEnumList count does not match its Enum children.')
}
const shapeResource = parsedShapeResource(element, elementValue, property)
if ((subElements || links || linkSubs) && elementValue && typeof elementValue === 'object') {
const countText = attribute(elementValue, 'count')
const count = Number(countText)
const actual = (subElements ?? links ?? linkSubs as unknown[]).length
if (!/^\d+$/.test(countText) || !Number.isSafeInteger(count) || count !== actual) throw new Error(`FCStd ${element} count does not match its child elements.`)
}
const value = structuredPropertyValue(element, elementValue, property) ?? propertyValue(property)
return { name: attribute(property, 'name') || '<unnamed>', typeId: attribute(property, 'type') || element || 'unknown', element, value, ...(subElements ? { subElements } : {}), ...(links ? { links } : {}), ...(linkSubs ? { linkSubs } : {}), ...(enumOptions ? { enumOptions } : {}), ...(shapeResource ? { shapeResource } : {}), ...(expression ? { expression } : {}) }
})
const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertySummary[]): SketchSnapshot | undefined => {
const geometryProperty = summaries.find((property) => property.name === 'Geometry' && property.element === 'GeometryList')
const constraintProperty = summaries.find((property) => property.name === 'Constraints' && property.element === 'ConstraintList')
if (!geometryProperty || !constraintProperty) return undefined
type ParsedNativeSketchGeometry = SketchGeometry & { freecadInternalType?: number }
let nativeGeometry: ParsedNativeSketchGeometry[]
let constraints: Array<{ id: string; type: number; value: number; driving: boolean; ids: number[]; positions: number[]; internalAlignmentType?: number; internalAlignmentIndex?: number }>
try {
nativeGeometry = JSON.parse(geometryProperty.value) as ParsedNativeSketchGeometry[]
constraints = JSON.parse(constraintProperty.value) as typeof constraints
} catch {
return undefined
}
const editableNativeIndexes = nativeGeometry.flatMap((candidate, index) => candidate.freecadInternalType ? [] : [index])
const webIds = summaries.find((property) => property.name === 'WebGeometryIds' && property.element === 'StringList')
if (webIds) {
let ids: unknown
try { ids = JSON.parse(webIds.value) } catch { throw new Error(`FCStd Sketch ${objectId} WebGeometryIds is not valid JSON.`) }
if (!Array.isArray(ids) || ids.length !== editableNativeIndexes.length || ids.some((id) => typeof id !== 'string' || !id.trim())) throw new Error(`FCStd Sketch ${objectId} WebGeometryIds count does not match editable GeometryList items.`)
const idByNativeIndex = new Map(editableNativeIndexes.map((nativeIndex, index) => [nativeIndex, ids[index] as string]))
nativeGeometry = nativeGeometry.map((candidate, nativeIndex) => idByNativeIndex.has(nativeIndex) ? { ...candidate, id: idByNativeIndex.get(nativeIndex)! } : candidate)
}
const geometry = nativeGeometry.filter((candidate) => !candidate.freecadInternalType).map(({ freecadInternalType: _internalType, ...candidate }) => candidate as SketchGeometry)
type ParsedExternalGeometry = SketchGeometry & { freecadExternal?: { ref: string; flags: number; refIndex?: number } }
const externalLinkProperty = summaries.find((property) => property.name === 'ExternalGeometry' && property.element === 'LinkSubList')
const externalGeoProperty = summaries.find((property) => property.name === 'ExternalGeo' && property.element === 'GeometryList')
const externalTypesProperty = summaries.find((property) => property.name === 'ExternalTypes' && property.element === 'IntegerList')
let externalGeometry: SketchSnapshot['externalGeometry'] = []
if (externalLinkProperty || externalGeoProperty || externalTypesProperty) {
if (!externalLinkProperty || !externalGeoProperty || !externalTypesProperty) return undefined
const links = externalLinkProperty.linkSubs ?? []
let nativeExternalGeometry: ParsedExternalGeometry[]
let externalTypes: number[]
try {
nativeExternalGeometry = (JSON.parse(externalGeoProperty.value) as ParsedExternalGeometry[]).filter((candidate) => Boolean(candidate.freecadExternal?.ref))
externalTypes = JSON.parse(externalTypesProperty.value) as number[]
} catch {
return undefined
}
const defaultEmptyTypes = links.length === 0 && externalTypes.length === 1 && externalTypes[0] === 0
if (!Array.isArray(nativeExternalGeometry) || !Array.isArray(externalTypes) || (!defaultEmptyTypes && externalTypes.length !== links.length) || externalTypes.some((type) => !Number.isSafeInteger(type) || type < 0 || type >= externalModeByNativeType.length)) return undefined
const metadataList = (name: string) => {
const property = summaries.find((candidate) => candidate.name === name && candidate.element === 'StringList')
if (!property) return undefined
let values: unknown
try { values = JSON.parse(property.value) } catch { throw new Error(`FCStd Sketch ${objectId} ${name} is not valid JSON.`) }
if (!Array.isArray(values) || values.length !== nativeExternalGeometry.length || values.some((value) => typeof value !== 'string' || !value.trim())) throw new Error(`FCStd Sketch ${objectId} ${name} count does not match ExternalGeo.`)
return values as string[]
}
const externalIds = metadataList('WebExternalIds')
const projectionIds = metadataList('WebExternalProjectionIds')
const serializedSources = metadataList('WebExternalSources')
if ([externalIds, projectionIds, serializedSources].some(Boolean) && (!externalIds || !projectionIds || !serializedSources)) throw new Error(`FCStd Sketch ${objectId} external Web metadata is incomplete.`)
const nativeKinds = links.map((link) => /^Face[1-9]\d*$/.test(link.subElement) ? 'face' as const : /^Edge[1-9]\d*$/.test(link.subElement) ? 'edge' as const : /^Vertex[1-9]\d*$/.test(link.subElement) ? 'vertex' as const : undefined)
if (nativeKinds.some((kind) => kind === undefined)) return undefined
const linkIndexByReference = new Map<string, number>()
for (const [index, link] of links.entries()) {
const reference = `${link.objectId}.${link.subElement}`
if (linkIndexByReference.has(reference)) return undefined
linkIndexByReference.set(reference, index)
}
const groupIndexByExternalReference = new Map<string, number>()
for (const [index, candidate] of nativeExternalGeometry.entries()) {
const externalState = candidate.freecadExternal
if (!externalState || !Number.isSafeInteger(externalState.flags) || externalState.flags < 0 || externalState.flags > 31) throw new Error(`FCStd Sketch ${objectId} ExternalGeo item ${index} has unsupported native flags.`)
if ((externalState.flags & 16) !== 0 && (externalState.flags & 2) === 0) throw new Error(`FCStd Sketch ${objectId} ExternalGeo item ${index} cannot synchronize unless it is frozen.`)
if (externalState.refIndex === undefined) continue
if (externalState.refIndex < 0 || externalState.refIndex >= links.length) throw new Error(`FCStd Sketch ${objectId} ExternalGeo item ${index} has an invalid RefIndex.`)
const existing = groupIndexByExternalReference.get(externalState.ref)
if (existing !== undefined && existing !== externalState.refIndex) throw new Error(`FCStd Sketch ${objectId} external reference '${externalState.ref}' has conflicting RefIndex values.`)
groupIndexByExternalReference.set(externalState.ref, externalState.refIndex)
}
const claimedGroupIndexes = new Set(groupIndexByExternalReference.values())
const projectionGroupIndexes = nativeExternalGeometry.map((candidate, index) => {
const externalState = candidate.freecadExternal!
let groupIndex = externalState.refIndex ?? groupIndexByExternalReference.get(externalState.ref)
if (groupIndex === undefined) {
const exactIndex = linkIndexByReference.get(externalState.ref)
groupIndex = exactIndex !== undefined && !claimedGroupIndexes.has(exactIndex)
? exactIndex
: links.findIndex((_link, candidateIndex) => !claimedGroupIndexes.has(candidateIndex))
if (groupIndex < 0) throw new Error(`FCStd Sketch ${objectId} ExternalGeo item ${index} does not match a native link.`)
groupIndexByExternalReference.set(externalState.ref, groupIndex)
claimedGroupIndexes.add(groupIndex)
}
return groupIndex
})
const projectionCounts = new Map<number, number>()
for (const groupIndex of projectionGroupIndexes) projectionCounts.set(groupIndex, (projectionCounts.get(groupIndex) ?? 0) + 1)
if (links.some((_link, index) => !projectionCounts.has(index)) || [...projectionCounts].some(([index, count]) => externalTypes[index] === 0 && nativeKinds[index] !== 'face' && count !== 1)) return undefined
if (nativeExternalGeometry.some((candidate, index) => externalTypes[projectionGroupIndexes[index]] === 0 && (nativeKinds[projectionGroupIndexes[index]] === 'vertex' ? candidate.type !== 'point' : candidate.type === 'point'))) return undefined
const flagsByGroup = new Map<number, number>()
for (const [index, groupIndex] of projectionGroupIndexes.entries()) {
const flags = nativeExternalGeometry[index].freecadExternal!.flags
const existing = flagsByGroup.get(groupIndex)
if (existing !== undefined && existing !== flags) throw new Error(`FCStd Sketch ${objectId} external group ${groupIndex} has conflicting native flags.`)
flagsByGroup.set(groupIndex, flags)
}
const sourceByGroup = new Map<number, string>()
externalGeometry = nativeExternalGeometry.map((nativeGeometry, index) => {
const groupIndex = projectionGroupIndexes[index]
const link = links[groupIndex]
const externalState = nativeGeometry.freecadExternal
if (!externalState) throw new Error(`FCStd Sketch ${objectId} ExternalGeo item ${index} is missing native state.`)
let source: TopoRefValue
if (serializedSources) {
try {
const encoded = serializedSources[index]
try { source = JSON.parse(encoded) as TopoRefValue }
catch { source = JSON.parse(decodeURIComponent(encoded)) as TopoRefValue }
} catch { throw new Error(`FCStd Sketch ${objectId} WebExternalSources item ${index} is not valid JSON.`) }
const nativeKind = nativeKinds[groupIndex]
if (source?.schemaVersion !== 1 || source.objectId !== link.objectId || source.persistentId !== link.subElement || source.kind !== nativeKind || source.status !== 'stable' || !Number.isSafeInteger(source.topologyVersion) || source.topologyVersion < 0 || !Number.isSafeInteger(source.generation) || source.generation < 0 || (source.signature !== undefined && typeof source.signature !== 'string') || (source.candidates !== undefined && (!Array.isArray(source.candidates) || source.candidates.some((candidate) => typeof candidate !== 'string' || !candidate.trim())))) throw new Error(`FCStd Sketch ${objectId} WebExternalSources item ${index} does not match native ExternalGeometry.`)
const serializedSource = topoRefMetadataKey(source)
const existingSource = sourceByGroup.get(groupIndex)
if (existingSource !== undefined && existingSource !== serializedSource) throw new Error(`FCStd Sketch ${objectId} Face projection group ${groupIndex} has conflicting Web TopoRef metadata.`)
sourceByGroup.set(groupIndex, serializedSource)
} else {
const kind = nativeKinds[groupIndex]!
source = { schemaVersion: 1, objectId: link.objectId, kind, persistentId: link.subElement, topologyVersion: 0, generation: 0, status: 'stable' }
}
const { freecadExternal: _external, ...projectionValue } = nativeGeometry
const projection = { ...projectionValue, id: projectionIds?.[index] ?? `ExternalProjection${index}`, construction: true } as SketchGeometry
const mode = externalModeByNativeType[externalTypes[groupIndex]]
return {
id: externalIds?.[index] ?? `External${index}`,
source,
projection,
construction: true as const,
...(mode === 'projection' ? {} : { mode }),
...webExternalStateFromNativeFlags(externalState.flags),
}
})
}
const syntheticConstraintProperty = summaries.find((property) => property.name === 'WebSyntheticConstraintIds' && property.element === 'StringList')
let syntheticConstraintIds = new Set<string>()
if (syntheticConstraintProperty) {
let ids: unknown
try { ids = JSON.parse(syntheticConstraintProperty.value) } catch { throw new Error(`FCStd Sketch ${objectId} WebSyntheticConstraintIds is not valid JSON.`) }
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string' || !id.trim()) || new Set(ids).size !== ids.length) throw new Error(`FCStd Sketch ${objectId} WebSyntheticConstraintIds must contain unique non-empty strings.`)
syntheticConstraintIds = new Set(ids as string[])
}
const nativeGeometryForNativeId = (nativeId: number): ParsedNativeSketchGeometry | undefined => nativeId >= 0 && nativeId < nativeGeometry.length
? nativeGeometry[nativeId]
: nativeId <= -3 ? externalGeometry[-nativeId - 3]?.projection : undefined
const geometryForNativeId = (nativeId: number): SketchGeometry | undefined => {
const candidate = nativeGeometryForNativeId(nativeId)
return candidate && !candidate.freecadInternalType ? candidate : undefined
}
type InternalHelperBinding = { geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'hyperbola-major' | 'hyperbola-minor' | 'hyperbola-focus' | 'parabola-focus' | 'parabola-focal-axis' | 'bspline-control-point' | 'bspline-knot' }
const internalHelperBindings = new Map<number, InternalHelperBinding>()
for (const record of constraints) {
if (record.type !== 15) continue
if (record.internalAlignmentType === undefined || record.internalAlignmentIndex === undefined) continue
const helperNativeId = record.ids[0]
const targetNativeId = record.ids[1]
const helper = nativeGeometryForNativeId(helperNativeId)
const target = geometryForNativeId(targetNativeId)
if (!helper || helper.freecadInternalType !== record.internalAlignmentType || !target || record.positions[1] !== 0 || record.ids[2] !== -2000 || record.positions[2] !== 0) return undefined
let binding: InternalHelperBinding | undefined
if (record.internalAlignmentType === 1 || record.internalAlignmentType === 2) {
if (helper.type !== 'line' || (target.type !== 'ellipse' && target.type !== 'arcEllipse') || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 1 ? 'ellipse-major' : 'ellipse-minor' }
} else if (record.internalAlignmentType === 3 || record.internalAlignmentType === 4) {
if (helper.type !== 'point' || (target.type !== 'ellipse' && target.type !== 'arcEllipse') || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentType === 3 ? 0 : 1, alignmentType: 'ellipse-focus' }
} else if (record.internalAlignmentType === 5 || record.internalAlignmentType === 6) {
if (helper.type !== 'line' || target.type !== 'arcHyperbola' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: record.internalAlignmentType === 5 ? 'hyperbola-major' : 'hyperbola-minor' }
} else if (record.internalAlignmentType === 7) {
if (helper.type !== 'point' || target.type !== 'arcHyperbola' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'hyperbola-focus' }
} else if (record.internalAlignmentType === 8) {
if (helper.type !== 'point' || target.type !== 'arcParabola' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'parabola-focus' }
} else if (record.internalAlignmentType === 9) {
if (helper.type !== 'circle' || target.type !== 'bspline' || record.positions[0] !== 3 || record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= target.controlPoints.length) return undefined
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-control-point' }
} else if (record.internalAlignmentType === 10) {
if (helper.type !== 'point' || target.type !== 'bspline' || record.positions[0] !== 1) return undefined
const uniqueKnots = bsplineExpandedKnots(target).filter((knot, index, knots) => index === 0 || knot !== knots[index - 1])
if (record.internalAlignmentIndex < 0 || record.internalAlignmentIndex >= uniqueKnots.length) return undefined
binding = { geometryId: target.id, internalGeometryIndex: record.internalAlignmentIndex, alignmentType: 'bspline-knot' }
} else if (record.internalAlignmentType === 11) {
if (helper.type !== 'line' || target.type !== 'arcParabola' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
binding = { geometryId: target.id, internalGeometryIndex: 0, alignmentType: 'parabola-focal-axis' }
} else return undefined
const existing = internalHelperBindings.get(helperNativeId)
if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) throw new Error(`FCStd Sketch ${objectId} internal helper ${helperNativeId} has conflicting geometry bindings.`)
internalHelperBindings.set(helperNativeId, binding)
}
if (nativeGeometry.some((candidate, nativeIndex) => candidate.freecadInternalType && !internalHelperBindings.has(nativeIndex))) return undefined
const point = (record: typeof constraints[number], slot: number): SketchPointRef | undefined => {
const index = record.ids[slot]
if (index === -2000) return undefined
const candidate = geometryForNativeId(index)
if (!candidate) return undefined
const position = record.positions[slot]
if (candidate.type === 'line') return position === 1 ? { geometryId: candidate.id, point: 'start' } : position === 2 ? { geometryId: candidate.id, point: 'end' } : undefined
if (candidate.type === 'point') return position === 1 ? { geometryId: candidate.id, point: 'position' } : undefined
if (candidate.type === 'circle' || candidate.type === 'arc' || candidate.type === 'ellipse' || candidate.type === 'arcEllipse' || candidate.type === 'arcHyperbola' || candidate.type === 'arcParabola') return position === 3 ? { geometryId: candidate.id, point: 'center' } : undefined
return undefined
}
const geometryRef = (record: typeof constraints[number], slot: number) => geometryForNativeId(record.ids[slot])?.id
const mapped: SketchConstraint[] = []
const consumedSyntheticConstraintIds = new Set<string>()
for (const [index, record] of constraints.entries()) {
const id = record.id.trim() || `Constraint${index}`
if (syntheticConstraintIds.has(id)) {
if (record.type !== 15 || internalHelperBindings.get(record.ids[0])?.alignmentType !== 'bspline-control-point') throw new Error(`FCStd Sketch ${objectId} synthetic constraint '${id}' is not a B-spline control-point alignment.`)
if (consumedSyntheticConstraintIds.has(id)) throw new Error(`FCStd Sketch ${objectId} contains duplicate synthetic constraint '${id}'.`)
consumedSyntheticConstraintIds.add(id)
continue
}
const first = point(record, 0)
const second = point(record, 1)
const third = point(record, 2)
const firstGeometryId = geometryRef(record, 0)
const secondGeometryId = geometryRef(record, 1)
const thirdGeometryId = geometryRef(record, 2)
let constraint: SketchConstraint | undefined
switch (record.type) {
case 1: if (first && second) constraint = { id, type: 'coincident', first, second, driving: record.driving }; break
case 2: if (firstGeometryId) constraint = { id, type: 'horizontal', geometryId: firstGeometryId, driving: record.driving }; break
case 3: if (firstGeometryId) constraint = { id, type: 'vertical', geometryId: firstGeometryId, driving: record.driving }; break
case 4: if (firstGeometryId && secondGeometryId) constraint = { id, type: 'parallel', firstGeometryId, secondGeometryId, driving: record.driving }; break
case 5: if (firstGeometryId && secondGeometryId) constraint = { id, type: 'tangent', firstGeometryId, secondGeometryId, driving: record.driving }; break
case 6: if (first && second) constraint = { id, type: 'distance', first, second, value: record.value, driving: record.driving }; break
case 7: if (first && second) constraint = { id, type: 'distanceX', first, second, value: record.value, driving: record.driving }; break
case 8: if (first && second) constraint = { id, type: 'distanceY', first, second, value: record.value, driving: record.driving }; break
case 9: if (firstGeometryId) constraint = { id, type: 'angle', geometryId: firstGeometryId, value: record.value, driving: record.driving }; break
case 10: if (firstGeometryId && secondGeometryId) constraint = { id, type: 'perpendicular', firstGeometryId, secondGeometryId, driving: record.driving }; break
case 11: if (firstGeometryId) constraint = { id, type: 'radius', geometryId: firstGeometryId, value: record.value, driving: record.driving }; break
case 12: if (firstGeometryId && secondGeometryId) constraint = { id, type: 'equal', firstGeometryId, secondGeometryId, driving: record.driving }; break
case 13: if (first && secondGeometryId) constraint = { id, type: 'pointOnObject', point: first, geometryId: secondGeometryId, driving: record.driving }; break
case 14: if (first && second && third) constraint = { id, type: 'symmetric', first, second, center: third, driving: record.driving }; break
case 15: {
const binding = internalHelperBindings.get(record.ids[0])
if (binding) constraint = { id, type: 'internalAlignment', ...binding, driving: record.driving }
break
}
case 16: if (first && second && thirdGeometryId) constraint = { id, type: 'snellsLaw', first, second, boundaryGeometryId: thirdGeometryId, value: record.value, driving: record.driving }; break
case 17: if (firstGeometryId) constraint = { id, type: 'block', geometryId: firstGeometryId, driving: record.driving }; break
case 18: if (firstGeometryId) constraint = { id, type: 'diameter', geometryId: firstGeometryId, value: record.value, driving: record.driving }; break
case 19: {
const binding = internalHelperBindings.get(record.ids[0])
if (binding?.alignmentType === 'bspline-control-point' && Number.isFinite(record.value) && record.value > 0) constraint = { id, type: 'weight', geometryId: binding.geometryId, controlPointIndex: binding.internalGeometryIndex, value: record.value, driving: record.driving }
break
}
default: return undefined
}
if (!constraint) return undefined
mapped.push(constraint)
}
if (consumedSyntheticConstraintIds.size !== syntheticConstraintIds.size) throw new Error(`FCStd Sketch ${objectId} WebSyntheticConstraintIds references a missing native constraint.`)
const sketch: SketchSnapshot = { id: objectId, geometry, externalGeometry, constraints: mapped, solver: { status: 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] } }
try { validateSketchSnapshot(sketch); return sketch } catch { return undefined }
}
export const decodeFcstdPropertyValue = (property: FcstdPropertySummary): FcstdDecodedPropertyValue => {
const numericTypes = new Set(['App::PropertyLength', 'App::PropertyDistance', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyFloatConstraint', 'App::PropertyInteger', 'App::PropertyIntegerConstraint', 'App::PropertyPercent'])
if (numericTypes.has(property.typeId)) {
const value = Number(property.value)
return Number.isFinite(value) ? { value, decoded: true } : { value: property.value, decoded: false, error: `Property ${property.name} is not a finite number.` }
}
if (property.typeId === 'App::PropertyEnumeration') {
const index = Number(property.value)
if (!Number.isSafeInteger(index) || index < 0) return { value: property.value, decoded: false, error: `Property ${property.name} is not a valid enumeration index.` }
if (property.name === 'MapMode' && !property.enumOptions) return index < freecadAttachmentMapModes.length
? { value: freecadAttachmentMapModes[index], decoded: true }
: { value: index, decoded: false, error: `Property ${property.name} enumeration index is outside the native FreeCAD map modes.` }
if (!property.enumOptions) return { value: index, decoded: true }
return index < property.enumOptions.length ? { value: property.enumOptions[index], decoded: true } : { value: index, decoded: false, error: `Property ${property.name} enumeration index is outside its options.` }
}
if (property.typeId === 'App::PropertyBool') {
if (property.value === 'true' || property.value === 'false') return { value: property.value === 'true', decoded: true }
return { value: property.value, decoded: false, error: `Property ${property.name} is not a boolean.` }
}
if (property.typeId === 'App::PropertyLinkSub' && property.subElements) return {
value: { schemaVersion: 1, objectId: property.value, subElements: [...property.subElements] },
decoded: true,
}
if (property.typeId === 'App::PropertyLink') return { value: property.value || null, decoded: true }
if (property.typeId === 'App::PropertyLinkList' && property.links) return { value: [...property.links], decoded: true }
if (property.typeId === 'App::PropertyLinkSubList' && property.linkSubs) return { value: { schemaVersion: 1, entries: property.linkSubs.map((entry) => ({ ...entry })) }, decoded: true }
if (property.typeId === 'Part::PropertyPartShape' && property.shapeResource) return { value: { ...property.shapeResource, format: 'brep' }, decoded: true }
if (property.typeId === 'Path::PropertyPath') {
try {
const value = JSON.parse(property.value) as unknown
return { value: pathPropertyValue(value, property.name), decoded: true }
} catch (error) {
return { value: property.value, decoded: false, error: `Property ${property.name} contains invalid Path data: ${error instanceof Error ? error.message : String(error)}` }
}
}
if (property.typeId === 'App::PropertyLinkSub' || property.typeId === 'App::PropertyLinkSubList' || property.typeId === 'App::PropertyLinkList' || property.typeId === 'App::PropertyFloatList' || property.typeId === 'App::PropertyIntegerList' || property.typeId === 'App::PropertyStringList' || property.typeId === 'App::PropertyVector' || property.typeId === 'App::PropertyPlacement' || property.typeId === 'App::PropertyMultiTransform' || property.typeId === 'Part::PropertyGeometryList' || property.typeId === 'Sketcher::PropertyConstraintList') {
try { return { value: JSON.parse(property.value), decoded: true } } catch (error) { return { value: property.value, decoded: false, error: `Property ${property.name} contains invalid JSON: ${error instanceof Error ? error.message : String(error)}` } }
}
return { value: property.value, decoded: true }
}
export const encodeFcstdPathResource = (value: PathPropertyValue): Uint8Array => new TextEncoder().encode(pathResourceText(pathPropertyValue(value, 'Path').commands))
export const decodeFcstdPathProperty = (bytes: Uint8Array, objectName: string, propertyName = 'Path', options: FcstdPathAccessOptions = {}): PathPropertyValue => {
if (!objectName.trim()) throw new TypeError('FCStd Path decoder requires an object name.')
const inspection = inspectFcstdArchive(bytes)
const object = inspection.objects.find((candidate) => candidate.name === objectName)
if (!object) throw new Error(`FCStd Path object does not exist: ${objectName}`)
const featurePython = object.typeId === 'Path::FeaturePython'
if (object.typeId !== 'Path::Feature' && !(featurePython && options.allowFeaturePython === true)) throw new Error(`FCStd Path editing is limited to native Path::Feature objects unless allowFeaturePython is explicitly enabled; ${objectName} is ${object.typeId}.`)
const property = object.properties.find((candidate) => candidate.name === propertyName && candidate.typeId === 'Path::PropertyPath')
if (!property) throw new Error(`FCStd Path property does not exist: ${objectName}.${propertyName}`)
const decoded = decodeFcstdPropertyValue(property)
if (!decoded.decoded) throw new Error(decoded.error ?? `FCStd Path property ${objectName}.${propertyName} could not be decoded.`)
return pathPropertyValue(decoded.value, `${objectName}.${propertyName}`)
}
/**
* Rewrites only the native .nc payload referenced by Path::PropertyPath. The
* XML metadata and all unknown FCStd entries remain unchanged, so FreeCAD's
* native Path loader remains the reopen authority and Python payloads never
* execute in the browser.
*/
export const rewriteFcstdPathProperty = (bytes: Uint8Array, edit: FcstdPathEdit): Uint8Array => {
const propertyName = edit.propertyName ?? 'Path'
const current = decodeFcstdPathProperty(bytes, edit.objectName, propertyName, { allowFeaturePython: edit.allowFeaturePython })
const next = pathPropertyValue(edit.value, `${edit.objectName}.${propertyName}`)
if ((next.resourcePath ?? current.resourcePath) !== current.resourcePath) throw new Error('FCStd Path rewrite cannot change the native resource path.')
if ((next.version ?? current.version) !== current.version) throw new Error('FCStd Path rewrite cannot change the native Path resource version.')
if (JSON.stringify(next.center ?? current.center) !== JSON.stringify(current.center)) throw new Error('FCStd Path rewrite cannot change the native Path center metadata.')
const resourcePath = current.resourcePath
if (!resourcePath) throw new Error('FCStd Path rewrite requires a native resource path.')
const entries = inspectZipDirectory(bytes, DEFAULT_FCSTD_LIMITS)
const files = unzipSync(bytes)
if (!files[resourcePath]) throw new Error(`FCStd Path resource is missing: ${resourcePath}`)
files[resourcePath] = new Uint8Array(encodeFcstdPathResource({ ...next, resourcePath, version: current.version, center: current.center }))
const output: Record<string, Uint8Array> = {}
for (const entry of entries) output[entry.path] = new Uint8Array(files[entry.path])
return new Uint8Array(zipSync(output, { level: 6 }))
}
const parseXmlRoot = (bytes: Uint8Array, entryName: string, rootName: string | string[], limits: FcstdArchiveLimits) => {
const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error(`FCStd ${entryName} declarations and entities are not allowed.`)
let nodeCount = 0
let parsed: Record<string, unknown>
try {
parsed = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
processEntities: false,
allowBooleanAttributes: false,
maxNestedTags: limits.maxXmlDepth,
updateTag: (tagName) => {
nodeCount += 1
if (nodeCount > limits.maxXmlNodes) throw new RangeError(`FCStd ${entryName} exceeds ${limits.maxXmlNodes} XML nodes.`)
return tagName
},
}).parse(xml, true) as Record<string, unknown>
} catch (error) {
if (error instanceof RangeError) throw error
const message = error instanceof Error ? error.message : String(error)
if (/Maximum nested tags exceeded/.test(message)) throw new RangeError(`FCStd ${entryName} exceeds XML nesting depth ${limits.maxXmlDepth}.`)
throw new Error(`FCStd ${entryName} contains invalid XML: ${message}`)
}
const rootNames = Array.isArray(rootName) ? rootName : [rootName]
const matchedRootName = rootNames.find((candidate) => Object.prototype.hasOwnProperty.call(parsed, candidate))
if (!matchedRootName) throw new Error(`FCStd ${entryName} root must be ${rootNames.join(' or ')}.`)
const rootValue = parsed[matchedRootName]
const root = (rootValue && typeof rootValue === 'object' ? rootValue : {}) as Record<string, unknown>
return { xml, root, rootName: matchedRootName }
}
const assertUniqueNodeNames = (nodes: Record<string, unknown>[], kind: string) => {
const names = nodes.map((node) => attribute(node, 'name'))
if (names.some((name) => !name)) throw new Error(`FCStd ${kind} names must be non-empty.`)
const duplicate = names.find((name, index) => names.indexOf(name) !== index)
if (duplicate) throw new Error(`Duplicate FCStd ${kind} name: ${duplicate}`)
}
const assertContainerCount = (container: unknown, attributeName: string, actual: number, context: string) => {
if (!container || typeof container !== 'object') return
const declared = attribute(container, attributeName)
if (declared && (!/^\d+$/.test(declared) || Number(declared) !== actual)) throw new Error(`FCStd ${context} ${attributeName} does not match its child elements.`)
}
const nativeExpressionMap = (properties: Record<string, unknown>[]): Map<string, string> => {
const property = properties.find((candidate) => attribute(candidate, 'name') === 'ExpressionEngine')
if (!property) return new Map()
if (attribute(property, 'type') !== 'App::PropertyExpressionEngine') throw new Error('FCStd ExpressionEngine property has an invalid type.')
const engine = property.ExpressionEngine
if (!engine || typeof engine !== 'object') throw new Error('FCStd ExpressionEngine property is missing its engine payload.')
const records = asArray((engine as Record<string, unknown>).Expression as Record<string, unknown> | Record<string, unknown>[] | undefined)
const countText = attribute(engine, 'count')
if (!/^\d+$/.test(countText) || Number(countText) !== records.length) throw new Error('FCStd ExpressionEngine count does not match its expressions.')
const result = new Map<string, string>()
for (const record of records) {
const path = attribute(record, 'path')
if (!path) throw new Error('FCStd ExpressionEngine contains an empty expression path.')
if (result.has(path)) throw new Error(`FCStd ExpressionEngine contains a duplicate path: ${path}`)
result.set(path, attribute(record, 'expression'))
}
return result
}
const parseDocumentXml = (bytes: Uint8Array, limits: FcstdArchiveLimits) => {
const { root } = parseXmlRoot(bytes, 'Document.xml', 'Document', limits)
const legacyStringHasherNodes = root.StringHasher === undefined ? [] : asArray(root.StringHasher as Record<string, unknown> | Record<string, unknown>[])
if (legacyStringHasherNodes.length > 1) throw new Error('FCStd Document contains duplicate StringHasher declarations.')
if (legacyStringHasherNodes[0]) {
for (const name of ['saveall', 'threshold', 'count']) {
const value = attribute(legacyStringHasherNodes[0], name)
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error(`FCStd StringHasher ${name} is invalid.`)
}
}
const stringHasherNodes = root.StringHasher2 === undefined ? [] : asArray(root.StringHasher2 as Record<string, unknown> | Record<string, unknown>[])
if (stringHasherNodes.length > 1) throw new Error('FCStd Document contains duplicate StringHasher2 resources.')
const stringHasherPath = stringHasherNodes[0] ? attribute(stringHasherNodes[0], 'file') : ''
const embeddedStringHasher = Boolean(stringHasherNodes[0] && !stringHasherPath.trim())
if (embeddedStringHasher) {
const countText = attribute(stringHasherNodes[0], 'count')
if (!/^\d+$/.test(countText) || !Number.isSafeInteger(Number(countText))) throw new Error('FCStd embedded StringHasher2 count is invalid.')
}
if (stringHasherPath) validateEntryPath(stringHasherPath)
const objectDeclarations = asArray((((root.Objects as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const objectData = asArray((((root.ObjectData as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
assertContainerCount(root.Objects, 'Count', objectDeclarations.length, 'Objects')
assertContainerCount(root.ObjectData, 'Count', objectData.length, 'ObjectData')
assertUniqueNodeNames(objectDeclarations, 'object declaration')
assertUniqueNodeNames(objectData, 'object data')
const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data]))
const documentProperties = asArray((((root.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label')
const nativeObjectTags = new Set<number>()
const objects = objectDeclarations.map((declaration): FcstdObjectSummary => {
const name = attribute(declaration, 'name') || '<unnamed>'
const typeId = attribute(declaration, 'type') || 'App::DocumentObject'
const objectTagText = attribute(declaration, 'id')
let nativeObjectTag: number | undefined
if (objectTagText) {
if (!/^\d+$/.test(objectTagText) || !Number.isSafeInteger(Number(objectTagText)) || Number(objectTagText) <= 0) throw new Error(`FCStd object ${name} has an invalid native id.`)
nativeObjectTag = Number(objectTagText)
if (nativeObjectTags.has(nativeObjectTag)) throw new Error(`FCStd object native id ${nativeObjectTag} is duplicated.`)
nativeObjectTags.add(nativeObjectTag)
}
const data = dataByName.get(name)
const propertiesContainer = data?.Properties as Record<string, unknown> | undefined
const properties = asArray(((propertiesContainer?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
const transientProperties = asArray(((propertiesContainer?._Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
assertContainerCount(propertiesContainer, 'Count', properties.length, `${name} Properties`)
assertContainerCount(propertiesContainer, 'TransientCount', transientProperties.length, `${name} Properties`)
const expressions = nativeExpressionMap(properties)
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
const extensions = [...new Set([...collectXmlNodes(declaration, 'Extension'), ...collectXmlNodes(data, 'Extension')].map((extension) => attribute(extension, 'type') || attribute(extension, 'name')).filter(Boolean))]
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
const summaries = propertySummaries(properties).map((property) => expressions.has(property.name) ? { ...property, expression: expressions.get(property.name) } : property)
const sketch = typeId === 'Sketcher::SketchObject' ? sketchFromPropertySummaries(name, summaries) : undefined
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, ...(nativeObjectTag === undefined ? {} : { nativeObjectTag }), propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) }
})
return {
schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown',
label: labelProperty ? propertyValue(labelProperty) || 'Unnamed FreeCAD document' : 'Unnamed FreeCAD document',
objects,
...(stringHasherPath ? { stringHasherPath } : {}),
...(embeddedStringHasher ? { embeddedStringHasher: true as const } : {}),
...(legacyStringHasherNodes.length > 0 ? { declaredStringHasher: true as const } : {}),
}
}
const parsePathResource = (bytes: Uint8Array, path: string): PathCommandValue[] => {
let text: string
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch (error) { throw new Error(`FCStd Path resource is not valid UTF-8: ${path}: ${error instanceof Error ? error.message : String(error)}`) }
const commands: PathCommandValue[] = []
for (const [lineIndex, rawLine] of text.split(/\r?\n/).entries()) {
const withoutComments = rawLine.replace(/\([^)]*\)/g, '').replace(/;.*$/, '').trim()
if (!withoutComments || withoutComments === '%') continue
const nameMatch = /^([A-Za-z][A-Za-z0-9]*)\b(.*)$/.exec(withoutComments)
if (!nameMatch) throw new Error(`FCStd Path resource ${path} line ${lineIndex + 1} has no command name.`)
const name = pathCommandName(nameMatch[1], `${path}:${lineIndex + 1}`)
const remainder = nameMatch[2].trim()
const parameters: Record<string, number> = {}
let consumed = 0
const matcher = /([A-Za-z])\s*([+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[Ee][+-]?\d+)?)/g
let match: RegExpExecArray | null
while ((match = matcher.exec(remainder)) !== null) {
if (remainder.slice(consumed, match.index).trim()) throw new Error(`FCStd Path resource ${path} line ${lineIndex + 1} contains an invalid parameter token.`)
const key = match[1].toUpperCase()
const value = Number(match[2])
if (!Number.isFinite(value)) throw new Error(`FCStd Path resource ${path} line ${lineIndex + 1} contains a non-finite parameter.`)
if (Object.prototype.hasOwnProperty.call(parameters, key)) throw new Error(`FCStd Path resource ${path} line ${lineIndex + 1} repeats parameter ${key}.`)
parameters[key] = Object.is(value, -0) ? 0 : value
consumed = matcher.lastIndex
}
if (remainder.slice(consumed).trim()) throw new Error(`FCStd Path resource ${path} line ${lineIndex + 1} contains trailing text.`)
commands.push({ name, parameters })
}
return pathCommands(commands, path)
}
const hydratePathProperties = (document: ReturnType<typeof parseDocumentXml>, files: Record<string, Uint8Array>) => {
for (const object of document.objects) for (const property of object.properties) {
if (property.typeId !== 'Path::PropertyPath') continue
let metadata: Record<string, unknown>
try { metadata = JSON.parse(property.value) as Record<string, unknown> } catch { throw new Error(`FCStd Path property ${object.name}.${property.name} metadata is invalid JSON.`) }
const resourcePath = typeof metadata.resourcePath === 'string' ? metadata.resourcePath : ''
if (!resourcePath || !files[resourcePath]) throw new Error(`FCStd Path property ${object.name}.${property.name} references missing resource: ${resourcePath || '<empty>'}`)
const commands = parsePathResource(files[resourcePath], resourcePath)
property.value = JSON.stringify({ ...metadata, commands })
}
}
const countXmlNodes = (value: unknown, name: string): number => {
if (!value || typeof value !== 'object') return 0
const record = value as Record<string, unknown>
return Object.entries(record).reduce((count, [key, child]) => count + (key === name ? asArray(child).length : 0) + countXmlNodes(child, name), 0)
}
const collectXmlNodes = (value: unknown, name: string): Record<string, unknown>[] => {
if (!value || typeof value !== 'object') return []
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => [
...(key === name ? asArray(child).filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === 'object')) : []),
...collectXmlNodes(child, name),
])
}
const binaryMaterialList = (bytes: Uint8Array, path: string, version: string): FcstdMaterialAppearance[] => {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let offset = 0
const requireBytes = (count: number) => {
if (!Number.isSafeInteger(count) || count < 0 || offset + count > bytes.byteLength) throw new Error(`FCStd material resource is truncated: ${path}`)
}
const uint32 = () => { requireBytes(4); const value = view.getUint32(offset, true); offset += 4; return value }
const float32 = () => { requireBytes(4); const value = view.getFloat32(offset, true); offset += 4; if (!Number.isFinite(value)) throw new Error(`FCStd material resource contains a non-finite value: ${path}`); return value }
const count = uint32()
if (count > Math.floor((bytes.byteLength - 4) / 24)) throw new Error(`FCStd material resource count exceeds its payload: ${path}`)
const materials = Array.from({ length: count }, (): FcstdMaterialAppearance => ({ ambientColor: packedToColor(uint32()), diffuseColor: packedToColor(uint32()), specularColor: packedToColor(uint32()), emissiveColor: packedToColor(uint32()), shininess: float32(), transparency: float32(), image: '', imagePath: '', uuid: '' }))
if (version === '3') {
const decoder = new TextDecoder('utf-8', { fatal: true })
const readString = () => { const length = uint32(); requireBytes(length); const value = decoder.decode(bytes.subarray(offset, offset + length)); offset += length; return value }
for (const material of materials) {
material.image = readString()
material.imagePath = readString()
material.uuid = readString()
}
}
if (offset !== bytes.byteLength) throw new Error(`FCStd material resource has unexpected trailing bytes: ${path}`)
return materials
}
const parseElementMapResource = (bytes: Uint8Array, path: string): FcstdElementMapResource => {
const status: FcstdElementMapResource['status'] = bytes.byteLength > 0 ? 'available' : 'empty'
if (bytes.byteLength === 0) return { path, format: 'element-map-v1', byteLength: 0, contentHash: hashBytes(bytes), status, sections: [] }
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error) {
throw new Error(`FCStd ElementMap2 resource is not valid UTF-8: ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
let document: ElementMap2Document
try {
document = migrateElementMap2Schema(parseElementMap2(text))
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
if (detail.includes('expected BeginElementMap v1')) throw new Error(`FCStd ElementMap2 resource has unsupported header: ${path}`)
throw new Error(`FCStd ElementMap2 resource is invalid: ${path}: ${detail}`)
}
const sections: Array<{ name: 'Edge' | 'Face' | 'Vertex'; nameCount: number }> = []
for (const map of document.maps) {
for (const section of map.sections) {
if (section.name === 'Edge' || section.name === 'Face' || section.name === 'Vertex') sections.push({ name: section.name, nameCount: section.names.length })
}
}
return {
path,
format: 'element-map-v1',
byteLength: bytes.byteLength,
contentHash: hashBytes(bytes),
status,
postfixCount: document.postfixes.length,
mapCount: document.maps.length,
sections,
document,
}
}
/** Serialize a parsed native ElementMap resource using FreeCAD's v1 grammar. */
export const serializeElementMap2Resource = (document: AnyElementMap2Document): Uint8Array => new TextEncoder().encode(writeElementMap2(document))
const parseStringHasherResource = (bytes: Uint8Array, path: string): FcstdStringHasherResource => {
if (bytes.byteLength === 0) return {
path,
format: 'string-hasher-v1',
byteLength: 0,
contentHash: hashBytes(bytes),
status: 'empty',
entryCount: 0,
validation: { valid: true, entryCount: 0, issues: [] },
}
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error) {
throw new Error(`FCStd StringHasher resource is not valid UTF-8: ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
let document: StringHasherTable
try {
document = migrateStringHasherSchema(parseStringHasherTable(text))
} catch (error) {
throw new Error(`FCStd StringHasher resource is invalid: ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
const validation = validateStringHasherTable(document)
if (!validation.valid) throw new Error(`FCStd StringHasher resource is invalid: ${path}: ${validation.issues[0].path}: ${validation.issues[0].message}`)
return {
path,
format: 'string-hasher-v1',
byteLength: bytes.byteLength,
contentHash: hashBytes(bytes),
status: 'available',
entryCount: document.entries.length,
validation,
document,
}
}
/** Serialize native StringHasher naming evidence using FreeCAD 1.1.1's v1 grammar. */
export const serializeStringHasherTableResource = (table: AnyStringHasherTable): Uint8Array => {
const validation = validateStringHasherTable(table)
if (!validation.valid) throw new Error(`StringHasher validation error: ${validation.issues[0].path}: ${validation.issues[0].message}`)
return new TextEncoder().encode(writeStringHasherTable(table))
}
const optionalViewScalar = (properties: Record<string, unknown>[], name: string) => {
const property = properties.find((candidate) => attribute(candidate, 'name') === name)
return property ? propertyValue(property) : undefined
}
const optionalBoolean = (value: string | undefined, name: string) => {
if (value === undefined) return undefined
if (value === 'true') return true
if (value === 'false') return false
throw new Error(`FCStd GuiDocument ${name} is not a boolean.`)
}
const optionalNumber = (value: string | undefined, name: string, integer = false) => {
if (value === undefined) return undefined
const number = Number(value)
if (!Number.isFinite(number) || (integer && !Number.isSafeInteger(number))) throw new Error(`FCStd GuiDocument ${name} is not a valid number.`)
return number
}
const optionalPackedColor = (value: string | undefined, name: string) => {
if (value === undefined) return undefined
if (!/^\d+$/.test(value)) throw new Error(`FCStd GuiDocument ${name} is not a packed color.`)
const number = Number(value)
if (!Number.isSafeInteger(number) || number < 0 || number > 0xffffffff) throw new Error(`FCStd GuiDocument ${name} is outside the packed color range.`)
return packedToColor(number)
}
const parseGuiDocument = (files: Record<string, Uint8Array>, entries: FcstdEntryMetadata[], limits: FcstdArchiveLimits): FcstdGuiInspection => {
const entry = entries.find((candidate) => candidate.path.toLowerCase() === 'guidocument.xml')
if (!entry) return { present: false, rootName: 'none', schemaVersion: 'unknown', viewCount: 0, contentHash: '', views: [], viewProviders: [] }
const bytes = files[entry.path]
const { xml, root, rootName } = parseXmlRoot(bytes, 'GuiDocument.xml', ['Document', 'GuiDocument'], limits)
const views = collectXmlNodes(root, 'View').map((view) => ({ name: attribute(view, 'name'), type: attribute(view, 'type'), visibility: attribute(view, 'visibility') }))
const providers = collectXmlNodes(root, 'ViewProvider')
assertUniqueNodeNames(providers, 'GuiDocument ViewProvider')
const providerContainer = root.ViewProviderData
if (providerContainer && typeof providerContainer === 'object') {
const declaredCount = attribute(providerContainer, 'Count')
if (!/^\d+$/.test(declaredCount) || Number(declaredCount) !== providers.length) throw new Error('FCStd GuiDocument ViewProviderData Count does not match its providers.')
}
const viewProviders = providers.map((provider): FcstdViewProviderSummary => {
const objectName = attribute(provider, 'name')
const propertiesContainer = provider.Properties as Record<string, unknown> | undefined
const properties = asArray(((propertiesContainer?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
if (propertiesContainer) {
const declaredCount = attribute(propertiesContainer, 'Count')
if (declaredCount && (!/^\d+$/.test(declaredCount) || Number(declaredCount) !== properties.length)) throw new Error(`FCStd GuiDocument ${objectName} Properties Count does not match its properties.`)
}
const shapeAppearanceProperty = properties.find((property) => attribute(property, 'name') === 'ShapeAppearance')
const materialList = shapeAppearanceProperty?.MaterialList
const shapeAppearanceResource = materialList && typeof materialList === 'object' ? attribute(materialList, 'file') : ''
let shapeAppearance: FcstdMaterialAppearance[] = []
if (shapeAppearanceResource) {
validateEntryPath(shapeAppearanceResource)
const materialBytes = files[shapeAppearanceResource]
if (!materialBytes) throw new Error(`FCStd GuiDocument references missing ShapeAppearance resource: ${shapeAppearanceResource}`)
shapeAppearance = binaryMaterialList(materialBytes, shapeAppearanceResource, attribute(materialList, 'version'))
}
const expandedText = attribute(provider, 'expanded')
const treeRankText = attribute(provider, 'treeRank')
const transparency = optionalNumber(optionalViewScalar(properties, 'Transparency'), `${objectName}.Transparency`, true)
if (transparency !== undefined && (transparency < 0 || transparency > 100)) throw new Error(`FCStd GuiDocument ${objectName}.Transparency is outside 0..100.`)
return {
objectName,
expanded: expandedText === '' ? null : expandedText === '1' ? true : expandedText === '0' ? false : (() => { throw new Error(`FCStd GuiDocument ${objectName}.expanded is invalid.`) })(),
treeRank: treeRankText === '' ? null : optionalNumber(treeRankText, `${objectName}.treeRank`, true) ?? null,
propertyCount: properties.length,
...(optionalBoolean(optionalViewScalar(properties, 'Visibility'), `${objectName}.Visibility`) === undefined ? {} : { visibility: optionalBoolean(optionalViewScalar(properties, 'Visibility'), `${objectName}.Visibility`) }),
...(transparency === undefined ? {} : { transparency }),
...(optionalPackedColor(optionalViewScalar(properties, 'LineColor'), `${objectName}.LineColor`) === undefined ? {} : { lineColor: optionalPackedColor(optionalViewScalar(properties, 'LineColor'), `${objectName}.LineColor`) }),
...(optionalPackedColor(optionalViewScalar(properties, 'PointColor'), `${objectName}.PointColor`) === undefined ? {} : { pointColor: optionalPackedColor(optionalViewScalar(properties, 'PointColor'), `${objectName}.PointColor`) }),
...(shapeAppearance[0]?.diffuseColor ? { shapeColor: shapeAppearance[0].diffuseColor } : {}),
...(optionalNumber(optionalViewScalar(properties, 'Deviation'), `${objectName}.Deviation`) === undefined ? {} : { deviation: optionalNumber(optionalViewScalar(properties, 'Deviation'), `${objectName}.Deviation`) }),
...(optionalNumber(optionalViewScalar(properties, 'AngularDeflection'), `${objectName}.AngularDeflection`) === undefined ? {} : { angularDeflection: optionalNumber(optionalViewScalar(properties, 'AngularDeflection'), `${objectName}.AngularDeflection`) }),
...(optionalNumber(optionalViewScalar(properties, 'DisplayMode'), `${objectName}.DisplayMode`, true) === undefined ? {} : { displayMode: optionalNumber(optionalViewScalar(properties, 'DisplayMode'), `${objectName}.DisplayMode`, true) }),
...(shapeAppearanceResource ? { shapeAppearanceResource } : {}),
shapeAppearance,
}
})
return { present: true, rootName: rootName as 'Document' | 'GuiDocument', schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown', viewCount: views.length + viewProviders.length, contentHash: hashString(xml), views, viewProviders }
}
const createProxyProperty = (name: string, label: string, value: string): ObjectPropertySnapshot => ({ name, label, group: 'FCStd import', scope: 'data', type: 'App::PropertyString', value, readOnly: true })
const createSketchAttachmentProxyProperties = (object: FcstdObjectSummary): ObjectPropertySnapshot[] => {
if (object.typeId !== 'Sketcher::SketchObject') return []
const summaries = object.properties
const nativeSupport = summaries.find((property) => property.name === 'AttachmentSupport' && property.typeId === 'App::PropertyLinkSubList')
const supportMetadata = summaries.find((property) => property.name === 'WebSupportValue' && property.typeId === 'App::PropertyString')
const nativeEntries = nativeSupport?.linkSubs ?? []
let supportValue: PropertyValue | undefined
if (supportMetadata) {
try {
const decoded = JSON.parse(decodeURIComponent(supportMetadata.value)) as unknown
if (decoded !== null && typeof decoded !== 'string') validateAttachmentSupport(decoded)
if (typeof decoded === 'string' && !decoded.trim()) throw new Error('empty Support object id')
const candidate = decoded === null ? null : typeof decoded === 'string' ? { objectId: decoded, subElement: null } : decoded
const matchesNative = !nativeSupport || candidate === null
? nativeEntries.length === 0
: nativeEntries.length === 1 && nativeEntries[0].objectId === candidate.objectId && (nativeEntries[0].subElement || '') === (typeof candidate === 'string' ? '' : candidate.subElement && typeof candidate.subElement === 'object' ? candidate.subElement.persistentId : candidate.subElement ?? '')
if (matchesNative) supportValue = decoded as PropertyValue
} catch {
supportValue = undefined
}
}
if (supportValue === undefined && nativeSupport && nativeEntries.length <= 1) {
if (nativeEntries.length === 0) supportValue = null
else supportValue = { objectId: nativeEntries[0].objectId, ...(nativeEntries[0].subElement ? { subElement: nativeEntries[0].subElement } : {}) }
}
const result: ObjectPropertySnapshot[] = []
if (supportValue !== undefined) result.push({ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: supportValue, readOnly: true, recompute: true })
const mapMode = summaries.find((property) => property.name === 'MapMode')
if (mapMode) {
const decoded = decodeFcstdPropertyValue(mapMode)
if (decoded.decoded && typeof decoded.value === 'string' && (ATTACHMENT_MAP_MODES as readonly string[]).includes(decoded.value)) result.push({ name: 'MapMode', label: 'Map mode', group: 'Attachment', scope: 'data', type: 'App::PropertyEnumeration', value: decoded.value, options: [...ATTACHMENT_MAP_MODES], readOnly: true, recompute: true })
}
const offset = summaries.find((property) => property.name === 'AttachmentOffset')
if (offset) {
const decoded = decodeFcstdPropertyValue(offset)
if (decoded.decoded && decoded.value && typeof decoded.value === 'object' && !Array.isArray(decoded.value)) {
try { validateAttachmentOffset(decoded.value); result.push({ name: 'AttachmentOffset', label: 'Attachment offset', group: 'Attachment', scope: 'data', type: 'App::PropertyPlacement', value: decoded.value as PlacementValue, readOnly: true, recompute: true }) } catch { /* keep malformed native metadata read-only but out of the editable surface */ }
}
}
return result
}
const createProxyDocument = (inspection: Omit<FcstdInspection, 'proxyDocument'>): DocumentSnapshot => {
const documentId = `fcstd-${hashString(`${inspection.label}|${inspection.schemaVersion}|${inspection.objects.map((object) => object.name).join('|')}`)}`
const viewProperties = new Map(inspection.guiDocument.viewProviders.map((provider) => [provider.objectName, [
...(provider.visibility === undefined ? [] : [createProxyProperty('Visibility', 'Visibility', String(provider.visibility))]),
...(provider.transparency === undefined ? [] : [createProxyProperty('Transparency', 'Transparency', String(provider.transparency))]),
...(provider.lineColor === undefined ? [] : [createProxyProperty('LineColor', 'Line color', provider.lineColor)]),
...(provider.pointColor === undefined ? [] : [createProxyProperty('PointColor', 'Point color', provider.pointColor)]),
...(provider.shapeColor === undefined ? [] : [createProxyProperty('ShapeColor', 'Shape color', provider.shapeColor)]),
...(provider.deviation === undefined ? [] : [createProxyProperty('Deviation', 'Deviation', String(provider.deviation))]),
...(provider.angularDeflection === undefined ? [] : [createProxyProperty('AngularDeflection', 'Angular deflection', String(provider.angularDeflection))]),
...(provider.displayMode === undefined ? [] : [createProxyProperty('DisplayMode', 'Display mode', String(provider.displayMode))]),
]]))
const tree: ModelTreeItem[] = inspection.objects.map((object) => ({ id: object.name, label: object.label, type: object.typeId.includes('Body') ? 'body' : object.typeId.includes('Sketch') ? 'sketch' : 'feature', state: 'readonly', detail: `${object.typeId} · ${object.support}` }))
const objects: DocumentObjectSnapshot[] = inspection.objects.map((object) => {
const pathSummary = object.typeId === 'Path::Feature' ? object.properties.find((property) => property.name === 'Path' && property.typeId === 'Path::PropertyPath') : undefined
const pathDecoded = pathSummary ? decodeFcstdPropertyValue(pathSummary) : undefined
return {
id: object.name,
typeId: object.typeId,
...(object.nativeObjectTag === undefined ? {} : { nativeObjectTag: object.nativeObjectTag }),
properties: [
createProxyProperty('Label', 'Label', object.label),
createProxyProperty('TypeId', 'Type', object.typeId),
createProxyProperty('ImportSupport', 'Import support', object.support),
createProxyProperty('PropertyCount', 'Property count', String(object.propertyCount)),
...(pathDecoded?.decoded ? [{ name: 'Path', label: 'Path', group: 'Path', scope: 'data' as const, type: 'Path::PropertyPath' as const, value: pathDecoded.value as PathPropertyValue, readOnly: true }] : []),
...createSketchAttachmentProxyProperties(object),
...object.properties.filter((property) => property.expression).map((property) => createProxyProperty(`Expression:${property.name}`, `Expression ${property.name}`, property.expression ?? '')),
...(viewProperties.get(object.name) ?? []),
],
...(object.sketch ? { sketch: object.sketch } : {}),
}
})
return { id: documentId, label: inspection.label, version: 1, dirty: false, readOnly: true, units: 'mm', tree, objects, dependencies: [], recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(objects.map((object) => [object.id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] } }
}
export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<FcstdArchiveLimits> = {}): FcstdInspection => {
const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides }
for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`)
const entries = inspectZipDirectory(bytes, limits)
const documentEntry = entries.find((entry) => entry.path.toLowerCase() === 'document.xml')
if (!documentEntry) throw new Error('FCStd archive does not contain Document.xml.')
const files = unzipSync(bytes)
for (const entry of entries) {
const content = files[entry.path]
if (!content || content.byteLength !== entry.uncompressedBytes) throw new Error(`FCStd entry ${entry.path} did not decompress to its declared size.`)
}
const document = parseDocumentXml(files[documentEntry.path], limits)
hydratePathProperties(document, files)
const guiDocument = parseGuiDocument(files, entries, limits)
const shapeResources = entries.filter((entry) => entry.role === 'shape').map((entry): FcstdShapeResource => {
const payload = files[entry.path]
return { path: entry.path, format: 'brep', mediaType: 'application/x-freecad-brep', byteLength: payload.byteLength, contentHash: hashBytes(payload), status: payload.byteLength > 0 ? 'available' : 'empty' }
})
const elementMapResources = entries.filter((entry) => entry.role === 'topology-map').map((entry) => parseElementMapResource(files[entry.path], entry.path))
const stringHasherEntry = document.stringHasherPath ? entries.find((entry) => entry.path === document.stringHasherPath) : undefined
if (document.stringHasherPath && !stringHasherEntry) throw new Error(`FCStd Document references missing StringHasher2 resource: ${document.stringHasherPath}`)
const stringHasherResource = stringHasherEntry ? parseStringHasherResource(files[stringHasherEntry.path], stringHasherEntry.path) : undefined
if (stringHasherResource?.document) for (const resource of elementMapResources) {
if (!resource.document) continue
const issues = validateElementMap2StringHasherEvidence(resource.document, stringHasherResource.document)
if (issues.length > 0) throw new Error(`FCStd ElementMap2 resource has unresolved StringHasher evidence: ${resource.path}: ${issues[0].path}: ${issues[0].message}`)
}
const shapeResourcePaths = new Set(shapeResources.map((resource) => resource.path))
const elementMapResourcePaths = new Set(elementMapResources.map((resource) => resource.path))
for (const object of document.objects) for (const property of object.properties) {
if (property.shapeResource && !shapeResourcePaths.has(property.shapeResource.path)) throw new Error(`FCStd Shape property ${object.name}.${property.name} references missing resource: ${property.shapeResource.path}`)
if (property.shapeResource) validateNativeShapeResourceName(object.name, property.shapeResource.path)
if (property.shapeResource?.elementMapResource && !elementMapResourcePaths.has(property.shapeResource.elementMapResource)) throw new Error(`FCStd Shape property ${object.name}.${property.name} references missing ElementMap2 resource: ${property.shapeResource.elementMapResource}`)
if (property.shapeResource?.hasherIndex !== undefined && !stringHasherResource && !document.embeddedStringHasher && !document.declaredStringHasher) throw new Error(`FCStd Shape property ${object.name}.${property.name} HasherIndex requires StringHasher.`)
}
const warnings: string[] = []
const scriptEntries = entries.filter((entry) => entry.role === 'script')
if (scriptEntries.length > 0) warnings.push(`${scriptEntries.length} script or macro resource(s) were isolated and will not execute.`)
const blockedObjects = document.objects.filter((object) => object.support === 'blocked')
if (blockedObjects.length > 0) warnings.push(`${blockedObjects.length} Python-backed object(s) require a non-executing proxy.`)
const proxyObjects = document.objects.filter((object) => object.support === 'proxy')
if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`)
const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible'
const inspection: Omit<FcstdInspection, 'proxyDocument'> = {
format: 'FCStd',
schemaVersion: document.schemaVersion,
label: document.label,
entries,
objects: document.objects,
guiDocument,
shapeResources,
elementMapResources,
...(stringHasherResource ? { stringHasherResource } : {}),
compatibility: {
level,
readOnly: true,
codeExecutionBlocked: true,
recognizedObjects: document.objects.filter((object) => object.support === 'recognized').length,
proxyObjects: proxyObjects.length,
blockedObjects: blockedObjects.length,
unknownTypeIds: [...new Set(proxyObjects.map((object) => object.typeId))].sort(),
warnings,
},
}
return { ...inspection, proxyDocument: createProxyDocument(inspection) }
}
export const extractFcstdShapeResources = (bytes: Uint8Array, limitOverrides: Partial<FcstdArchiveLimits> = {}): FcstdShapeResourcePayload[] => {
const inspection = inspectFcstdArchive(bytes, limitOverrides)
const files = unzipSync(bytes)
return inspection.shapeResources.map((resource) => ({ ...resource, bytes: new Uint8Array(files[resource.path]) }))
}
const throwIfShapeInstantiationAborted = (signal?: AbortSignal) => {
if (signal?.aborted) throw new DOMException('FCStd Shape instantiation was cancelled.', 'AbortError')
}
export const instantiateFcstdShapeResource = async <T>(
bytes: Uint8Array,
resourcePath: string,
importShape: (input: { format: 'brep'; text: string }) => Promise<T>,
options: FcstdShapeInstantiationOptions<T> = {},
): Promise<FcstdInstantiatedShape<T>> => {
throwIfShapeInstantiationAborted(options.signal)
if (typeof resourcePath !== 'string' || !resourcePath.trim()) throw new TypeError('FCStd Shape resource path must be non-empty.')
if (typeof importShape !== 'function') throw new TypeError('FCStd Shape importer must be a function.')
const inspection = inspectFcstdArchive(bytes, options.limits)
const resource = inspection.shapeResources.find((candidate) => candidate.path === resourcePath)
if (!resource) throw new Error(`FCStd Shape resource does not exist: ${resourcePath}`)
const references = inspection.objects.flatMap((object) => object.properties.flatMap((property): FcstdShapeResourceReference[] => property.shapeResource?.path === resourcePath
? [{ objectName: object.name, propertyName: property.name, ...(property.shapeResource.hasherIndex === undefined ? {} : { hasherIndex: property.shapeResource.hasherIndex }), ...(property.shapeResource.elementMap === undefined ? {} : { elementMap: property.shapeResource.elementMap }), ...(property.shapeResource.elementMapResource === undefined ? {} : { elementMapResource: property.shapeResource.elementMapResource }) }]
: []))
if (references.length === 0) throw new Error(`FCStd Shape resource is not referenced by Part::PropertyPartShape: ${resourcePath}`)
if (resource.status === 'empty') throw new Error(`FCStd Shape resource is empty: ${resourcePath}`)
const payload = unzipSync(bytes)[resource.path]
if (!payload || payload.byteLength !== resource.byteLength) throw new Error(`FCStd Shape resource could not be extracted: ${resourcePath}`)
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(payload)
} catch (error) {
throw new TypeError(`FCStd Shape resource is not valid UTF-8 BRep text: ${resourcePath}`, { cause: error })
}
if (!text.trim()) throw new Error(`FCStd Shape resource contains no BRep text: ${resourcePath}`)
throwIfShapeInstantiationAborted(options.signal)
const shape = await importShape({ format: 'brep', text })
if (options.signal?.aborted) {
await options.release?.(shape)
throwIfShapeInstantiationAborted(options.signal)
}
return { resource: { ...resource }, references, shape }
}
export const storeFcstdShapeResources = async (
bytes: Uint8Array,
put: (payload: Uint8Array, mediaType: string) => Promise<{ hash: string }>,
limitOverrides: Partial<FcstdArchiveLimits> = {},
): Promise<FcstdStoredShapeResource[]> => {
const resources = extractFcstdShapeResources(bytes, limitOverrides).filter((resource) => resource.status === 'available')
return Promise.all(resources.map(async (resource) => {
const stored = await put(resource.bytes, resource.mediaType)
if (!stored || typeof stored.hash !== 'string' || !stored.hash.trim()) throw new Error(`FCStd shape resource ${resource.path} returned an invalid resource hash.`)
return { path: resource.path, format: resource.format, mediaType: resource.mediaType, byteLength: resource.byteLength, contentHash: resource.contentHash, status: resource.status, hash: stored.hash }
}))
}