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