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

@@ -17,9 +17,11 @@ import { runTopologyMutationReplay } from '../src/facade/topologyReplay'
import { captureNativeTopologyHistory, captureNativeTopologyHistoryStages, captureSignatureTopologyHistory, composeNativeTopologyHistoryLineage } from '../src/facade/topologyHistory'
import { createNativeOcctStepHistoryBridge, mapNativeOcctHistoryRecords } from '../src/facade/nativeHistoryProvider'
import { DirectNativeOcctHistoryProvider, NativeOcctHistoryCoordinator, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, nativeNamingAbiCapabilities, type NativeOcctHistoryProvider } from '../src/facade/nativeHistoryProtocol'
import { probeFreeCadPrivateNamingAbi } from '../src/facade/nativeNamingAbi'
import { NativeOcctHistoryWorkerProvider } from '../src/facade/nativeHistoryWorkerClient'
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher'
import { applySketchAutoConstraints, cloneSketch, createSketch, deleteSketchGeometry, dragSketchPoint, editBsplineGeometry, editSketchBspline, extendSketchLine, FREECAD_SKETCHER_CONSTRAINT_TYPES, FREECAD_SKETCHER_GEOMETRY_TYPES, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES, replaySketchEditorEvents, setSketchConstruction, SketchEditorInteractionSession, sketchGeometrySignature, solveSketch, splitSketchLine, suggestSketchAutoConstraints, trimSketchLine, validateSketchGeometry, type SketchGeometry } from '../src/facade/sketcher'
import { PARTDESIGN_PARAMETER_SPACE, partDesignParameterSpaceCoverage, validatePartDesignParameterSet } from '../src/facade/partDesignParameters'
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
import { solvePlanegcsSubset, type PlanegcsWasmModule } from '../src/facade/planegcsAdapter'
import { PlanegcsWorkerProvider } from '../src/facade/planegcsWorkerClient'
@@ -87,7 +89,7 @@ test('production facade starts from an empty document unless an application boot
assert.deepEqual(state.selectedObjectIds, [])
assert.equal(facade.runtime.mode, 'production')
assert.equal(facade.runtime.geometry.compatibility, 'none')
assert.equal(facade.runtime.naming.nativeAbi, 'not-exposed')
assert.equal(facade.runtime.naming.nativeAbi, 'freecad-private-v1-optional-worker')
facade.geometry.dispose()
})
@@ -684,7 +686,7 @@ test('native OCCT history protocol validates STEP context and isolates stale gen
resultStep: objectStep, records: [{ relation: operation === 'cut' ? 'modified' : 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }],
}),
})
assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', 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' })
assert.deepEqual(provider.capabilities(), { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', 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: { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: 'unavailable', tokenGeneration: 'forbidden', reason: 'Native module does not export the versioned FreeCAD naming callbacks.' } })
const coordinator = new NativeOcctHistoryCoordinator(() => 2)
const execution = await coordinator.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-1', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
assert.equal(execution.status, 'completed')
@@ -699,8 +701,38 @@ test('native OCCT history protocol validates STEP context and isolates stale gen
test('native naming ABI capabilities forbid synthetic FreeCAD tokens unless a provider declares native evidence', () => {
const base = { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available' as const, operations: ['cut' as const], transport: 'step-text' as const }
assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', tokenGeneration: 'forbidden' })
assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', tokenGeneration: 'native-only' })
assert.deepEqual(nativeNamingAbiCapabilities(base), { mappedNameRef: 'unavailable', stringHasher: 'unavailable', elementMap2: 'unavailable', tokenGeneration: 'forbidden' })
assert.deepEqual(nativeNamingAbiCapabilities({ ...base, naming: { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', elementMap2: 'opaque-preserved', tokenGeneration: 'native-only' } }), { mappedNameRef: 'optional', stringHasher: 'opaque-preserved', elementMap2: 'opaque-preserved', tokenGeneration: 'native-only' })
})
test('versioned FreeCAD private naming ABI is capability-gated and attaches validated native evidence', async () => {
const elementMap2 = parseElementMap2([
'BeginElementMap v1', '1 PostfixCount 1', 'Edge', 'MapCount 1', 'ElementMap 1 1 1', 'Edge',
'ChildCount 1', '1 0 1 0 0 Edge 0.54', 'NameCount 1', '$#36:2.1.37 0', 'EndMap', '',
].join('\n'))
const stringHasher = parseStringHasherTable(['StringTableStart v1 2', '-36.0 0:prefix', '-1.0 0:suffix', ''].join('\n'))
let capturedRequest: Record<string, unknown> | undefined
const module = {
occtVersion: () => '8.0.0',
booleanHistoryFromStep: (objectStep: string) => ({ provider: 'occt-native' as const, occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, resultStep: objectStep, records: [{ relation: 'modified' as const, source: 'object' as const, kind: 'edge' as const, sourceIndex: 0, resultIndex: 0 }] }),
freecadNamingAbiVersion: () => 1,
freecadNamingCapabilitiesJson: () => JSON.stringify({ schemaVersion: 1, freecadVersion: '1.1.1', sourceCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', mappedNameRef: true, stringHasher: true, elementMap2: true, operations: ['cut'] }),
freecadNamingEvidenceJson: (requestJson: string) => {
capturedRequest = JSON.parse(requestJson) as Record<string, unknown>
return JSON.stringify({ schemaVersion: 1, stageId: 'cut-native:stage:0', resultObjectId: 'cut-native', status: 'native-evidence', mappedNames: [{ kind: 'edge', resultIndex: 0, resultPersistentId: 'edge-native-0', relation: 'modified', reference: { name: 'Edge1', stringIds: [0x36] }, sourceRefs: [{ objectId: 'object', persistentId: 'Edge1' }] }], stringHasher, elementMap2 })
},
}
assert.deepEqual(probeFreeCadPrivateNamingAbi(module).availability, 'available')
const provider = new DirectNativeOcctHistoryProvider(module)
assert.deepEqual(provider.capabilities().naming, { mappedNameRef: 'available', stringHasher: 'available', elementMap2: 'available', tokenGeneration: 'native-only', abiVersion: 1, freecadVersion: '1.1.1', sourceCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', operations: ['cut'] })
const response = await provider.capture({ protocolVersion: 1, requestId: 'naming-1', documentId: 'doc', documentVersion: 2, operationId: 'cut-native', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }, new AbortController().signal)
assert.equal(response.history.namingEvidence?.status, 'native-evidence')
assert.equal(response.history.namingEvidence?.mappedNames?.[0].resultPersistentId, 'edge-native-0')
assert.equal(capturedRequest?.operation, 'cut')
assert.equal((capturedRequest?.history as { records: unknown[] }).records.length, 1)
const invalid = { ...module, freecadNamingCapabilitiesJson: () => JSON.stringify({ schemaVersion: 1, freecadVersion: '1.1.1', sourceCommit: 'wrong', mappedNameRef: true, stringHasher: true, elementMap2: true, operations: ['cut'] }) }
assert.equal(probeFreeCadPrivateNamingAbi(invalid).availability, 'unavailable')
})
test('native OCCT history coordinator reports timeout and cancellation', async () => {
@@ -713,6 +745,27 @@ test('native OCCT history coordinator reports timeout and cancellation', async (
assert.equal((await pending).status, 'cancelled')
})
test('Sketcher and supported PartDesign parameter contracts cover every declared native partition', () => {
assert.deepEqual(FREECAD_SKETCHER_GEOMETRY_TYPES, ['point', 'line', 'arc', 'circle', 'ellipse', 'arcEllipse', 'arcHyperbola', 'arcParabola', 'bspline'])
assert.equal(FREECAD_SKETCHER_CONSTRAINT_TYPES.length, 19)
assert.equal(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length, 10)
assert.equal(new Set(FREECAD_SKETCHER_CONSTRAINT_TYPES).size, FREECAD_SKETCHER_CONSTRAINT_TYPES.length)
assert.equal(new Set(FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES).size, FREECAD_SKETCHER_INTERNAL_ALIGNMENT_TYPES.length)
assert.deepEqual(partDesignParameterSpaceCoverage(), { schemaVersion: 1, baseline: 'FreeCAD 1.1.1', families: 21, properties: 202, partitions: 72, duplicateTypeIds: 0, familiesWithoutPartitions: [] })
for (const family of PARTDESIGN_PARAMETER_SPACE) {
assert.equal(new Set(family.properties).size, family.properties.length, `${family.typeId} has duplicate properties`)
assert.equal(new Set(family.partitions.map((entry) => entry.id)).size, family.partitions.length, `${family.typeId} has duplicate partitions`)
for (const partition of family.partitions) for (const propertyName of Object.keys(partition.values)) assert.ok(family.properties.includes(propertyName), `${family.typeId}.${partition.id} uses undeclared ${propertyName}`)
}
const validPad = validatePartDesignParameterSet('PartDesign::Pad', { Profile: 'Sketch', Length: 10, Length2: 5, Type: 'TwoLengths', Type2: 'Dimension', SideType: 'Two sides', UpToFace: null, UpToFace2: null, TaperAngle: 0, TaperAngle2: 0, Reversed: false, Midplane: false, ReferenceAxis: null, AlongSketchNormal: true, UseCustomVector: false, Direction: { x: 0, y: 0, z: 1 }, Offset: 0, Offset2: 0 })
assert.equal(validPad.valid, true)
assert.equal(validatePartDesignParameterSet('PartDesign::Pad', { ...Object.fromEntries(PARTDESIGN_PARAMETER_SPACE.find((entry) => entry.typeId === 'PartDesign::Pad')!.properties.map((name) => [name, null])), Type: 'TwoLengths', SideType: 'Two sides', Length: 10, Length2: 5, Midplane: true, TaperAngle: 0, TaperAngle2: 0 }).valid, false)
assert.equal(validatePartDesignParameterSet('PartDesign::Hole', { Diameter: 5, DepthType: 'ThroughAll', Direction: { x: 0, y: 0, z: 1 }, HoleCutType: 'None', Threaded: false, ModelThread: true }, { requireComplete: false }).issues.some((issue) => issue.propertyName === 'ModelThread'), true)
assert.equal(validatePartDesignParameterSet('PartDesign::Draft', { Base: 'Pad', Angle: 5, Reversed: false }, { requireComplete: false }).valid, true)
assert.equal(validatePartDesignParameterSet('PartDesign::LinearPattern', { Originals: ['Pad'], Occurrences: 2, Mode: 'Extent', Length: 10, Direction: 'Horizontal' }, { requireComplete: false }).valid, true)
})
test('native OCCT Worker provider preserves versioned request and response context', async () => {
const messageListeners = new Set<(event: MessageEvent) => void>()
const errorListeners = new Set<(event: ErrorEvent) => void>()
@@ -4666,6 +4719,33 @@ test('FCStd Sketcher codec round-trips ellipse and B-spline knot internal geomet
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: invalidEllipseIndex }] }), /invalid ellipse internal index/)
})
test('FCStd Sketcher codec round-trips all conic arc geometry and private internal alignments', () => {
const sketch = createSketch('Sketch', [
{ id: 'arc-ellipse', type: 'arcEllipse', center: { x: 1, y: 2 }, majorRadius: 4, minorRadius: 2, rotation: 0.2, startAngle: -1, endAngle: 1 },
{ id: 'arc-hyperbola', type: 'arcHyperbola', center: { x: 8, y: 2 }, majorRadius: 3, minorRadius: 1.5, rotation: 0.4, startAngle: -0.8, endAngle: 0.8 },
{ id: 'arc-parabola', type: 'arcParabola', center: { x: 14, y: 2 }, focal: 2, rotation: 0.6, startAngle: -1.2, endAngle: 1.2 },
], [
{ id: 'ellipse-major', type: 'internalAlignment', geometryId: 'arc-ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-major' },
{ id: 'hyperbola-major', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-major' },
{ id: 'hyperbola-minor', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-minor' },
{ id: 'hyperbola-focus', type: 'internalAlignment', geometryId: 'arc-hyperbola', internalGeometryIndex: 0, alignmentType: 'hyperbola-focus' },
{ id: 'parabola-focus', type: 'internalAlignment', geometryId: 'arc-parabola', internalGeometryIndex: 0, alignmentType: 'parabola-focus' },
{ id: 'parabola-axis', type: 'internalAlignment', geometryId: 'arc-parabola', internalGeometryIndex: 0, alignmentType: 'parabola-focal-axis' },
])
const document = recomputeDocumentFixture()
document.objects = [{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch }]
const archive = serializeFcstdMetadataArchive(document)
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(written, /type="Part::GeomArcOfEllipse"/)
assert.match(written, /type="Part::GeomArcOfHyperbola"/)
assert.match(written, /type="Part::GeomArcOfParabola"/)
assert.deepEqual([...written.matchAll(/InternalAlignmentType="(\d+)"/g)].map((match) => Number(match[1])), [1, 5, 6, 7, 8, 11])
const restored = inspectFcstdArchive(archive).objects[0].sketch
assert.ok(restored)
assert.deepEqual(restored.geometry, sketch.geometry)
assert.deepEqual(restored.constraints, sketch.constraints.map((constraint) => ({ ...constraint, driving: true })))
})
test('FCStd Sketcher codec round-trips native external Edge/Vertex projections and stable TopoRefs', () => {
const sketch = createSketch('Sketch', [{ id: 'profile', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 } }])
const edgeSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'edge' as const, persistentId: 'Edge1', topologyVersion: 7, generation: 4, status: 'stable' as const, signature: 'edge-signature', candidates: ['Edge1'] }