import type { DocumentObjectSnapshot, PropertyValue, VectorValue } from './types' export type PartDesignParameterPartition = { id: string description: string values: Record } export type PartDesignParameterFamily = { typeId: string properties: readonly string[] partitions: readonly PartDesignParameterPartition[] } export type PartDesignParameterIssue = { code: 'MISSING_PROPERTY' | 'INVALID_COMBINATION' | 'INVALID_RANGE' | 'INVALID_REFERENCE' | 'INVALID_VECTOR' propertyName: string message: string } export type PartDesignParameterValidation = { typeId: string covered: boolean valid: boolean issues: PartDesignParameterIssue[] } const partition = (id: string, description: string, values: Record): PartDesignParameterPartition => ({ id, description, values }) const family = (typeId: string, properties: readonly string[], partitions: readonly PartDesignParameterPartition[]): PartDesignParameterFamily => ({ typeId, properties, partitions }) const attachmentProperties = ['Support', 'MapMode', 'AttachmentOffset', 'DatumType'] as const const linearProperties = ['Profile', 'Length', 'Length2', 'Type', 'Type2', 'SideType', 'UpToFace', 'UpToFace2', 'TaperAngle', 'TaperAngle2', 'Reversed', 'Midplane', 'ReferenceAxis', 'AlongSketchNormal', 'UseCustomVector', 'Direction', 'Offset', 'Offset2'] as const const angularProperties = ['Profile', 'Angle', 'Angle2', 'Type', 'UpToFace', 'ReferenceAxis', 'Axis', 'AxisLink', 'Midplane', 'Reversed', 'Offset', 'Offset2'] as const const loftProperties = ['Profile', 'Sections', 'Base', 'Ruled', 'Closed', 'Solid'] as const const pipeProperties = ['Profile', 'Spine', 'Base', 'Transition', 'Mode', 'Transformation', 'Solid'] as const export const PARTDESIGN_PARAMETER_SPACE: readonly PartDesignParameterFamily[] = Object.freeze([ family('PartDesign::Plane', attachmentProperties, [ partition('detached', 'No support and Deactivated map mode.', { Support: null, MapMode: 'Deactivated' }), partition('flat-face', 'Stable face support.', { MapMode: 'FlatFace' }), partition('three-points', 'Three-point plane attachment.', { MapMode: 'ThreePointsPlane' }), ]), family('PartDesign::Line', attachmentProperties, [ partition('detached', 'No support and Deactivated map mode.', { Support: null, MapMode: 'Deactivated' }), partition('normal-edge', 'Stable edge support.', { MapMode: 'NormalToEdge' }), partition('two-points', 'Two-point line attachment.', { MapMode: 'TwoPointLine' }), ]), family('PartDesign::Point', attachmentProperties, [ partition('detached', 'No support and Deactivated map mode.', { Support: null, MapMode: 'Deactivated' }), partition('vertex', 'Stable vertex support.', { MapMode: 'Vertex' }), partition('center-mass', 'Center-of-mass attachment.', { MapMode: 'CenterOfMass' }), ]), family('PartDesign::ShapeBinder', ['Support', 'BindMode', 'TraceSupport', 'ClaimChildren'], [ partition('synchronized', 'Live synchronized support.', { BindMode: 'Synchronized', TraceSupport: true }), partition('frozen', 'Frozen support snapshot.', { BindMode: 'Frozen', TraceSupport: false }), partition('claim-children', 'Binder owns source children.', { ClaimChildren: true }), ]), family('PartDesign::Pad', linearProperties, [ partition('dimension', 'One finite side.', { Type: 'Dimension', SideType: 'One side', Length: 10, Reversed: false }), partition('two-lengths', 'Independent forward and reverse sides.', { Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5 }), partition('midplane', 'Symmetric finite pad.', { Type: 'Dimension', SideType: 'Symmetric', Length: 10, Midplane: true }), partition('up-to-face', 'Stable terminating face.', { Type: 'Up to face' }), partition('custom-vector', 'Explicit extrusion vector.', { UseCustomVector: true, Direction: { x: 0, y: 0, z: 1 } }), ]), family('PartDesign::Pocket', ['Base', ...linearProperties], [ partition('dimension', 'One finite side.', { Type: 'Dimension', SideType: 'One side', Length: 10, Reversed: false }), partition('through-all', 'Through-all removal.', { Type: 'Through all', SideType: 'One side' }), partition('two-lengths', 'Independent forward and reverse sides.', { Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5 }), partition('midplane', 'Symmetric finite pocket.', { Type: 'Dimension', SideType: 'Symmetric', Length: 10, Midplane: true }), partition('up-to-face', 'Stable terminating face.', { Type: 'Up to face' }), ]), family('PartDesign::Revolution', angularProperties, [ partition('angle', 'One angular side.', { Type: 'Angle', Angle: 180, Reversed: false }), partition('two-angles', 'Independent positive and reverse angles.', { Type: 'Two angles', Angle: 120, Angle2: 60 }), partition('midplane', 'Symmetric angular feature.', { Type: 'Angle', Angle: 180, Midplane: true }), partition('up-to-face', 'Stable angular terminating face.', { Type: 'Up to face' }), ]), family('PartDesign::Groove', ['Base', ...angularProperties], [ partition('angle', 'One angular side.', { Type: 'Angle', Angle: 180, Reversed: false }), partition('two-angles', 'Independent positive and reverse angles.', { Type: 'Two angles', Angle: 120, Angle2: 60 }), partition('midplane', 'Symmetric angular feature.', { Type: 'Angle', Angle: 180, Midplane: true }), partition('up-to-face', 'Stable angular terminating face.', { Type: 'Up to face' }), ]), family('PartDesign::AdditiveLoft', loftProperties, [ partition('smooth', 'Smooth additive loft.', { Ruled: false, Closed: false, Solid: true }), partition('ruled', 'Ruled additive loft.', { Ruled: true, Closed: false, Solid: true }), partition('closed', 'Closed additive loft.', { Ruled: false, Closed: true, Solid: true }), ]), family('PartDesign::SubtractiveLoft', loftProperties, [ partition('smooth', 'Smooth subtractive loft.', { Ruled: false, Closed: false, Solid: true }), partition('ruled', 'Ruled subtractive loft.', { Ruled: true, Closed: false, Solid: true }), partition('closed', 'Closed subtractive loft.', { Ruled: false, Closed: true, Solid: true }), ]), family('PartDesign::AdditivePipe', pipeProperties, [ partition('standard', 'Standard transformed pipe.', { Mode: 'Standard', Transition: 'Transformed', Transformation: 'Constant' }), partition('frenet', 'Frenet pipe.', { Mode: 'Frenet', Transition: 'Round corner', Transformation: 'Constant' }), partition('multisection', 'Multi-section pipe.', { Mode: 'Standard', Transition: 'Right corner', Transformation: 'Multisection' }), ]), family('PartDesign::SubtractivePipe', pipeProperties, [ partition('standard', 'Standard transformed pipe.', { Mode: 'Standard', Transition: 'Transformed', Transformation: 'Constant' }), partition('frenet', 'Frenet pipe.', { Mode: 'Frenet', Transition: 'Round corner', Transformation: 'Constant' }), partition('multisection', 'Multi-section pipe.', { Mode: 'Standard', Transition: 'Right corner', Transformation: 'Multisection' }), ]), family('PartDesign::Fillet', ['Base', 'Radius', 'UseAllEdges'], [ partition('selected', 'Selected stable edges.', { Radius: 1, UseAllEdges: false }), partition('all-edges', 'All edges.', { Radius: 1, UseAllEdges: true }), partition('boundary', 'Small positive radius.', { Radius: Number.EPSILON, UseAllEdges: false }), ]), family('PartDesign::Chamfer', ['Base', 'Size', 'Size2', 'Angle', 'ChamferType', 'FlipDirection', 'UseAllEdges'], [ partition('equal', 'Equal-distance chamfer.', { Size: 1, ChamferType: 'Equal distance' }), partition('two-distances', 'Two-distance chamfer.', { Size: 1, Size2: 2, ChamferType: 'Two distances' }), partition('distance-angle', 'Distance-and-angle chamfer.', { Size: 1, Angle: 45, ChamferType: 'Distance and Angle' }), ]), family('PartDesign::Draft', ['Base', 'Angle', 'Direction', 'NeutralPlaneOrigin', 'NeutralPlaneDirection', 'Reversed', 'UseAllFaces'], [ partition('positive', 'Positive draft.', { Angle: 5, Reversed: false }), partition('negative', 'Reverse draft.', { Angle: -5, Reversed: true }), partition('all-faces', 'All supported faces.', { Angle: 5, UseAllFaces: true }), ]), family('PartDesign::Thickness', ['Base', 'Value', 'RemoveFaces', 'Join', 'Mode', 'Reversed'], [ partition('arc', 'Arc join.', { Value: 1, Join: 'Arc', Mode: 'Skin', Reversed: false }), partition('intersection', 'Intersection join.', { Value: 1, Join: 'Intersection', Mode: 'Skin', Reversed: false }), partition('reversed', 'Reversed thickness.', { Value: 1, Join: 'Arc', Mode: 'Skin', Reversed: true }), ]), family('PartDesign::Mirrored', ['Base', 'Originals', 'TransformMode', 'Plane', 'PlaneOrigin', 'PlaneNormal', 'Fuse'], [ partition('xy', 'Mirror on XY plane.', { Plane: 'XY plane', PlaneNormal: { x: 0, y: 0, z: 1 }, Fuse: true }), partition('xz', 'Mirror on XZ plane.', { Plane: 'XZ plane', PlaneNormal: { x: 0, y: 1, z: 0 }, Fuse: true }), partition('yz', 'Mirror on YZ plane.', { Plane: 'YZ plane', PlaneNormal: { x: 1, y: 0, z: 0 }, Fuse: false }), ]), family('PartDesign::MultiTransform', ['Base', 'Originals', 'TransformMode', 'Transformations'], [ partition('linear-polar', 'Ordered linear and polar transforms.', { Transformations: { steps: [{ id: 'linear', type: 'linear', occurrences: 2, length: 10, direction: 'Horizontal' }, { id: 'polar', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' }] } }), partition('linear-mirror', 'Ordered linear and mirrored transforms.', { Transformations: { steps: [{ id: 'linear', type: 'linear', occurrences: 2, length: 10, direction: 'Vertical' }, { id: 'mirror', type: 'mirrored', plane: 'YZ plane' }] } }), partition('polar-mirror', 'Ordered polar and mirrored transforms.', { Transformations: { steps: [{ id: 'polar', type: 'polar', occurrences: 3, angle: 360, axis: 'Normal' }, { id: 'mirror', type: 'mirrored', plane: 'XY plane' }] } }), ]), family('PartDesign::LinearPattern', ['Base', 'Originals', 'TransformMode', 'Occurrences', 'Length', 'Offset', 'Direction', 'DirectionVector', 'Mode', 'Reversed', 'Spacings', 'SpacingPattern', 'Direction2', 'Mode2', 'Length2', 'Offset2', 'Occurrences2', 'Reversed2', 'Spacings2', 'SpacingPattern2'], [ partition('extent', 'One-direction extent pattern.', { Occurrences: 2, Length: 20, Mode: 'Extent', Direction2: 'None' }), partition('spacing', 'One-direction spacing pattern.', { Occurrences: 3, Offset: 5, Mode: 'Spacing', Direction2: 'None' }), partition('two-directions', 'Two-direction rectangular pattern.', { Occurrences: 2, Length: 20, Mode: 'Extent', Direction2: 'Vertical', Occurrences2: 2, Length2: 10, Mode2: 'Extent' }), ]), family('PartDesign::PolarPattern', ['Base', 'Originals', 'TransformMode', 'Occurrences', 'Angle', 'Axis', 'AxisOrigin', 'AxisDirection', 'Mode', 'Offset', 'Spacings', 'SpacingPattern', 'Reversed'], [ partition('extent', 'Angular extent pattern.', { Occurrences: 3, Angle: 360, Mode: 'Extent' }), partition('spacing', 'Fixed angular spacing.', { Occurrences: 3, Offset: 30, Mode: 'Spacing' }), partition('reversed', 'Reversed angular pattern.', { Occurrences: 3, Angle: 180, Mode: 'Extent', Reversed: true }), ]), family('PartDesign::Hole', ['Base', 'Diameter', 'Depth', 'Type', 'DepthType', 'Position', 'Direction', 'Reversed', 'HoleCutType', 'HoleCutDiameter', 'HoleCutDepth', 'HoleCutCountersinkAngle', 'DrillPoint', 'DrillPointAngle', 'DrillForDepth', 'Tapered', 'TaperedAngle', 'HoleCutCustomValues', 'Threaded', 'ModelThread', 'ThreadType', 'ThreadSize', 'ThreadDiameter', 'ThreadPitch', 'ThreadClass', 'ThreadFit', 'ThreadDirection', 'ThreadDepthType', 'ThreadDepth', 'UseCustomThreadClearance', 'CustomThreadClearance'], [ partition('dimension', 'Finite plain hole.', { DepthType: 'Dimension', Diameter: 5, Depth: 10, HoleCutType: 'None' }), partition('through-all', 'Through-all hole.', { DepthType: 'ThroughAll', Diameter: 5, Depth: 10 }), partition('counterbore', 'Counterbored hole.', { HoleCutType: 'Counterbore', HoleCutDiameter: 8, HoleCutDepth: 2 }), partition('countersink', 'Countersunk hole.', { HoleCutType: 'Countersink', HoleCutDiameter: 8, HoleCutCountersinkAngle: 90 }), partition('threaded', 'Thread metadata without modeled thread.', { Threaded: true, ModelThread: false, ThreadType: 'ISOMetricProfile', ThreadDiameter: 6, ThreadPitch: 1 }), partition('modeled-thread', 'Modeled thread.', { Threaded: true, ModelThread: true, ThreadType: 'ISOMetricProfile', ThreadDiameter: 6, ThreadPitch: 1 }), ]), ]) const byTypeId = new Map(PARTDESIGN_PARAMETER_SPACE.map((entry) => [entry.typeId, entry])) const number = (values: Readonly>, name: string) => typeof values[name] === 'number' ? values[name] as number : undefined const string = (values: Readonly>, name: string) => typeof values[name] === 'string' ? values[name] as string : undefined const bool = (values: Readonly>, name: string) => values[name] === true const vector = (values: Readonly>, name: string) => { const value = values[name] return value && typeof value === 'object' && !Array.isArray(value) && 'x' in value && 'y' in value && 'z' in value ? value as VectorValue : undefined } const push = (issues: PartDesignParameterIssue[], code: PartDesignParameterIssue['code'], propertyName: string, message: string) => issues.push({ code, propertyName, message }) const positive = (issues: PartDesignParameterIssue[], values: Readonly>, name: string) => { const value = number(values, name) if (value === undefined || !Number.isFinite(value) || value <= 0) push(issues, 'INVALID_RANGE', name, `${name} must be finite and greater than zero.`) } const angle = (issues: PartDesignParameterIssue[], values: Readonly>, name: string, allowZero = false) => { const value = number(values, name) if (value === undefined || !Number.isFinite(value) || value < (allowZero ? 0 : Number.EPSILON) || value > 360) push(issues, 'INVALID_RANGE', name, `${name} must be within ${allowZero ? '[0, 360]' : '(0, 360]'} degrees.`) } const nonZeroVector = (issues: PartDesignParameterIssue[], values: Readonly>, name: string) => { const value = vector(values, name) if (!value || ![value.x, value.y, value.z].every(Number.isFinite) || Math.hypot(value.x, value.y, value.z) <= 0) push(issues, 'INVALID_VECTOR', name, `${name} must be a finite non-zero vector.`) } export const parameterValuesForObject = (object: Pick): Record => Object.fromEntries(object.properties.filter((property) => property.scope === 'data').map((property) => [property.name, property.value])) export const validatePartDesignParameterSet = ( typeId: string, values: Readonly>, options: { requireComplete?: boolean } = {}, ): PartDesignParameterValidation => { const definition = byTypeId.get(typeId) if (!definition) return { typeId, covered: false, valid: true, issues: [] } const issues: PartDesignParameterIssue[] = [] const requireComplete = options.requireComplete !== false const has = (name: string) => Object.prototype.hasOwnProperty.call(values, name) const checkPositive = (name: string) => { if (requireComplete || has(name)) positive(issues, values, name) } const checkAngle = (name: string, allowZero = false) => { if (requireComplete || has(name)) angle(issues, values, name, allowZero) } const checkVector = (name: string) => { if (requireComplete || has(name)) nonZeroVector(issues, values, name) } if (requireComplete) for (const name of definition.properties) if (!has(name)) push(issues, 'MISSING_PROPERTY', name, `${typeId} is missing parameter ${name}.`) if (typeId === 'PartDesign::Pad' || typeId === 'PartDesign::Pocket') { const mode = string(values, 'Type') if (mode === 'Dimension' || mode === 'TwoLengths') checkPositive('Length') if (mode === 'TwoLengths' || string(values, 'SideType') === 'Two sides') checkPositive('Length2') if (mode === 'Up to face' && values.UpToFace === null) push(issues, 'INVALID_REFERENCE', 'UpToFace', 'Up-to-face mode requires a stable face reference.') if (bool(values, 'Midplane') && (mode === 'TwoLengths' || string(values, 'SideType') === 'Two sides')) push(issues, 'INVALID_COMBINATION', 'Midplane', 'Midplane cannot be combined with independent two-sided lengths.') for (const name of ['TaperAngle', 'TaperAngle2']) { const value = number(values, name) if (value !== undefined && (!Number.isFinite(value) || Math.abs(value) >= 90)) push(issues, 'INVALID_RANGE', name, `${name} must be strictly between -90 and 90 degrees.`) } if (bool(values, 'UseCustomVector')) checkVector('Direction') } if (typeId === 'PartDesign::Revolution' || typeId === 'PartDesign::Groove') { checkAngle('Angle') if (string(values, 'Type') === 'Two angles') { checkAngle('Angle2') if ((number(values, 'Angle') ?? 0) + (number(values, 'Angle2') ?? 0) > 360) push(issues, 'INVALID_COMBINATION', 'Angle2', 'Angle and Angle2 must total no more than 360 degrees.') if (bool(values, 'Midplane')) push(issues, 'INVALID_COMBINATION', 'Midplane', 'Midplane cannot be combined with Two angles.') } } if (typeId.endsWith('Loft')) { const sections = [...(typeof values.Profile === 'string' ? [values.Profile] : []), ...(Array.isArray(values.Sections) ? values.Sections.filter((entry): entry is string => typeof entry === 'string') : [])] if ((requireComplete || has('Profile') || has('Sections')) && (sections.length < 2 || new Set(sections).size !== sections.length)) push(issues, 'INVALID_REFERENCE', 'Sections', 'Loft requires at least two unique section profiles.') } if (typeId.endsWith('Pipe')) { const profile = string(values, 'Profile') const spineValue = values.Spine const spine = typeof spineValue === 'string' ? spineValue : spineValue && typeof spineValue === 'object' && !Array.isArray(spineValue) && 'objectId' in spineValue ? String(spineValue.objectId) : undefined if ((requireComplete || has('Profile') || has('Spine')) && (!profile || !spine || profile === spine)) push(issues, 'INVALID_REFERENCE', 'Spine', 'Pipe requires distinct Profile and Spine references.') } if (typeId === 'PartDesign::Fillet') checkPositive('Radius') if (typeId === 'PartDesign::Chamfer') { checkPositive('Size') if (string(values, 'ChamferType') === 'Two distances') checkPositive('Size2') if (string(values, 'ChamferType') === 'Distance and Angle') checkAngle('Angle') } if (typeId === 'PartDesign::Draft') { const value = number(values, 'Angle') if ((requireComplete || has('Angle')) && (value === undefined || !Number.isFinite(value) || value === 0 || Math.abs(value) >= 90)) push(issues, 'INVALID_RANGE', 'Angle', 'Draft Angle must be non-zero and strictly between -90 and 90 degrees.') checkVector('Direction') checkVector('NeutralPlaneDirection') } if (typeId === 'PartDesign::Thickness') checkPositive('Value') if (typeId === 'PartDesign::Mirrored') checkVector('PlaneNormal') if (typeId === 'PartDesign::LinearPattern') { const occurrences = number(values, 'Occurrences') if ((requireComplete || has('Occurrences')) && (!Number.isSafeInteger(occurrences) || (occurrences ?? 0) < 2 || (occurrences ?? 0) > 100)) push(issues, 'INVALID_RANGE', 'Occurrences', 'Occurrences must be an integer between 2 and 100.') if (string(values, 'Mode') === 'Extent') checkPositive('Length') else if (has('Mode') || requireComplete) checkPositive('Offset') checkVector('DirectionVector') if (string(values, 'Direction2') !== 'None') { const occurrences2 = number(values, 'Occurrences2') if ((requireComplete || has('Occurrences2')) && (!Number.isSafeInteger(occurrences2) || (occurrences2 ?? 0) < 2 || (occurrences2 ?? 0) > 100)) push(issues, 'INVALID_RANGE', 'Occurrences2', 'Occurrences2 must be an integer between 2 and 100 when the second direction is enabled.') if (string(values, 'Mode2') === 'Extent') checkPositive('Length2') else if (has('Mode2') || requireComplete) checkPositive('Offset2') } } if (typeId === 'PartDesign::PolarPattern') { const occurrences = number(values, 'Occurrences') if ((requireComplete || has('Occurrences')) && (!Number.isSafeInteger(occurrences) || (occurrences ?? 0) < 2 || (occurrences ?? 0) > 100)) push(issues, 'INVALID_RANGE', 'Occurrences', 'Occurrences must be an integer between 2 and 100.') if (string(values, 'Mode') === 'Extent') checkAngle('Angle') else if (has('Mode') || requireComplete) checkAngle('Offset') checkVector('AxisDirection') } if (typeId === 'PartDesign::Hole') { checkPositive('Diameter') if (string(values, 'DepthType') === 'Dimension') checkPositive('Depth') checkVector('Direction') if (string(values, 'HoleCutType') !== 'None' && (has('HoleCutType') || requireComplete)) checkPositive('HoleCutDiameter') if (string(values, 'HoleCutType') === 'Counterbore' || string(values, 'HoleCutType') === 'Counterdrill') checkPositive('HoleCutDepth') if (string(values, 'HoleCutType') === 'Countersink' || string(values, 'HoleCutType') === 'Counterdrill') checkAngle('HoleCutCountersinkAngle') if (bool(values, 'Threaded')) { if (!string(values, 'ThreadType') || string(values, 'ThreadType') === 'None') push(issues, 'INVALID_COMBINATION', 'ThreadType', 'Threaded holes require a thread standard.') checkPositive('ThreadDiameter') checkPositive('ThreadPitch') } if (bool(values, 'ModelThread') && !bool(values, 'Threaded')) push(issues, 'INVALID_COMBINATION', 'ModelThread', 'ModelThread requires Threaded.') } return { typeId, covered: true, valid: issues.length === 0, issues } } export const assertPartDesignParameterSet = (object: Pick): void => { const report = validatePartDesignParameterSet(object.typeId, parameterValuesForObject(object)) if (!report.valid) throw new RangeError(`${object.typeId}.${report.issues[0].propertyName}: ${report.issues[0].message}`) } export const partDesignParameterSpaceCoverage = () => ({ schemaVersion: 1 as const, baseline: 'FreeCAD 1.1.1' as const, families: PARTDESIGN_PARAMETER_SPACE.length, properties: PARTDESIGN_PARAMETER_SPACE.reduce((total, entry) => total + entry.properties.length, 0), partitions: PARTDESIGN_PARAMETER_SPACE.reduce((total, entry) => total + entry.partitions.length, 0), duplicateTypeIds: PARTDESIGN_PARAMETER_SPACE.length - new Set(PARTDESIGN_PARAMETER_SPACE.map((entry) => entry.typeId)).size, familiesWithoutPartitions: PARTDESIGN_PARAMETER_SPACE.filter((entry) => entry.partitions.length < 3).map((entry) => entry.typeId), })