feat: complete supported Sketcher and PartDesign ABI contract
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-11 20:34:23 -04:00
parent 2be18a511e
commit aa607451ad
20 changed files with 918 additions and 62 deletions

View File

@@ -334,6 +334,26 @@ const nativeDataPropertyNames = new Map<string, ReadonlySet<string>>([
['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'])],
@@ -384,6 +404,9 @@ const sketchGeometryType = {
circle: 'Part::GeomCircle',
arc: 'Part::GeomArcOfCircle',
ellipse: 'Part::GeomEllipse',
arcEllipse: 'Part::GeomArcOfEllipse',
arcHyperbola: 'Part::GeomArcOfHyperbola',
arcParabola: 'Part::GeomArcOfParabola',
bspline: 'Part::GeomBSplineCurve',
} as const
@@ -393,6 +416,9 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
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) => {
@@ -411,7 +437,7 @@ const sketchGeometryPayloadXml = (geometry: SketchGeometry) => {
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 | 9 | 10
type SketchInternalAlignmentType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11
type SketchInternalGeometryHelper = {
key: string
@@ -533,8 +559,8 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome
firstPosition: 1,
geometry: { id: `Internal${helpers.size}`, type: 'point', position: bsplinePointAtParameter(target, uniqueKnots[constraint.internalGeometryIndex]), construction: true },
})
} else {
if (target.type !== 'ellipse') throw new Error(`FCStd native Sketch InternalAlignment '${constraint.id}' requires ellipse geometry '${constraint.geometryId}'.`)
} 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
@@ -548,6 +574,31 @@ const sketchInternalGeometryPlan = (sketch: SketchSnapshot): SketchInternalGeome
? { 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
@@ -746,7 +797,7 @@ const sketchConstraintPoint = (geometryIndex: Map<string, number>, geometryById:
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' ? reference.point === 'center'
: 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)
@@ -1370,6 +1421,9 @@ const structuredPropertyValue = (element: string, elementValue: unknown, propert
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)
@@ -1676,7 +1730,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
const candidate = nativeGeometryForNativeId(nativeId)
return candidate && !candidate.freecadInternalType ? candidate : undefined
}
type InternalHelperBinding = { geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'bspline-control-point' | 'bspline-knot' }
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
@@ -1688,11 +1742,20 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
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' || record.positions[0] !== 0 || record.internalAlignmentIndex !== -1) return undefined
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' || record.positions[0] !== 1 || record.internalAlignmentIndex !== -1) return undefined
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' }
@@ -1701,6 +1764,9 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
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.`)
@@ -1715,7 +1781,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
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') return position === 3 ? { geometryId: candidate.id, point: 'center' } : 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

View File

@@ -77,14 +77,18 @@ export { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '
export type { NativeOcctHistoryOperation, NativeOcctHistoryRecord, NativeOcctHistoryResponse, NativeOcctHistoryStage, NativeOcctHistoryStepGeometry, NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
export { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NativeOcctHistoryUnavailableError, UnavailableNativeOcctHistoryProvider } from './nativeHistoryProtocol'
export { NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from './nativeHistoryProtocol'
export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities } from './nativeHistoryProtocol'
export { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, nativeNamingAbiCapabilities, nativeNamingCapabilitiesForModule } from './nativeHistoryProtocol'
export type { NativeNamingAbiCapabilities, NativeOcctHistoryCapabilities, NativeOcctHistoryExecution, NativeOcctHistoryInputTransport, NativeOcctHistoryProvider, NativeOcctHistoryProtocolResponse, NativeOcctHistoryRequest, NativeOcctHistoryStageTransport } from './nativeHistoryProtocol'
export { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, FREECAD_PRIVATE_NAMING_ABI_VERSION, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
export type { FreeCadPrivateNamingAbiDescriptor, FreeCadPrivateNamingAbiProbe, FreeCadPrivateNamingAbiRequest, NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
export { assertNativeNamingEvidence, createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, hasNativeMappedNameEvidence, validateNativeNamingEvidence } from './nativeNamingEvidence'
export type { NativeMappedNameRef, NativeMappedNameRelation, NativeNamingEvidenceIssue, NativeNamingEvidenceReport, NativeNamingEvidenceStatus, NativeStageNamingEvidence } from './nativeNamingEvidence'
export { NativeOcctHistoryWorkerProvider } from './nativeHistoryWorkerClient'
export type { NativeOcctHistoryWorkerOptions } from './nativeHistoryWorkerClient'
export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher'
export { applySketchAutoConstraints, BasicSketchSolverAdapter, carbonCopySketchGeometry, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, FREECAD_SKETCHER_CONSTRAINT_TYPES, FREECAD_SKETCHER_GEOMETRY_TYPES, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, projectSketchGeometry, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, validateSketchSnapshot } from './sketcher'
export type { BsplineGeometryPatch, SketchAutoConstraintSuggestion, SketchConstraint, SketchDiagnostic, SketchEditorEvent, SketchEditorReplayResult, SketchExternalGeometry, SketchExternalMode, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
export { assertPartDesignParameterSet, parameterValuesForObject, PARTDESIGN_PARAMETER_SPACE, partDesignParameterSpaceCoverage, validatePartDesignParameterSet } from './partDesignParameters'
export type { PartDesignParameterFamily, PartDesignParameterIssue, PartDesignParameterPartition, PartDesignParameterValidation } from './partDesignParameters'
export { assertSketchSolverRequest, assertSketchSolverResponse, BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol'
export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverExecution, SketchSolverProvider, SketchSolverReplayCase, SketchSolverReplayResult, SketchSolverRequest, SketchSolverResponse } from './sketchSolverProtocol'
export { PLANEGCS_WASM_CAPABILITIES, PlanegcsSubsetError, solvePlanegcsSubset } from './planegcsAdapter'

View File

@@ -46,6 +46,7 @@ import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replac
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
import { createCamJob } from './cam'
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
import { assertPartDesignParameterSet, parameterValuesForObject, validatePartDesignParameterSet } from './partDesignParameters'
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
@@ -145,32 +146,54 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'SideType', label: 'Side definition', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'One side', options: ['One side', 'Two sides', 'Symmetric'], recompute: true },
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face', 'TwoLengths'], recompute: true },
{ name: 'Type2', label: 'Type 2', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face'], recompute: true },
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'UpToFace2', label: 'Reverse up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'Length2', label: 'Reverse length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
{ name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
{ name: 'TaperAngle2', label: 'Reverse taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Direction', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'AlongSketchNormal', label: 'Along sketch normal', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true },
{ name: 'UseCustomVector', label: 'Use custom vector', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Direction', label: 'Direction', group: 'Direction', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 }, recompute: true },
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
]
if (item.id.startsWith('pocket')) return [
{ name: 'SideType', label: 'Side definition', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'One side', options: ['One side', 'Two sides', 'Symmetric'], recompute: true },
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Through all', options: ['Dimension', 'Through all', 'Up to face', 'TwoLengths'], recompute: true },
{ name: 'Type2', label: 'Type 2', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'Through all', 'Up to face'], recompute: true },
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true },
{ name: 'Length2', label: 'Reverse length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 0, unit: 'mm', recompute: true },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'UpToFace2', label: 'Reverse up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
{ name: 'TaperAngle2', label: 'Reverse taper angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Direction', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'AlongSketchNormal', label: 'Along sketch normal', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true },
{ name: 'UseCustomVector', label: 'Use custom vector', group: 'Direction', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Direction', label: 'Direction', group: 'Direction', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 }, recompute: true },
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
]
if (item.id.startsWith('revolution')) return [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
{ name: 'Angle2', label: 'Reverse angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 0, unit: 'deg', recompute: true },
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angle', options: ['Angle', 'To last', 'To first', 'Up to face', 'Two angles'], recompute: true },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'Axis', label: 'Axis', group: 'Axis', scope: 'data', type: 'App::PropertyEnumeration', value: 'Vertical sketch axis', options: ['Horizontal sketch axis', 'Vertical sketch axis', 'Custom'], recompute: true },
{ name: 'AxisLink', label: 'Axis link', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
]
if (item.id.startsWith('groove')) return [
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 360, unit: 'deg', recompute: true },
@@ -178,8 +201,14 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angle', options: ['Angle', 'To last', 'To first', 'Up to face', 'Two angles'], recompute: true },
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true },
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'pad', recompute: true },
{ name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'ReferenceAxis', label: 'Reference axis', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'Axis', label: 'Axis', group: 'Axis', scope: 'data', type: 'App::PropertyEnumeration', value: 'Vertical sketch axis', options: ['Horizontal sketch axis', 'Vertical sketch axis', 'Custom'], recompute: true },
{ name: 'AxisLink', label: 'Axis link', group: 'Axis', scope: 'data', type: 'App::PropertyLinkSub', value: null, recompute: true },
{ name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'Offset', label: 'Profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
{ name: 'Offset2', label: 'Reverse profile offset', group: 'Parameters', scope: 'data', type: 'App::PropertyDistance', value: 0, unit: 'mm', recompute: true },
]
if (item.id.startsWith('fillet')) return [
{ name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true },
@@ -885,6 +914,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
...commonProperties(item).map((property) => property.name === 'TypeId' ? { ...property, value: typeId } : property),
{ name: 'Base', label: 'Base edge', group: 'Chamfer', scope: 'data', type: 'App::PropertyLinkSub', value: selectedEdgeRef, recompute: true },
{ name: 'Size', label: 'Size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true },
{ name: 'Size2', label: 'Second size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true },
{ name: 'Angle', label: 'Angle', group: 'Chamfer', scope: 'data', type: 'App::PropertyAngle', value: 45, unit: 'deg', recompute: true },
{ name: 'ChamferType', label: 'Chamfer type', group: 'Chamfer', scope: 'data', type: 'App::PropertyEnumeration', value: 'Equal distance', options: ['Equal distance', 'Two distances', 'Distance and Angle'], recompute: true },
{ name: 'FlipDirection', label: 'Flip direction', group: 'Chamfer', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
{ name: 'UseAllEdges', label: 'Use all edges', group: 'Chamfer', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
@@ -913,6 +944,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
{ name: 'Value', label: 'Value', group: 'Thickness', scope: 'data', type: 'App::PropertyLength', value: 1, unit: 'mm', recompute: true },
{ name: 'RemoveFaces', label: 'Remove faces', group: 'Thickness', scope: 'data', type: 'App::PropertyLinkSub', value: selectedFaceRef, recompute: true },
{ name: 'Join', label: 'Join type', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Arc', options: ['Arc', 'Intersection'], recompute: true },
{ name: 'Mode', label: 'Mode', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Skin', options: ['Skin', 'Pipe', 'Recto verso'], recompute: true },
{ name: 'Reversed', label: 'Reversed', group: 'Thickness', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true },
...viewProperties(),
]
@@ -1002,6 +1034,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
if (commandId !== 'sweep-part' && !bodyTip) throw new RangeError('Part Design Pipe requires a valid Body Tip base feature.')
}
for (const property of objectSnapshot.properties.filter((candidate) => candidate.recompute && !candidate.readOnly)) validatePropertyValue(document, property, property.value)
assertPartDesignParameterSet(objectSnapshot)
if (commandId === 'linear-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Length')?.value) <= 0) throw new RangeError('Linear pattern length must be greater than zero.')
if (commandId === 'polar-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Angle')?.value) <= 0) throw new RangeError('Polar pattern angle must be greater than zero.')
if (commandId === 'hole') {
@@ -1033,6 +1066,8 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const document = cloneDocumentSnapshot(state.document)
const object = document.objects[objectIndex]
object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined }
const parameterReport = validatePartDesignParameterSet(object.typeId, parameterValuesForObject(object), { requireComplete: false })
if (!parameterReport.valid) throw new RangeError(`${object.typeId}.${parameterReport.issues[0].propertyName}: ${parameterReport.issues[0].message}`)
const treeItem = document.tree.find((item) => item.id === objectId)
if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
document.dependencies = collectDependencyEdges(document)

View File

@@ -1,16 +1,24 @@
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse, NativeOcctHistoryStepProvider, NativeOcctMultiTransformStep } from './nativeHistoryProvider'
import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest, probeFreeCadPrivateNamingAbi } from './nativeNamingAbi'
export const NATIVE_OCCT_HISTORY_PROTOCOL_VERSION = 1 as const
export type NativeNamingAbiCapabilities = {
mappedNameRef: 'available' | 'optional' | 'unavailable'
stringHasher: 'available' | 'opaque-preserved' | 'unavailable'
elementMap2: 'available' | 'opaque-preserved' | 'unavailable'
tokenGeneration: 'native-only' | 'forbidden'
abiVersion?: number
freecadVersion?: string
sourceCommit?: string
operations?: NativeOcctHistoryOperation[]
reason?: string
}
export const NATIVE_OCCT_NAMING_ABI_UNAVAILABLE: NativeNamingAbiCapabilities = Object.freeze({
mappedNameRef: 'unavailable',
stringHasher: 'unavailable',
elementMap2: 'unavailable',
tokenGeneration: 'forbidden',
})
@@ -29,8 +37,24 @@ export type NativeOcctHistoryCapabilities = {
export const nativeNamingAbiCapabilities = (capabilities: NativeOcctHistoryCapabilities): NativeNamingAbiCapabilities => ({
...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE,
...(capabilities.naming ?? {}),
...(capabilities.naming?.operations ? { operations: [...capabilities.naming.operations] } : {}),
})
export const nativeNamingCapabilitiesForModule = (module: NativeOcctHistoryStepProvider): NativeNamingAbiCapabilities => {
const probe = probeFreeCadPrivateNamingAbi(module)
if (probe.availability !== 'available' || !probe.descriptor) return { ...NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, ...(probe.reason ? { reason: probe.reason } : {}), ...(probe.abiVersion === null ? {} : { abiVersion: probe.abiVersion }) }
return {
mappedNameRef: 'available',
stringHasher: 'available',
elementMap2: 'available',
tokenGeneration: 'native-only',
abiVersion: probe.abiVersion ?? undefined,
freecadVersion: probe.descriptor.freecadVersion,
sourceCommit: probe.descriptor.sourceCommit,
operations: [...probe.descriptor.operations],
}
}
export type NativeOcctHistoryRequest = {
protocolVersion: typeof NATIVE_OCCT_HISTORY_PROTOCOL_VERSION
requestId: string
@@ -241,6 +265,7 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
availability: 'available',
operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'],
transport: 'step-text',
naming: nativeNamingCapabilitiesForModule(this.module),
}
}
@@ -323,7 +348,27 @@ export class DirectNativeOcctHistoryProvider implements NativeOcctHistoryProvide
return this.module.booleanHistoryFromStep(request.objectStep, request.toolStep!, request.operation)
})
if (signal.aborted) throw abortError()
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: attachStageMetadata(request, history) })
const stagedHistory = attachStageMetadata(request, history)
const orderedStages = [...(request.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal)
const finalStage = orderedStages.at(-1)
const stageId = finalStage?.resultStageId ?? finalStage?.stageId ?? `${request.operationId}:stage:0`
const namingEvidence = captureFreeCadPrivateNamingEvidence(this.module, createFreeCadPrivateNamingAbiRequest({
requestId: request.requestId,
documentId: request.documentId,
documentVersion: request.documentVersion,
operationId: request.operationId,
operation: request.operation,
stageId,
resultObjectId: request.operationId,
inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, role, stageId: inputStageId, step }) => ({ inputId, step, ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}) })),
stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })),
...(stagedHistory.resultStep ? { resultStep: stagedHistory.resultStep } : {}),
...(stagedHistory.resultBrep ? { resultBrep: stagedHistory.resultBrep } : {}),
...(request.resultStepByStage ? { resultStepByStage: { ...request.resultStepByStage } } : {}),
history: stagedHistory,
}))
const enrichedHistory = namingEvidence ? { ...stagedHistory, namingEvidence } : stagedHistory
return assertResponse(request, { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider, history: enrichedHistory })
}
}

View File

@@ -1,5 +1,6 @@
import type { NativeMultiTransformHistoryStep, NativeTopologyHistoryInput, NativeTopologyHistoryRecord, ShapeHandle, SubshapeRef } from './types'
import type { NativeStageNamingEvidence } from './nativeNamingEvidence'
import type { NativeFreeCadNamingAbiModule } from './nativeNamingAbi'
export type NativeOcctMultiTransformStep = NativeMultiTransformHistoryStep
@@ -54,7 +55,7 @@ export type NativeOcctHistoryResponse = {
export type NativeOcctHistoryOperation = 'fuse' | 'cut' | 'common' | 'rotate' | 'pad' | 'pocket' | 'loft' | 'pipe' | 'revolution' | 'groove' | 'fillet' | 'chamfer' | 'hole' | 'draft' | 'thickness' | 'linear-pattern' | 'polar-pattern' | 'mirrored' | 'multi-transform'
export type NativeOcctHistoryStepProvider = {
export type NativeOcctHistoryStepProvider = NativeFreeCadNamingAbiModule & {
occtVersion(): string
booleanHistoryFromStep(objectStep: string, toolStep: string, operation: NativeOcctHistoryOperation): NativeOcctHistoryResponse
rotateHistoryFromStep?(shapeStep: string, axisOriginX: number, axisOriginY: number, axisOriginZ: number, axisDirectionX: number, axisDirectionY: number, axisDirectionZ: number, angleDegrees: number): NativeOcctHistoryResponse

View File

@@ -1,7 +1,8 @@
/// <reference lib="webworker" />
import type { NativeOcctHistoryStepProvider } from './nativeHistoryProvider'
import { NATIVE_OCCT_NAMING_ABI_UNAVAILABLE, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol'
import { nativeNamingCapabilitiesForModule, type NativeOcctHistoryCapabilities, type NativeOcctHistoryRequest, type NativeOcctHistoryProtocolResponse } from './nativeHistoryProtocol'
import { captureFreeCadPrivateNamingEvidence, createFreeCadPrivateNamingAbiRequest } from './nativeNamingAbi'
type WorkerRequest = { type: 'initialize'; moduleUrl: string } | { type: 'capture'; request: NativeOcctHistoryRequest } | { type: 'cancel'; requestId: string } | { type: 'dispose' }
type WorkerResponse = { type: 'ready'; capabilities: NativeOcctHistoryCapabilities } | { type: 'response'; response: NativeOcctHistoryProtocolResponse } | { type: 'error'; requestId?: string; error: string }
@@ -11,6 +12,16 @@ let provider: NativeOcctHistoryStepProvider | null = null
const cancelled = new Set<string>()
const send = (message: WorkerResponse) => scope.postMessage(message)
const operations: NativeOcctHistoryCapabilities['operations'] = ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform']
const capabilitiesFor = (module: NativeOcctHistoryStepProvider, occtVersion = module.occtVersion()): NativeOcctHistoryCapabilities => ({
providerId: 'occt-native.history-step',
providerVersion: '8.0.0-embind',
occtVersion,
availability: 'available',
operations: [...operations],
transport: 'step-text',
naming: nativeNamingCapabilitiesForModule(module),
})
const captureMultiTransform = (module: NativeOcctHistoryStepProvider, request: NativeOcctHistoryRequest) => {
if (request.transforms && request.transforms.length >= 2) return module.multiTransformHistoryFromStep?.(request.objectStep, request.transforms)
@@ -25,15 +36,7 @@ const initialize = async (moduleUrl: string) => {
provider = await imported.default()
send({
type: 'ready',
capabilities: {
providerId: 'occt-native.history-step',
providerVersion: '8.0.0-embind',
occtVersion: provider.occtVersion(),
availability: 'available',
operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'],
transport: 'step-text',
naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE,
},
capabilities: capabilitiesFor(provider),
})
}
@@ -81,7 +84,25 @@ scope.onmessage = ({ data }: MessageEvent<WorkerRequest>) => {
: provider.booleanHistoryFromStep(request.objectStep, request.toolStep || '', request.operation)
if (!history) throw new Error('Native OCCT history provider does not expose the requested feature operation.')
if (cancelled.delete(data.request.requestId)) return
send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: history.occtVersion, availability: 'available', operations: ['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'], transport: 'step-text', naming: NATIVE_OCCT_NAMING_ABI_UNAVAILABLE }, history } })
const orderedStages = [...(request.stages ?? [])].sort((left, right) => left.ordinal - right.ordinal)
const finalStage = orderedStages.at(-1)
const stageId = finalStage?.resultStageId ?? finalStage?.stageId ?? `${request.operationId}:stage:0`
const namingEvidence = captureFreeCadPrivateNamingEvidence(provider, createFreeCadPrivateNamingAbiRequest({
requestId: request.requestId,
documentId: request.documentId,
documentVersion: request.documentVersion,
operationId: request.operationId,
operation: request.operation,
stageId,
resultObjectId: request.operationId,
inputs: (request.inputs ?? [{ inputId: 'object', role: 'object', step: request.objectStep }, ...(request.toolStep ? [{ inputId: 'tool', role: 'tool', step: request.toolStep }] : [])]).map(({ inputId, role, stageId: inputStageId, step }) => ({ inputId, step, ...(role ? { role } : {}), ...(inputStageId ? { stageId: inputStageId } : {}) })),
stages: orderedStages.map((stage) => ({ ...stage, inputIds: [...stage.inputIds] })),
...(history.resultStep ? { resultStep: history.resultStep } : {}),
...(history.resultBrep ? { resultBrep: history.resultBrep } : {}),
...(request.resultStepByStage ? { resultStepByStage: { ...request.resultStepByStage } } : {}),
history,
}))
send({ type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: capabilitiesFor(provider, history.occtVersion), history: namingEvidence ? { ...history, namingEvidence } : history } })
} catch (error) {
send({ type: 'error', requestId: data.type === 'capture' ? data.request.requestId : undefined, error: error instanceof Error ? error.message : String(error) })
}

View File

@@ -0,0 +1,136 @@
import { migrateElementMap2Schema, validateElementMap2 } from './elementMap2'
import { assertNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence'
import { migrateStringHasherSchema, validateElementMap2StringHasherEvidence, validateStringHasherTable } from './stringHasher'
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse } from './nativeHistoryProvider'
export const FREECAD_PRIVATE_NAMING_ABI_VERSION = 1 as const
export const FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES = 16 * 1024 * 1024
export const FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
export type FreeCadPrivateNamingAbiDescriptor = {
schemaVersion: 1
freecadVersion: string
sourceCommit: string
mappedNameRef: true
stringHasher: true
elementMap2: true
operations: NativeOcctHistoryOperation[]
maxRequestBytes?: number
maxResponseBytes?: number
}
export type FreeCadPrivateNamingAbiProbe = {
availability: 'available' | 'unavailable'
abiVersion: number | null
descriptor?: FreeCadPrivateNamingAbiDescriptor
reason?: string
}
/** Embind surface implemented only by a FreeCAD-linked native module. */
export type NativeFreeCadNamingAbiModule = {
freecadNamingAbiVersion?(): number
freecadNamingCapabilitiesJson?(): string
freecadNamingEvidenceJson?(requestJson: string): string
}
export type FreeCadPrivateNamingAbiRequest = {
schemaVersion: 1
requestId: string
documentId: string
documentVersion: number
operationId: string
operation: NativeOcctHistoryOperation
stageId: string
resultObjectId: string
inputs: Array<{ inputId: string; role?: string; stageId?: string; step: string }>
stages: Array<{ stageId: string; operation?: NativeOcctHistoryOperation; inputIds: string[]; resultStageId?: string; ordinal: number }>
resultStep?: string
resultBrep?: string
resultStepByStage?: Record<string, string>
history: Pick<NativeOcctHistoryResponse, 'provider' | 'occtVersion' | 'records' | 'hasModified' | 'hasGenerated' | 'hasDeleted' | 'resultStep' | 'resultBrep'>
}
const encoder = new TextEncoder()
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
const supportedOperations = new Set<NativeOcctHistoryOperation>(['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'])
const boundedLimit = (value: unknown, maximum: number) => Number.isSafeInteger(value) && (value as number) > 0 ? Math.min(value as number, maximum) : maximum
export const probeFreeCadPrivateNamingAbi = (module: NativeFreeCadNamingAbiModule): FreeCadPrivateNamingAbiProbe => {
if (typeof module.freecadNamingAbiVersion !== 'function' || typeof module.freecadNamingCapabilitiesJson !== 'function' || typeof module.freecadNamingEvidenceJson !== 'function') return {
availability: 'unavailable',
abiVersion: null,
reason: 'Native module does not export the versioned FreeCAD naming callbacks.',
}
let abiVersion: number
try { abiVersion = module.freecadNamingAbiVersion() } catch (error) {
return { availability: 'unavailable', abiVersion: null, reason: `FreeCAD naming ABI version probe failed: ${error instanceof Error ? error.message : String(error)}` }
}
if (abiVersion !== FREECAD_PRIVATE_NAMING_ABI_VERSION) return { availability: 'unavailable', abiVersion, reason: `Unsupported FreeCAD naming ABI version ${abiVersion}.` }
try {
const raw = module.freecadNamingCapabilitiesJson()
if (encoder.encode(raw).byteLength > 64 * 1024) throw new RangeError('FreeCAD naming capability payload exceeds 64 KiB.')
const descriptor = JSON.parse(raw) as FreeCadPrivateNamingAbiDescriptor
if (descriptor.schemaVersion !== 1 || descriptor.freecadVersion !== '1.1.1' || descriptor.sourceCommit !== lockedCommit || descriptor.mappedNameRef !== true || descriptor.stringHasher !== true || descriptor.elementMap2 !== true) throw new TypeError('FreeCAD naming capabilities are not locked to the required 1.1.1 private ABI.')
if (!Array.isArray(descriptor.operations) || descriptor.operations.length === 0 || new Set(descriptor.operations).size !== descriptor.operations.length || descriptor.operations.some((operation) => !supportedOperations.has(operation))) throw new TypeError('FreeCAD naming capabilities require a unique non-empty supported operation list.')
return {
availability: 'available',
abiVersion,
descriptor: {
...descriptor,
operations: [...descriptor.operations],
maxRequestBytes: boundedLimit(descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES),
maxResponseBytes: boundedLimit(descriptor.maxResponseBytes, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES),
},
}
} catch (error) {
return { availability: 'unavailable', abiVersion, reason: `FreeCAD naming capability probe failed: ${error instanceof Error ? error.message : String(error)}` }
}
}
export const createFreeCadPrivateNamingAbiRequest = (input: Omit<FreeCadPrivateNamingAbiRequest, 'schemaVersion'>): FreeCadPrivateNamingAbiRequest => {
if (!input.requestId.trim() || !input.documentId.trim() || !input.operationId.trim() || !input.stageId.trim() || !input.resultObjectId.trim()) throw new TypeError('FreeCAD naming ABI request IDs must be non-empty.')
if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('FreeCAD naming ABI documentVersion must be a non-negative integer.')
return {
schemaVersion: 1,
...input,
inputs: input.inputs.map((entry) => {
if (!entry.inputId.trim() || !entry.step.trim()) throw new TypeError('FreeCAD naming ABI inputs require non-empty inputId and STEP text.')
return { ...entry }
}),
stages: input.stages.map((entry) => ({ ...entry, inputIds: [...entry.inputIds] })),
...(input.resultStepByStage ? { resultStepByStage: { ...input.resultStepByStage } } : {}),
history: { ...input.history, records: input.history.records.map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined })) },
}
}
export const captureFreeCadPrivateNamingEvidence = (
module: NativeFreeCadNamingAbiModule,
request: FreeCadPrivateNamingAbiRequest,
probe = probeFreeCadPrivateNamingAbi(module),
): NativeStageNamingEvidence | undefined => {
if (probe.availability !== 'available' || !probe.descriptor || typeof module.freecadNamingEvidenceJson !== 'function') return undefined
if (!probe.descriptor.operations.includes(request.operation)) return undefined
const requestJson = JSON.stringify(request)
const requestBytes = encoder.encode(requestJson).byteLength
const requestLimit = boundedLimit(probe.descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES)
if (requestBytes > requestLimit) throw new RangeError(`FreeCAD naming ABI request exceeds ${requestLimit} bytes.`)
const responseJson = module.freecadNamingEvidenceJson(requestJson)
if (typeof responseJson !== 'string') throw new TypeError('FreeCAD naming ABI must return a JSON string.')
const responseBytes = encoder.encode(responseJson).byteLength
const responseLimit = boundedLimit(probe.descriptor.maxResponseBytes, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES)
if (responseBytes > responseLimit) throw new RangeError(`FreeCAD naming ABI response exceeds ${responseLimit} bytes.`)
const evidence = assertNativeNamingEvidence(JSON.parse(responseJson) as NativeStageNamingEvidence)
if (evidence.stageId !== request.stageId || evidence.resultObjectId !== request.resultObjectId) throw new RangeError('FreeCAD naming ABI response does not match its stage and result object context.')
if (evidence.status !== 'native-evidence' && evidence.status !== 'ambiguous') throw new TypeError(`FreeCAD naming ABI cannot return non-native status '${evidence.status}'.`)
if (!evidence.stringHasher || !evidence.elementMap2) throw new TypeError('FreeCAD naming ABI response requires both StringHasher and ElementMap2 evidence.')
const stringHasher = migrateStringHasherSchema(evidence.stringHasher)
const stringHasherReport = validateStringHasherTable(stringHasher)
if (!stringHasherReport.valid) throw new Error(`FreeCAD naming ABI StringHasher is invalid: ${stringHasherReport.issues[0].path}: ${stringHasherReport.issues[0].message}`)
const elementMap2 = migrateElementMap2Schema(evidence.elementMap2)
const elementMap2Report = validateElementMap2(elementMap2)
if (!elementMap2Report.valid) throw new Error(`FreeCAD naming ABI ElementMap2 is invalid: ${elementMap2Report.issues[0].path}: ${elementMap2Report.issues[0].message}`)
const closureIssues = validateElementMap2StringHasherEvidence(elementMap2, stringHasher)
if (closureIssues.length > 0) throw new Error(`FreeCAD naming ABI StringHasher closure is invalid: ${closureIssues[0].path}: ${closureIssues[0].message}`)
return { ...evidence, stringHasher, elementMap2 }
}

View File

@@ -0,0 +1,288 @@
import type { DocumentObjectSnapshot, PropertyValue, VectorValue } from './types'
export type PartDesignParameterPartition = {
id: string
description: string
values: Record<string, PropertyValue>
}
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<string, PropertyValue>): 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<Record<string, PropertyValue>>, name: string) => typeof values[name] === 'number' ? values[name] as number : undefined
const string = (values: Readonly<Record<string, PropertyValue>>, name: string) => typeof values[name] === 'string' ? values[name] as string : undefined
const bool = (values: Readonly<Record<string, PropertyValue>>, name: string) => values[name] === true
const vector = (values: Readonly<Record<string, PropertyValue>>, 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<Record<string, PropertyValue>>, 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<Record<string, PropertyValue>>, 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<Record<string, PropertyValue>>, 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<DocumentObjectSnapshot, 'properties'>): Record<string, PropertyValue> => Object.fromEntries(object.properties.filter((property) => property.scope === 'data').map((property) => [property.name, property.value]))
export const validatePartDesignParameterSet = (
typeId: string,
values: Readonly<Record<string, PropertyValue>>,
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<DocumentObjectSnapshot, 'typeId' | 'properties'>): 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),
})

View File

@@ -12,7 +12,7 @@ export type FacadeRuntimeProfile = {
availability: 'configured-at-application-boundary' | 'not-configured'
}
naming: {
nativeAbi: 'not-exposed'
nativeAbi: 'freecad-private-v1-optional-worker' | 'not-exposed'
preservation: 'opaque-preserved'
}
persistence: 'sqlite-opfs-with-memory-fallback'
@@ -31,7 +31,7 @@ export const createFacadeRuntimeProfile = (mode: FacadeRuntimeMode): FacadeRunti
availability: 'not-configured',
},
naming: {
nativeAbi: 'not-exposed',
nativeAbi: mode === 'production' ? 'freecad-private-v1-optional-worker' : 'not-exposed',
preservation: 'opaque-preserved',
},
persistence: 'sqlite-opfs-with-memory-fallback',

View File

@@ -8,6 +8,9 @@ export type SketchGeometry =
| { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean }
| { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean }
| { id: string; type: 'ellipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; construction?: boolean }
| { id: string; type: 'arcEllipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean }
| { id: string; type: 'arcHyperbola'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean }
| { id: string; type: 'arcParabola'; center: SketchPoint; focal: number; rotation: number; startAngle: number; endAngle: number; construction?: boolean }
| { id: string; type: 'bspline'; degree: number; controlPoints: SketchPoint[]; weights?: number[]; knots?: number[]; periodic?: boolean; construction?: boolean }
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' }
@@ -43,7 +46,11 @@ export type SketchConstraint =
| { id: string; type: 'weight'; geometryId: string; controlPointIndex: number; value: number; driving?: boolean }
| { id: string; type: 'snellsLaw'; first: SketchPointRef; second: SketchPointRef; boundaryGeometryId: string; value: number; driving?: boolean }
| { id: string; type: 'snellsLaw'; firstGeometryId: string; secondGeometryId: string; value: number; driving?: boolean }
| { id: string; type: 'internalAlignment'; geometryId: string; internalGeometryIndex: number; alignmentType: 'ellipse-major' | 'ellipse-minor' | 'ellipse-focus' | 'bspline-control-point' | 'bspline-knot'; driving?: boolean }
| { id: string; type: 'internalAlignment'; 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'; driving?: boolean }
export const FREECAD_SKETCHER_GEOMETRY_TYPES = Object.freeze(['point', 'line', 'arc', 'circle', 'ellipse', 'arcEllipse', 'arcHyperbola', 'arcParabola', 'bspline'] as const)
export const FREECAD_SKETCHER_CONSTRAINT_TYPES = Object.freeze(['coincident', 'horizontal', 'vertical', 'parallel', 'tangent', 'distance', 'distanceX', 'distanceY', 'angle', 'perpendicular', 'radius', 'equal', 'pointOnObject', 'symmetric', 'internalAlignment', 'snellsLaw', 'block', 'diameter', 'weight'] as const)
export const FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES = Object.freeze(['ellipse-major', 'ellipse-minor', 'ellipse-focus', 'hyperbola-major', 'hyperbola-minor', 'hyperbola-focus', 'parabola-focus', 'parabola-focal-axis', 'bspline-control-point', 'bspline-knot'] as const)
export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid'
@@ -97,10 +104,17 @@ export const validateSketchGeometry = (geometry: SketchGeometry): void => {
if (!Number.isFinite(geometry.radius) || geometry.radius <= 0) throw new RangeError(`${geometry.id} radius must be finite and greater than zero.`)
if (geometry.type === 'arc' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle))) throw new RangeError(`${geometry.id} angles must be finite.`)
}
if (geometry.type === 'ellipse') {
if (geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola') {
assertFinitePoint(geometry.center, `${geometry.id}.center`)
if (!Number.isFinite(geometry.majorRadius) || geometry.majorRadius <= 0 || !Number.isFinite(geometry.minorRadius) || geometry.minorRadius <= 0) throw new RangeError(`${geometry.id} radii must be finite and greater than zero.`)
if (geometry.majorRadius < geometry.minorRadius || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
if ((geometry.type === 'ellipse' || geometry.type === 'arcEllipse') && geometry.majorRadius < geometry.minorRadius) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
if (!Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid axis parameters.`)
if (geometry.type !== 'ellipse' && (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle) || geometry.startAngle === geometry.endAngle)) throw new RangeError(`${geometry.id} parameter range must contain two distinct finite values.`)
}
if (geometry.type === 'arcParabola') {
assertFinitePoint(geometry.center, `${geometry.id}.center`)
if (!Number.isFinite(geometry.focal) || geometry.focal <= 0 || !Number.isFinite(geometry.rotation)) throw new RangeError(`${geometry.id} has invalid parabola parameters.`)
if (!Number.isFinite(geometry.startAngle) || !Number.isFinite(geometry.endAngle) || geometry.startAngle === geometry.endAngle) throw new RangeError(`${geometry.id} parameter range must contain two distinct finite values.`)
}
if (geometry.type === 'bspline') {
if (!Number.isSafeInteger(geometry.degree) || geometry.degree < 1) throw new RangeError(`${geometry.id} degree must be a positive integer.`)
@@ -148,7 +162,7 @@ export const sketchGeometrySignature = (geometry: SketchGeometry): string => {
export const cloneSketchGeometry = (geometry: SketchGeometry): SketchGeometry => {
validateSketchGeometry(geometry)
if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } }
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') return { ...geometry, center: { ...geometry.center } }
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola') return { ...geometry, center: { ...geometry.center } }
if (geometry.type === 'bspline') return { ...geometry, controlPoints: geometry.controlPoints.map((point) => ({ ...point })), weights: geometry.weights ? [...geometry.weights] : undefined, knots: geometry.knots ? [...geometry.knots] : undefined }
return { ...geometry, position: { ...geometry.position } }
}
@@ -530,7 +544,7 @@ const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: stri
const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => {
if (geometry.type === 'point' && point === 'position') return geometry.position
if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end
if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') && point === 'center') return geometry.center
if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola') && point === 'center') return geometry.center
diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` })
return null
}
@@ -583,7 +597,7 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n
if (isBlocked(geometry.id, blocked)) return
if (geometry.type === 'point') { geometry.position = { ...next }; return }
if (geometry.type === 'line') { if (point === 'start') geometry.start = { ...next }; else if (point === 'end') geometry.end = { ...next }; return }
if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse')) geometry.center = { ...next }
if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse' || geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola' || geometry.type === 'arcParabola')) geometry.center = { ...next }
}
const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => {
@@ -684,7 +698,7 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
const snapshot = cloneSketch(input)
const diagnostics: SketchDiagnostic[] = []
for (const geometry of snapshot.geometry) {
if (geometry.type === 'ellipse' || geometry.type === 'bspline') diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` })
if (!['point', 'line', 'circle', 'arc'].includes(geometry.type)) diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` })
}
for (const constraint of snapshot.constraints) if (constraint.type === 'weight' || constraint.type === 'snellsLaw' || constraint.type === 'internalAlignment') diagnostics.push({ code: 'UNSUPPORTED_CONSTRAINT', constraintId: constraint.id, message: `The typescript-basic solver does not solve ${constraint.type} constraint '${constraint.id}'.` })
const geometryById = new Map(snapshot.geometry.map((geometry) => [geometry.id, geometry]))
@@ -829,6 +843,8 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
if (geometry.type === 'line') return 4
if (geometry.type === 'circle') return 3
if (geometry.type === 'arc' || geometry.type === 'ellipse') return 5
if (geometry.type === 'arcEllipse' || geometry.type === 'arcHyperbola') return 7
if (geometry.type === 'arcParabola') return 6
return geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0)
}
const variableCount = snapshot.geometry.reduce((count, geometry) => count + variableCountForGeometry(geometry), 0)