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 = { resource: FcstdShapeResource references: FcstdShapeResourceReference[] shape: T } export type FcstdShapeInstantiationOptions = { limits?: Partial signal?: AbortSignal release?: (shape: T) => Promise | 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 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, '&').replace(/"/g, '"').replace(//g, '>') 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 = `` if (property.type === 'App::PropertyColor') return `${header}` if (property.type === 'App::PropertyBool') { if (typeof property.value !== 'boolean') throw new TypeError(`FCStd GUI Bool property ${property.name} requires a boolean.`) return `${header}` } 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}` } if (property.type === 'App::PropertyFloat' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance') return `${header}` 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}` } if (property.type === 'App::PropertyString') return `${header}` return '' } const nativeDataPropertyNames = new Map>([ ['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 `` if (geometry.type === 'line') return `` if (geometry.type === 'circle') return `` if (geometry.type === 'arc') return `` if (geometry.type === 'ellipse') return `` if (geometry.type === 'arcEllipse') return `` if (geometry.type === 'arcHyperbola') return `` if (geometry.type === 'arcParabola') return `` 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) => ``).join('') const knotXml = uniqueKnots.map((knot) => ``).join('') return `${poles}${knotXml}` } 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 explicitAlignmentId?: string syntheticAlignmentId?: string weightConstraintId?: string } const bsplineExpandedKnots = (geometry: Extract) => { 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, 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() const addHelper = (helper: Omit) => { 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 `${sketchGeometryPayloadXml(geometry)}` }).join('') const helpers = internalHelpers.map((helper) => `${sketchGeometryPayloadXml(helper.geometry)}`).join('') return `${geometries}${helpers}` } const sketchAxisGeometryXml = (nativeId: -1 | -2) => { const payload = nativeId === -1 ? '' : '' return `${payload}` } const nativeExternalSubElement = (source: TopoRefValue, sketchId: string, objectIds: ReadonlySet) => { 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 = { 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) => { type ExternalGroup = { source: TopoRefValue; subElement: string; records: number[]; mode: SketchExternalMode; flags: number } const groups: ExternalGroup[] = [] const groupIndexByReference = new Map() 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 = `${sketchGeometryPayloadXml(external.projection)}` 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 }) => ``).join('') const types = groups.map(({ mode }) => ``).join('') return [ `${axes}${externalRecords.map((record) => record.geometry).join('')}`, `${links}`, `${types}`, 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) => { 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) => { 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 = `` } 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 ? `` : '' if (support.objectId) result.push(`${links}`) 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, geometryById: Map, 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, geometryById: Map, 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(sketch.geometry.map((geometry, index) => [geometry.id, index])) const geometryById = new Map(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 `` } 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 `` }).join('') return `${syntheticConstraints.join('')}${constraints}` } const sketchNativePropertiesXml = (object: DocumentObjectSnapshot, objectIds: ReadonlySet) => { 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 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 = {} for (const [rawKey, rawValue] of Object.entries(record.parameters as Record)) { 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 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)[axis] !== 'number' || !Number.isFinite((center as Record)[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 `
` } 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 ? '' : `${property.options.map((option) => ``).join('')}` return `${customList}` } 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 `` } 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 `` } 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 `` } 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) => ``).join('') return `${links}` } 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 `` }).join('') return `${links}` } 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 ? '' : `${mapEntries.map((entry) => ``).join('')}` 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 ? '' : `` return `${elementMapXml}${elementMap2}` } 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) => ``).join('') return `${subs}` } 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) => ``).join('') return `${values}` } 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) => ``).join('') return `${values}` } 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) => ``).join('') return `${values}` } if (property.type === 'App::PropertyString') { if (typeof property.value !== 'string') throw new TypeError(`FCStd String property ${property.name} requires a string.`) return `` } if (property.type === 'App::PropertyBool') { if (typeof property.value !== 'boolean') throw new TypeError(`FCStd Bool property ${property.name} requires a boolean.`) return `` } 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 `` } 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 `` } 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)[property.type] ?? 'String' return `<${element} value="${xmlEscape(value)}"/>` } 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) => ``).join('') return `${entries}` } 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() 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 ? `` : `${targets.map((target) => ``).join('')}` return `${dependencyXml}` }).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() 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 `${shapeMetadata}${properties.map((property) => propertyXml(property, isNativeDataProperty(object, property), object.id, object.topology?.elementMap)).join('')}${sketchProperties.join('')}${expressionEngine}` }).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 ? '' : '' : '' const useHasherXml = hasShapeResources ? '' : '' const documentXml = `${stringHasherXml}${useHasherXml}${objectDeclarations}${objectData}` 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 `` }).join('') const generatedGuiEntries: Record = {} 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(``) } return properties.length === 0 ? '' : `${properties.join('')}` }).filter(Boolean) const nativeGuiXml = `${nativeViewProviders.join('')}` const guiXml = options.guiDocumentXml ?? (options.guiViews ? `${guiViewsXml ?? ''}` : 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 = { ...(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 = { 'Document.xml': new TextEncoder().encode(documentXml) } const emittedOpaque = new Set() 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 = {}): 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 = {} 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 || ''}`) 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() 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 = (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 const value = record[`@_${name}`] ?? record[name] return value === undefined || value === null ? '' : String(value) } const propertyValue = (property: Record): 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 => { 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): { 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 | Record[]) 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 || ''}`) if (mapResource) validateEntryPath(mapResource) const mapEntriesNode = property?.ElementMap === undefined ? [] : asArray(property.ElementMap as Record | Record[]) 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).Element as Record | Record[] | 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 | 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).Float as Record | Record[] | 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).I as Record | Record[] | 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 as Record | Record[] | 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).Geometry as Record | Record[] | 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 : 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 : {} const poles = asArray(curve.Pole as Record | Record[] | undefined) const knots = asArray(curve.Knot as Record | Record[] | 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).GeoExtension as Record | Record[] | 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).Constrain as Record | Record[] | 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).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).Link as Record | Record[] | 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[]): 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).Sub as Record | Record[] | undefined).map((sub) => attribute(sub, 'value')) : undefined const links = element === 'LinkList' && elementValue && typeof elementValue === 'object' ? asArray((elementValue as Record).Link as Record | Record[] | undefined).map((link) => attribute(link, 'value')) : undefined const linkSubs = element === 'LinkSubList' && elementValue && typeof elementValue === 'object' ? asArray((elementValue as Record).Link as Record | Record[] | undefined).map((link) => ({ objectId: attribute(link, 'obj'), subElement: attribute(link, 'sub') })) : undefined const customEnumList = property.CustomEnumList && typeof property.CustomEnumList === 'object' ? property.CustomEnumList as Record : undefined const enumOptions = customEnumList ? asArray(customEnumList.Enum as Record | Record[] | 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') || '', 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() 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() 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() 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() 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() 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() 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() 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() 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 = {} 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 (/ 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 } 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 return { xml, root, rootName: matchedRootName } } const assertUniqueNodeNames = (nodes: Record[], 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[]): Map => { 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).Expression as Record | Record[] | 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() 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 | Record[]) 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 | Record[]) 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 | undefined)?.Object) as Record | Record[] | undefined)) const objectData = asArray((((root.ObjectData as Record | undefined)?.Object) as Record | Record[] | 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 | undefined)?.Property) as Record | Record[] | undefined)) const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label') const nativeObjectTags = new Set() const objects = objectDeclarations.map((declaration): FcstdObjectSummary => { const name = attribute(declaration, 'name') || '' 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 | undefined const properties = asArray(((propertiesContainer?.Property) as Record | Record[] | undefined)) const transientProperties = asArray(((propertiesContainer?._Property) as Record | Record[] | 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 = {} 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, files: Record) => { for (const object of document.objects) for (const property of object.properties) { if (property.typeId !== 'Path::PropertyPath') continue let metadata: Record try { metadata = JSON.parse(property.value) as Record } 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 || ''}`) 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 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[] => { if (!value || typeof value !== 'object') return [] return Object.entries(value as Record).flatMap(([key, child]) => [ ...(key === name ? asArray(child).filter((entry): entry is Record => 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[], 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, 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 | undefined const properties = asArray(((propertiesContainer?.Property) as Record | Record[] | 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): 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 = {}): 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 = { 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 = {}): 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 ( bytes: Uint8Array, resourcePath: string, importShape: (input: { format: 'brep'; text: string }) => Promise, options: FcstdShapeInstantiationOptions = {}, ): Promise> => { 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 = {}, ): Promise => { 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 } })) }