5737 lines
466 KiB
TypeScript
5737 lines
466 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { strToU8, unzipSync, zipSync } from 'fflate'
|
|
import { createMockFacade } from '../src/facade/mockFacade'
|
|
import { createProductionFacade } from '../src/facade/productionFacade'
|
|
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
|
|
import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
|
|
import { DependencyGraph } from '../src/facade/dependencyGraph'
|
|
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
|
import { createAnalyticSubshapeRefs, createEdgeSubshapeRefs, createSubshapeRefs, createTopologyAdjacency, createVertexSubshapeRefs, matchSubshapes, matchSubshapesWithAdjacency, signatureForAnalyticEdge, signatureForAnalyticFace, signatureForAnalyticVertex, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
|
|
import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveDocumentTopologyReference, resolveTopoRef, serializeTopoRef, validateTopologySnapshotForPersistence } from '../src/facade/topologyReferences'
|
|
import { createElementMapSnapshot, formatElementMapName, parseElementMapName } from '../src/facade/elementMap'
|
|
import { appendElementMap2NameEntry, createElementMap2MultiStageNameMapping, createElementMap2NameEntry, createElementMap2NameToken, elementMap2NameTokenToReference, formatElementMap2NameToken, parseElementMap2, parseElementMap2MultiStageNameMapping, validateElementMap2, validateElementMap2MultiStageNameMapping, writeElementMap2, writeElementMap2MultiStageNameMapping } from '../src/facade/elementMap2'
|
|
import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from '../src/facade/stringHasher'
|
|
import { createFinalShapeOnlyNamingEvidence, createNativeStageNamingEvidence, validateNativeNamingEvidence } from '../src/facade/nativeNamingEvidence'
|
|
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 { 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 { 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'
|
|
import { validateDraftInput, validateEllipsoidInput, validateExtrudeInput, validateThicknessInput, validateWedgeInput } from '../src/facade/geometryRuntime'
|
|
import { resolveMeshSubshape, resolveScreenBoxSelection } from '../src/facade/threeViewport'
|
|
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
|
|
import { decodeFcstdPropertyValue, extractFcstdShapeResources, inspectFcstdArchive, instantiateFcstdShapeResource, rewriteFcstdMetadataArchive, serializeFcstdMetadataArchive, serializeStringHasherTableResource, storeFcstdShapeResources } from '../src/facade/fcstd'
|
|
import { ATTACHMENT_MAP_MODES, composeAttachmentPlacement, identityAttachmentOffset, validateAttachmentMapMode, validateAttachmentOffset, validateAttachmentSupport } from '../src/facade/attachment'
|
|
import { redirectBodyTips, resolveBodyTip } from '../src/facade/bodyRules'
|
|
import { assertShapeHandleIntegrity, BitbybitGeometryRuntime, classifyPlanarProfile, collectGeometryImportText, MAX_GEOMETRY_IMPORT_TEXT_BYTES, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validateGeometryFileImport, validateGrooveInput, validateLoftInput, validateMirrorInput, validatePadInput, validatePipeInput, validatePlacementInput, validatePlanarProfile, validatePrismInput, validateRevolutionInput, validateSphereInput, validateTorusInput } from '../src/facade/geometryRuntime'
|
|
import type { DocumentObjectSnapshot, DocumentSnapshot, MultiTransformValue, NativeTopologyHistoryInput, ObjectTopologySnapshot, PlanarProfile, Point3, ShapeHandle, SubshapeTopology, TopoRefValue } from '../src/facade/types'
|
|
|
|
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
|
|
id: 'doc-recompute',
|
|
label: 'Recompute fixture',
|
|
version: 1,
|
|
dirty: true,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [
|
|
{ id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
|
|
{ id: 'child', label: 'Child', type: 'feature', state: 'dirty' },
|
|
],
|
|
objects: [
|
|
{ id: 'root', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'child', typeId: 'Part::Feature', properties: [] },
|
|
],
|
|
dependencies: edges,
|
|
recompute: {
|
|
generation: 0,
|
|
status: 'idle',
|
|
objectStates: { root: 'touched', child: 'touched' },
|
|
dirtyObjects: ['root'],
|
|
order: [],
|
|
errors: [],
|
|
},
|
|
})
|
|
|
|
test('facade exposes a stable initial document projection', () => {
|
|
const facade = createMockFacade()
|
|
const state = facade.getState()
|
|
assert.equal(state.apiVersion, '0.1')
|
|
assert.equal(state.document.id, 'doc-pump-housing')
|
|
assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body')
|
|
assert.equal(facade.gui.workbench.getActive(), 'Part Design')
|
|
assert.deepEqual(facade.runtime, {
|
|
mode: 'mock',
|
|
geometry: { engine: 'bitbybit-occt', execution: 'facade-runtime', compatibility: 'demo-bootstrap' },
|
|
nativeHistory: { transport: 'optional-worker', availability: 'not-configured' },
|
|
naming: { nativeAbi: 'not-exposed', preservation: 'opaque-preserved' },
|
|
persistence: 'sqlite-opfs-with-memory-fallback',
|
|
viewport: 'three-webgl2',
|
|
})
|
|
assert.equal(facade.geometry.capabilities().provider, 'Bitbybit OCCT')
|
|
assert.deepEqual({ shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount, releasedShapeCount: facade.geometry.capabilities().releasedShapeCount }, { shapeCount: 0, kernelReferenceCount: 0, releasedShapeCount: 0 })
|
|
})
|
|
|
|
test('production facade starts from an empty document unless an application bootstrap is supplied', () => {
|
|
const facade = createProductionFacade()
|
|
const state = facade.getState()
|
|
assert.equal(state.document.id, 'doc-untitled')
|
|
assert.equal(state.document.label, 'Untitled document')
|
|
assert.deepEqual(state.document.tree, [])
|
|
assert.deepEqual(state.document.objects, [])
|
|
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')
|
|
facade.geometry.dispose()
|
|
})
|
|
|
|
test('geometry boundary validates dimensions before invoking OCCT', () => {
|
|
assert.throws(() => validateBoxInput({ width: 0, length: 2, height: 3, documentId: 'doc', documentVersion: 1 }), /width/)
|
|
assert.throws(() => validateBoxInput({ width: 1, length: 2, height: 3, center: [0, Number.NaN, 0], documentId: 'doc', documentVersion: 1 }), /center/)
|
|
assert.doesNotThrow(() => validateBoxInput({ width: 1, length: 2, height: 3, documentId: 'doc', documentVersion: 0 }))
|
|
assert.throws(() => validateCylinderInput({ radius: 1, height: 2, direction: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /direction/)
|
|
assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/)
|
|
assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentId: 'doc', documentVersion: 1 }), /radius/)
|
|
assert.doesNotThrow(() => validateTorusInput({ majorRadius: 10, minorRadius: 2, direction: [0, 0, 1], angle: 270, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateTorusInput({ majorRadius: 10, minorRadius: 0, documentId: 'doc', documentVersion: 1 }), /minorRadius/)
|
|
assert.doesNotThrow(() => validatePrismInput({ polygon: 6, circumradius: 2, height: 10, firstAngle: 10, secondAngle: -5, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validatePrismInput({ polygon: 2, circumradius: 2, height: 10, documentId: 'doc', documentVersion: 1 }), /polygon/)
|
|
assert.throws(() => validatePrismInput({ polygon: 6, circumradius: 2, height: 10, firstAngle: 90, documentId: 'doc', documentVersion: 1 }), /firstAngle/)
|
|
assert.doesNotThrow(() => validateWedgeInput({ xmin: 0, ymin: 0, zmin: 0, z2min: 0, x2min: 0, xmax: 10, ymax: 10, zmax: 10, z2max: 8, x2max: 8, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateWedgeInput({ xmin: 0, ymin: 0, zmin: 0, z2min: 3, x2min: 0, xmax: 10, ymax: 10, zmax: 10, z2max: 2, x2max: 8, documentId: 'doc', documentVersion: 1 }), /z2max/)
|
|
assert.doesNotThrow(() => validateEllipsoidInput({ radius1: 2, radius2: 4, radius3: 0, angle1: -90, angle2: 90, angle3: 360, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateEllipsoidInput({ radius1: 2, radius2: 4, radius3: -1, documentId: 'doc', documentVersion: 1 }), /radius3/)
|
|
assert.throws(() => validateEllipsoidInput({ radius1: 2, radius2: 4, angle1: 10, angle2: 5, documentId: 'doc', documentVersion: 1 }), /angle2/)
|
|
assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentId: 'doc', documentVersion: 2 }), /rotationAxis/)
|
|
assert.throws(() => validateMirrorInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, origin: [0, 0, 0], normal: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /normal/)
|
|
const grooveShape: ShapeHandle = { id: 'base', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }
|
|
const grooveProfile: PlanarProfile = { outer: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] }
|
|
assert.doesNotThrow(() => validateGrooveInput({ base: grooveShape, profile: grooveProfile, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateGrooveInput({ base: { ...grooveShape, documentId: 'other' }, profile: grooveProfile, documentId: 'doc', documentVersion: 1 }), /another document/)
|
|
assert.throws(() => validateGrooveInput({ base: grooveShape, profile: grooveProfile, axisDirection: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /axisDirection/)
|
|
const loftSections: PlanarProfile[] = [grooveProfile, { outer: grooveProfile.outer.map(([x, y]): [number, number, number] => [x, y, 5]) }]
|
|
assert.doesNotThrow(() => validateLoftInput({ sections: loftSections, documentId: 'doc', documentVersion: 1 }))
|
|
assert.doesNotThrow(() => validateLoftInput({ sections: loftSections, mode: 'additive', base: grooveShape, documentId: 'doc', documentVersion: 1 }))
|
|
assert.doesNotThrow(() => validateLoftInput({ sections: [...loftSections, { outer: grooveProfile.outer.map(([x, y]): [number, number, number] => [x, y, 10]) }], ruled: true, closed: true, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateLoftInput({ sections: [grooveProfile], documentId: 'doc', documentVersion: 1 }), /at least two/)
|
|
assert.throws(() => validateLoftInput({ sections: loftSections, closed: true, documentId: 'doc', documentVersion: 1 }), /at least three/)
|
|
assert.throws(() => validateLoftInput({ sections: loftSections, mode: 'subtractive', documentId: 'doc', documentVersion: 1 }), /requires a base/)
|
|
assert.throws(() => validateLoftInput({ sections: loftSections, mode: 'additive', base: { ...grooveShape, documentId: 'other' }, documentId: 'doc', documentVersion: 1 }), /another document/)
|
|
assert.throws(() => validateLoftInput({ sections: [{ ...grooveProfile, holes: [[[0.2, 0.2, 0], [0.4, 0.2, 0], [0.3, 0.4, 0]]] }, loftSections[1]], documentId: 'doc', documentVersion: 1 }), /contains holes/)
|
|
assert.doesNotThrow(() => validatePipeInput({ profile: grooveProfile, path: [[0, 0, 0], [0, 0, 5], [5, 0, 5]], documentId: 'doc', documentVersion: 1 }))
|
|
assert.doesNotThrow(() => validatePipeInput({ profile: grooveProfile, path: [[0, 0, 0], [0, 0, 5]], mode: 'subtractive', base: grooveShape, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validatePipeInput({ profile: grooveProfile, path: [[0, 0, 0]], documentId: 'doc', documentVersion: 1 }), /at least two/)
|
|
assert.throws(() => validatePipeInput({ profile: grooveProfile, path: [[0, 0, 0], [0, 0, 0]], documentId: 'doc', documentVersion: 1 }), /duplicate/)
|
|
assert.throws(() => validatePipeInput({ profile: grooveProfile, path: [[0, 0, 0], [0, 0, 5]], mode: 'additive', documentId: 'doc', documentVersion: 1 }), /requires a base/)
|
|
for (const format of ['step', 'iges', 'brep'] as const) assert.doesNotThrow(() => validateGeometryFileImport({ format, text: 'payload', documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateGeometryFileImport({ format: 'step', text: ' ', documentId: 'doc', documentVersion: 1 }), /non-empty/)
|
|
assert.throws(() => validateGeometryFileImport({ format: 'stl' as never, text: 'payload', documentId: 'doc', documentVersion: 1 }), /Unsupported geometry import format/)
|
|
assert.throws(() => validateGeometryFileImport({ format: 'step', text: 'x'.repeat(MAX_GEOMETRY_IMPORT_TEXT_BYTES + 1), documentId: 'doc', documentVersion: 1 }), /exceeds/)
|
|
})
|
|
|
|
test('geometry handles reject forged document ownership', () => {
|
|
const expected: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-one', documentVersion: 4 }
|
|
assert.doesNotThrow(() => assertShapeHandleIntegrity({ ...expected }, expected))
|
|
assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentVersion: 5 }, expected), /integrity/)
|
|
assert.throws(() => assertShapeHandleIntegrity({ ...expected, id: 'shape-two' }, expected), /integrity/)
|
|
assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentId: 'doc-two' }, expected), /integrity/)
|
|
})
|
|
|
|
test('boolean geometry rejects cross-document and future-version operands', () => {
|
|
const first: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-one', documentVersion: 2 }
|
|
const second: ShapeHandle = { ...first, id: 'shape-two', documentVersion: 3 }
|
|
assert.doesNotThrow(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 3 }))
|
|
assert.throws(() => validateBooleanUnionInput({ shapes: [first, { ...second, documentId: 'doc-two' }], documentId: 'doc-one', documentVersion: 3 }), /another document/)
|
|
assert.throws(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 2 }), /newer/)
|
|
assert.throws(() => validateBooleanUnionInput({ shapes: [first], documentId: 'doc-one', documentVersion: 2 }), /at least 2/)
|
|
assert.throws(() => validateFilletInput({ base: first, radius: 0, documentId: 'doc-one', documentVersion: 2 }), /radius/)
|
|
assert.throws(() => validateChamferInput({ base: first, distance: 1, indexes: [-1], documentId: 'doc-one', documentVersion: 2 }), /indexes/)
|
|
})
|
|
|
|
test('feature profiles reject degenerate and non-planar geometry', () => {
|
|
const rectangle = { outer: [[-1, 0, -1], [1, 0, -1], [1, 0, 1], [-1, 0, 1]] as [number, number, number][] }
|
|
assert.doesNotThrow(() => validatePlanarProfile(rectangle))
|
|
assert.doesNotThrow(() => validatePadInput({ profile: rectangle, length: 4, documentId: 'doc', documentVersion: 1 }))
|
|
assert.doesNotThrow(() => validateExtrudeInput({ profile: rectangle, length: 4, direction: [0, 0, 1], documentId: 'doc', documentVersion: 1 }))
|
|
const shapeHandle: ShapeHandle = { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }
|
|
assert.throws(() => validateDraftInput({ base: shapeHandle, angle: 0, documentId: 'doc', documentVersion: 1 }), /angle/)
|
|
assert.throws(() => validateDraftInput({ base: shapeHandle, angle: 5, direction: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /direction/)
|
|
assert.doesNotThrow(() => validateDraftInput({ base: shapeHandle, angle: 5, direction: [0, 1, 0], documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validateThicknessInput({ base: shapeHandle, offset: 0, documentId: 'doc', documentVersion: 1 }), /offset/)
|
|
assert.doesNotThrow(() => validateThicknessInput({ base: shapeHandle, offset: 1, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validatePlanarProfile({ outer: [[0, 0, 0], [1, 0, 0], [2, 0, 0]] }), /collinear/)
|
|
assert.throws(() => validatePlanarProfile({ outer: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0.1, 1]] }), /coplanar/)
|
|
assert.throws(() => validateRevolutionInput({ profile: rectangle, axisDirection: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /axisDirection/)
|
|
})
|
|
|
|
test('feature profile classifier distinguishes closed, open, multi-ring and self-intersecting profiles', () => {
|
|
const outer = [[0, 0, 0], [6, 0, 0], [6, 6, 0], [0, 6, 0]] as [number, number, number][]
|
|
const hole = [[2, 2, 0], [2, 4, 0], [4, 4, 0], [4, 2, 0]] as [number, number, number][]
|
|
assert.deepEqual(classifyPlanarProfile({ outer }), { status: 'closed', ringCount: 1, selfIntersections: 0 })
|
|
assert.deepEqual(classifyPlanarProfile({ outer, closed: false }), { status: 'open', ringCount: 1, selfIntersections: 0 })
|
|
assert.deepEqual(classifyPlanarProfile({ outer, holes: [hole] }), { status: 'multi-ring', ringCount: 2, selfIntersections: 0 })
|
|
const bowTie = { outer: [[0, 0, 0], [4, 4, 0], [0, 4, 0], [4, 0, 0]] as [number, number, number][] }
|
|
const crossed = classifyPlanarProfile(bowTie)
|
|
assert.equal(crossed.status, 'self-intersecting')
|
|
assert.equal(crossed.selfIntersections, 1)
|
|
const outsideHole = [[8, 8, 0], [8, 9, 0], [9, 9, 0], [9, 8, 0]] as [number, number, number][]
|
|
assert.equal(classifyPlanarProfile({ outer, holes: [outsideHole] }).status, 'invalid-nesting')
|
|
assert.throws(() => validatePlanarProfile({ outer, closed: false }), /open/)
|
|
assert.throws(() => validatePadInput({ profile: bowTie, length: 4, documentId: 'doc', documentVersion: 1 }), /self-intersecting/)
|
|
assert.throws(() => validatePadInput({ profile: { outer, holes: [outsideHole] }, length: 4, documentId: 'doc', documentVersion: 1 }), /contained directly/)
|
|
})
|
|
|
|
test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () => {
|
|
const shape: ShapeHandle = { id: 'shape-test', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 7 }
|
|
const mesh = normalizeBitbybitMesh(shape, {
|
|
faceList: [{ faceIndex: 1, vertexCoord: [0, 0, 0, 2, 0, 0, 0, 3, 0], normalCoord: [0, 0, 1, 0, 0, 1, 0, 0, 1], triIndexes: [0, 1, 2], numberOfTriangles: 1 }],
|
|
edgeList: [], pointsList: [],
|
|
} as never)
|
|
assert.deepEqual([...mesh.positions], [0, 0, 0, 2, 0, 0, 0, 3, 0])
|
|
assert.deepEqual([...mesh.indices], [0, 1, 2])
|
|
assert.deepEqual(mesh.bounds, { min: [0, 0, 0], max: [2, 3, 0] })
|
|
assert.equal(mesh.topologyVersion, 7)
|
|
assert.equal(mesh.subshapes?.length, 1)
|
|
assert.equal(mesh.subshapes?.[0].status, 'stable')
|
|
assert.deepEqual(mesh.subshapeRanges?.map(({ startTriangle, triangleCount, ref }) => ({ startTriangle, triangleCount, persistentId: ref.persistentId })), [{ startTriangle: 0, triangleCount: 1, persistentId: mesh.subshapes?.[0].persistentId }])
|
|
assert.equal(mesh.subshapeEdges?.length, 3)
|
|
assert.equal(mesh.subshapeVertices?.length, 3)
|
|
assert.equal(mesh.subshapeEdges?.every((entry) => entry.ref.kind === 'edge' && entry.start.length === 3 && entry.end.length === 3), true)
|
|
assert.equal(mesh.subshapeVertices?.every((entry) => entry.ref.kind === 'vertex' && entry.position.length === 3), true)
|
|
assert.throws(() => normalizeBitbybitMesh(shape, {
|
|
faceList: [{ faceIndex: 2, vertexCoord: [0, 0, 0], normalCoord: [], triIndexes: [0, 1, 0], numberOfTriangles: 1 }],
|
|
edgeList: [], pointsList: [],
|
|
} as never), /out-of-range/)
|
|
})
|
|
|
|
test('Bitbybit analytic mesh normalization uses OCCT edges and vertices instead of triangulation diagonals', () => {
|
|
const shape: ShapeHandle = { id: 'shape-brep-pick', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 4 }
|
|
const corners = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [0, 2, 0]] as [number, number, number][]
|
|
const mesh = normalizeBitbybitMesh(shape, {
|
|
faceList: [{ faceIndex: 1, vertexCoord: corners.flat(), normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3], numberOfTriangles: 2 }],
|
|
edgeList: corners.map((start, index) => ({ edgeIndex: index + 1, middlePoint: start, vertexCoord: [start, corners[(index + 1) % corners.length]] })),
|
|
pointsList: corners,
|
|
} as never, 1e-5, {
|
|
faces: [{ surfaceType: 'Plane', area: 4, centroid: [1, 1, 0], adjacentFaceCount: 0, edgeCount: 4 }],
|
|
edges: corners.map(() => ({ curveType: 'Line', length: 2, adjacentFaceTypes: ['Plane'] })),
|
|
vertices: corners.map((point) => ({ point, incidentEdgeCount: 2 })),
|
|
})
|
|
assert.equal(mesh.subshapeRanges?.[0]?.ref.signature?.includes('surface=plane'), true)
|
|
assert.equal(mesh.subshapeEdges?.length, 4)
|
|
assert.equal(mesh.subshapeEdges?.every((entry) => entry.ref.signature?.includes('curve=line')), true)
|
|
assert.equal(mesh.subshapeEdges?.some((entry) => JSON.stringify(entry.start) === '[0,0,0]' && JSON.stringify(entry.end) === '[2,2,0]'), false)
|
|
assert.equal(mesh.subshapeVertices?.length, 4)
|
|
})
|
|
|
|
test('mesh triangle picking resolves stable TopoRefs and rejects unmapped triangles', () => {
|
|
const shape: ShapeHandle = { id: 'shape-pick', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }
|
|
const mesh = normalizeBitbybitMesh(shape, {
|
|
faceList: [
|
|
{ faceIndex: 1, vertexCoord: [0, 0, 0, 1, 0, 0, 0, 1, 0], normalCoord: [], triIndexes: [0, 1, 2], numberOfTriangles: 1 },
|
|
{ faceIndex: 2, vertexCoord: [0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1], normalCoord: [], triIndexes: [0, 1, 2, 1, 3, 2], numberOfTriangles: 2 },
|
|
],
|
|
edgeList: [], pointsList: [],
|
|
} as never)
|
|
assert.equal(resolveMeshSubshape(mesh, 0)?.persistentId, mesh.subshapes?.[0].persistentId)
|
|
assert.equal(resolveMeshSubshape(mesh, 1)?.persistentId, mesh.subshapes?.[1].persistentId)
|
|
assert.equal(resolveMeshSubshape(mesh, 2)?.persistentId, mesh.subshapes?.[1].persistentId)
|
|
assert.equal(resolveMeshSubshape(mesh, 3), null)
|
|
assert.equal(resolveMeshSubshape(mesh, -1), null)
|
|
})
|
|
|
|
test('viewport box selection distinguishes enclosed window objects from crossing objects', () => {
|
|
const candidates = [
|
|
{ objectId: 'inside', bounds: { left: 20, top: 20, right: 40, bottom: 40 } },
|
|
{ objectId: 'crossing', bounds: { left: 80, top: 80, right: 120, bottom: 120 } },
|
|
{ objectId: 'outside', bounds: { left: 140, top: 140, right: 160, bottom: 160 } },
|
|
]
|
|
const selection = { left: 10, top: 10, right: 100, bottom: 100 }
|
|
assert.deepEqual(resolveScreenBoxSelection(candidates, selection, 'window'), ['inside'])
|
|
assert.deepEqual(resolveScreenBoxSelection(candidates, selection, 'crossing'), ['inside', 'crossing'])
|
|
})
|
|
|
|
test('project schema is versioned and covers the FreeCAD document graph', () => {
|
|
assert.equal(PROJECT_SCHEMA_VERSION, 6)
|
|
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6])
|
|
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources', 'document_checkpoints']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
|
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
|
|
})
|
|
|
|
test('project migrations are ordered, skip applied versions and roll back atomically', () => {
|
|
const calls: string[] = []
|
|
const applied = new Set([1])
|
|
const transaction = {
|
|
begin: () => { calls.push('begin') },
|
|
isApplied: (version: number) => applied.has(version),
|
|
execute: (sql: string) => { calls.push(`execute:${sql}`); if (sql === 'broken') throw new Error('migration failed') },
|
|
markApplied: (version: number) => { calls.push(`mark:${version}`); applied.add(version) },
|
|
commit: () => { calls.push('commit') },
|
|
rollback: () => { calls.push('rollback') },
|
|
}
|
|
assert.deepEqual(runProjectSchemaMigrations(transaction, [{ version: 2, sql: 'second' }, { version: 1, sql: 'first' }, { version: 3, sql: 'third' }], 123), [2, 3])
|
|
assert.deepEqual(calls, ['begin', 'execute:second', 'mark:2', 'execute:third', 'mark:3', 'commit'])
|
|
calls.length = 0
|
|
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 4, sql: 'fourth' }, { version: 5, sql: 'broken' }], 124), /migration failed/)
|
|
assert.deepEqual(calls, ['begin', 'execute:fourth', 'mark:4', 'execute:broken', 'rollback'])
|
|
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 6, sql: 'one' }, { version: 6, sql: 'duplicate' }]), /duplicate/)
|
|
})
|
|
|
|
test('quantity expressions convert units and reject incompatible dimensions', () => {
|
|
const result = evaluateQuantityExpression('1 in + 2 mm')
|
|
assert.equal(result.value.dimension, 'length')
|
|
assert.ok(Math.abs(result.value.value - 27.4) < 1e-9)
|
|
const variables = new Map([['pad.Length', { value: 42, dimension: 'length' as const }]])
|
|
const reference = evaluateQuantityExpression('pad.Length * 2', variables)
|
|
assert.deepEqual(reference.references, ['pad.Length'])
|
|
assert.equal(reference.value.value, 84)
|
|
assert.throws(() => evaluateQuantityExpression('1 in + 90 deg'), /Cannot add/)
|
|
assert.throws(() => evaluateQuantityExpression('1 mm / 0'), /Division by zero/)
|
|
assert.equal(evaluateQuantityExpression('2 + 3').value.value, 5)
|
|
assert.equal(quantityFromNumber(7).dimension, 'dimensionless')
|
|
assert.equal(evaluateQuantityExpression('50%').value.dimension, 'percent')
|
|
assert.ok(Math.abs(evaluateQuantityExpression('sin(90 deg)').value.value - 1) < 1e-12)
|
|
assert.equal(evaluateQuantityExpression('max(2 mm, 1 cm)').value.value, 10)
|
|
assert.equal(evaluateQuantityExpression('clamp(15, 0, 10)').value.value, 10)
|
|
assert.equal(evaluateQuantityExpression('round(2.6)').value.value, 3)
|
|
assert.equal(evaluateQuantityExpression('2 mm ^ 2').value.dimension, 'area')
|
|
assert.equal(evaluateQuantityExpression('2 mm ^ 3').value.dimension, 'volume')
|
|
assert.equal(evaluateQuantityExpression('1 cm2 + 100 mm2').value.value, 200)
|
|
assert.equal(evaluateQuantityExpression('2 ^ 3').value.value, 8)
|
|
assert.throws(() => evaluateQuantityExpression('sin(2 mm)'), /angle or dimensionless/)
|
|
assert.throws(() => evaluateQuantityExpression('unknown(1)'), /Unknown expression function/)
|
|
assert.throws(() => evaluateQuantityExpression('2 mm ^ 0.5'), /dimensionless integer/)
|
|
})
|
|
|
|
test('topology signatures are independent of transient face indexes and flag ambiguity', () => {
|
|
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const signature = signatureForFace(square)
|
|
const sameSignature = signatureForFace({ ...square, triIndexes: [3, 0, 1, 1, 2, 3] })
|
|
assert.equal(signature.hash, sameSignature.hash)
|
|
const first = createSubshapeRefs('shape-a', 1, [square])
|
|
assert.equal(first.refs[0].status, 'stable')
|
|
const duplicate = createSubshapeRefs('shape-b', 2, [square, square])
|
|
assert.equal(duplicate.refs[0].status, 'ambiguous')
|
|
assert.deepEqual(duplicate.refs[0].candidates, [duplicate.refs[0].persistentId, duplicate.refs[1].persistentId])
|
|
assert.notEqual(duplicate.refs[0].persistentId, duplicate.refs[1].persistentId)
|
|
const matches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }])
|
|
assert.equal(matches[0].status, 'ambiguous')
|
|
const shifted = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 10 : value) }
|
|
const previousShifted = createSubshapeRefs('shape-shifted', 1, [shifted])
|
|
const currentSingle = createSubshapeRefs('shape-current', 2, [square])
|
|
const deleted = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }, { ref: previousShifted.refs[0], signature: previousShifted.signatures[0] }], [{ ref: currentSingle.refs[0], signature: currentSingle.signatures[0] }])
|
|
assert.equal(deleted.at(-1)?.status, 'deleted')
|
|
|
|
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
|
|
const moved = createSubshapeRefs('shape-moved', 3, [translated])
|
|
const movedMatches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: moved.refs[0], signature: moved.signatures[0] }])
|
|
assert.equal(movedMatches[0].status, 'stable')
|
|
assert.equal(movedMatches[0].previousId, first.refs[0].persistentId)
|
|
})
|
|
|
|
test('versioned TopoRefs serialize without transient indexes and resolve migration states', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const previous = createSubshapeRefs('shape-old', 4, [face])
|
|
const record = createPersistedTopoRef('pad', previous.refs[0], 7)
|
|
const restored = parseTopoRef(serializeTopoRef(record))
|
|
assert.deepEqual(restored, record)
|
|
assert.equal(resolveTopoRef(restored, previous.refs).status, 'resolved')
|
|
assert.throws(() => parseTopoRef(JSON.stringify({ ...record, faceIndex: 1 })), /must not persist transient faceIndex/)
|
|
|
|
const split = createSubshapeRefs('shape-new', 5, [face, face])
|
|
const migration = migrateTopoRefs('pad', [{ ref: previous.refs[0], signature: previous.signatures[0] }], split.refs.map((ref, index) => ({ ref, signature: split.signatures[index] })), 8)
|
|
assert.equal(migration.counts.ambiguous, 2)
|
|
assert.equal(migration.counts.deleted, 0)
|
|
assert.ok(migration.records.every((candidate) => candidate.status === 'ambiguous' && candidate.candidates && candidate.candidates.length >= 2))
|
|
assert.equal(resolveTopoRef(restored, split.refs).status, 'ambiguous')
|
|
})
|
|
|
|
test('signature topology history conservatively classifies boolean fallback relations', () => {
|
|
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
|
|
const triangle = { vertexCoord: [0, 0, 1, 1, 0, 1, 0, 1, 1], normalCoord: [], triIndexes: [0, 1, 2] }
|
|
const before = createSubshapeRefs('base-shape', 1, [square])
|
|
const after = createSubshapeRefs('result-shape', 2, [translated, triangle])
|
|
const history = captureSignatureTopologyHistory('boolean-1', [{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }], after.refs.map((ref, index) => ({ ref, signature: after.signatures[index] })))
|
|
assert.equal(history.provider, 'signature-fallback')
|
|
assert.equal(history.counts.modified, 1)
|
|
assert.equal(history.counts.generated, 1)
|
|
|
|
const deleted = captureSignatureTopologyHistory('boolean-2', [{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }], [])
|
|
assert.equal(deleted.counts.deleted, 1)
|
|
const ambiguous = captureSignatureTopologyHistory('boolean-3', [
|
|
{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) },
|
|
{ objectId: 'tool', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) },
|
|
], before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })))
|
|
assert.equal(ambiguous.counts.ambiguous, 1)
|
|
assert.deepEqual(new Set(ambiguous.relations[0].candidates?.map((candidate) => candidate.sourceObjectId)), new Set(['base', 'tool']))
|
|
})
|
|
|
|
test('multi-input topology history preserves source ownership for cross-body and pattern results', () => {
|
|
const baseFace = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const toolFace = { vertexCoord: [0, 0, 3, 1, 0, 3, 1, 1, 3, 0, 1, 3], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const base = createSubshapeRefs('base-shape', 1, [baseFace])
|
|
const tool = createSubshapeRefs('tool-shape', 1, [toolFace])
|
|
const result = createSubshapeRefs('pattern-result', 2, [baseFace, toolFace, { ...baseFace, vertexCoord: baseFace.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }])
|
|
const history = captureSignatureTopologyHistory('pattern-cross-body', [
|
|
{ objectId: 'body-a', entries: base.refs.map((ref, index) => ({ ref, signature: base.signatures[index] })) },
|
|
{ objectId: 'body-b', entries: tool.refs.map((ref, index) => ({ ref, signature: tool.signatures[index] })) },
|
|
], result.refs.map((ref, index) => ({ ref, signature: result.signatures[index] })))
|
|
assert.equal(history.counts.preserved, 2)
|
|
assert.equal(history.counts.generated, 1)
|
|
assert.ok(history.relations.filter((relation) => relation.relation !== 'generated').every((relation) => relation.sourceObjectId && relation.sourcePersistentId))
|
|
assert.ok(history.relations.filter((relation) => relation.relation === 'generated').every((relation) => relation.resultPersistentId && !relation.sourceObjectId))
|
|
})
|
|
|
|
test('native topology history maps OCCT relation indexes without transient references', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const secondFace = { vertexCoord: [0, 0, 1, 2, 0, 1, 2, 2, 1, 0, 2, 1], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const input = createSubshapeRefs('native-input', 1, [face, secondFace])
|
|
const output = createSubshapeRefs('native-output', 2, [face])
|
|
const result = captureNativeTopologyHistory(
|
|
'boolean-native',
|
|
[{ objectId: 'base', entries: input.refs.map((ref, index) => ({ ref, signature: input.signatures[index] })) }],
|
|
output.refs.map((ref, index) => ({ ref, signature: output.signatures[index] })),
|
|
[
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultIndexes: [0] },
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 1, relation: 'deleted' },
|
|
],
|
|
)
|
|
assert.equal(result.provider, 'occt-native')
|
|
assert.equal(result.counts.modified, 1)
|
|
assert.equal(result.counts.deleted, 1)
|
|
assert.equal(result.relations[0].sourcePersistentId, input.refs[0].persistentId)
|
|
assert.equal(result.relations[0].resultPersistentId, output.refs[0].persistentId)
|
|
assert.equal(result.relations[1].sourcePersistentId, input.refs[1].persistentId)
|
|
const preserved = captureNativeTopologyHistory('native-preserved', [{ objectId: 'base', entries: [{ ref: input.refs[0], signature: input.signatures[0] }] }], [{ ref: output.refs[0], signature: output.signatures[0] }], [])
|
|
assert.equal(preserved.counts.preserved, 1)
|
|
assert.throws(() => captureNativeTopologyHistory('native-unexplained', [{ objectId: 'base', entries: [{ ref: input.refs[1], signature: input.signatures[1] }] }], [{ ref: output.refs[0], signature: output.signatures[0] }], []), /does not explain/)
|
|
assert.throws(() => captureNativeTopologyHistory('boolean-native', [{ objectId: 'base', entries: [] }], [], [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'deleted' }]), /out of range/)
|
|
})
|
|
|
|
test('native OCCT history rejects relation records whose summary flags claim absence', () => {
|
|
assert.throws(() => mapNativeOcctHistoryRecords({ provider: 'occt-native', occtVersion: '8.0.0', hasModified: false, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] }, { object: 'base', tool: 'tool' }), /modified flag is inconsistent/)
|
|
})
|
|
|
|
test('native stage capture retains explicit naming-evidence status without inferring tokens', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const topology = createSubshapeRefs('naming-stage-shape', 1, [face])
|
|
const entries = topology.refs.map((ref, index) => ({ ref, signature: topology.signatures[index] }))
|
|
const namingEvidence = createFinalShapeOnlyNamingEvidence('naming-stage', 'result')
|
|
const history = captureNativeTopologyHistoryStages('naming-stage-op', [{ stageId: 'naming-stage', ordinal: 0, inputs: [{ objectId: 'base', entries }], output: entries, records: [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }], resultObjectId: 'result', namingEvidence }])
|
|
assert.equal(history.namingEvidence?.[0].status, 'final-shape-only')
|
|
assert.throws(() => captureNativeTopologyHistoryStages('naming-stage-op', [{ stageId: 'naming-stage', ordinal: 0, inputs: [{ objectId: 'base', entries }], output: entries, records: [], resultObjectId: 'result', namingEvidence: { ...namingEvidence, resultObjectId: 'other' } }]), /does not match/)
|
|
})
|
|
|
|
test('native OCCT history groups one-to-many results and captures indexes in their producing stages', () => {
|
|
const grouped = mapNativeOcctHistoryRecords({
|
|
provider: 'occt-native',
|
|
occtVersion: '8.0.0',
|
|
hasModified: false,
|
|
hasGenerated: true,
|
|
hasDeleted: false,
|
|
records: [
|
|
{ relation: 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0, resultStageId: 'stage-0' },
|
|
{ relation: 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 1, resultStageId: 'stage-0' },
|
|
],
|
|
}, { object: 'base', tool: 'tool' })
|
|
assert.deepEqual(grouped, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'generated', resultKind: 'face', resultIndexes: [0, 1], resultStageId: 'stage-0' }])
|
|
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const base = createSubshapeRefs('stage-base', 1, [face])
|
|
const intermediate = createSubshapeRefs('stage-intermediate', 2, [face, face])
|
|
const final = createSubshapeRefs('stage-final', 3, [face])
|
|
const entries = (value: ReturnType<typeof createSubshapeRefs>) => value.refs.map((ref, index) => ({ ref, signature: value.signatures[index] }))
|
|
const history = captureNativeTopologyHistoryStages('two-stage-feature', [
|
|
{
|
|
stageId: 'stage-0',
|
|
operationId: 'two-stage-feature:generate',
|
|
ordinal: 0,
|
|
inputs: [{ objectId: 'base', entries: entries(base) }],
|
|
output: entries(intermediate),
|
|
records: grouped,
|
|
resultObjectId: 'intermediate',
|
|
},
|
|
{
|
|
stageId: 'stage-1',
|
|
operationId: 'two-stage-feature:finish',
|
|
ordinal: 1,
|
|
inputs: [{ objectId: 'intermediate', entries: entries(intermediate), sourceStageId: 'stage-0' }],
|
|
output: entries(final),
|
|
records: [
|
|
{ sourceObjectId: 'intermediate', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
|
|
{ sourceObjectId: 'intermediate', sourceKind: 'face', sourceIndex: 1, relation: 'deleted' },
|
|
],
|
|
resultObjectId: 'final',
|
|
},
|
|
])
|
|
assert.deepEqual(history.counts, { preserved: 0, modified: 1, generated: 2, deleted: 1, ambiguous: 0 })
|
|
assert.deepEqual(history.stages?.map((stage) => stage.stageId), ['stage-0', 'stage-1'])
|
|
assert.ok(history.relations.slice(0, 2).every((relation) => relation.resultStageId === 'stage-0'))
|
|
assert.ok(history.relations.slice(2).every((relation) => relation.sourceStageId === 'stage-0' && relation.resultStageId === 'stage-1'))
|
|
const stagedElementMap = createElementMapSnapshot(undefined, 'final', entries(final), history, new Map([['base', {
|
|
schemaVersion: 1,
|
|
entries: [{ name: 'Face7', objectId: 'base', kind: 'face', persistentId: base.refs[0].persistentId, status: 'stable' }],
|
|
}]]))
|
|
assert.equal(stagedElementMap.entries[0].name, 'Face7')
|
|
assert.deepEqual(stagedElementMap.entries[0].candidates, [{ objectId: 'base', persistentId: base.refs[0].persistentId, name: 'Face7' }])
|
|
|
|
const downstreamOutput = createSubshapeRefs('stage-downstream', 4, [face])
|
|
const downstream = captureNativeTopologyHistoryStages('downstream-feature', [{
|
|
stageId: 'stage-2',
|
|
ordinal: 0,
|
|
inputs: [{ objectId: 'final', entries: entries(final) }],
|
|
output: entries(downstreamOutput),
|
|
records: [{ sourceObjectId: 'final', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }],
|
|
resultObjectId: 'downstream',
|
|
}])
|
|
const lineage = composeNativeTopologyHistoryLineage('feature-chain', downstream, [{ objectId: 'final', history }])
|
|
assert.deepEqual(lineage.stages?.map((stage) => [stage.stageId, stage.ordinal]), [['stage-0', 0], ['stage-1', 1], ['stage-2', 2]])
|
|
assert.equal(lineage.relations.at(-1)?.sourceStageId, 'stage-1')
|
|
assert.equal(lineage.relations.at(-1)?.resultStageId, 'stage-2')
|
|
|
|
const ambiguous = captureNativeTopologyHistoryStages('ambiguous-stage', [{
|
|
stageId: 'ambiguous-stage-0',
|
|
ordinal: 0,
|
|
inputs: [{ objectId: 'base', entries: entries(base) }],
|
|
output: entries(intermediate),
|
|
records: [],
|
|
}])
|
|
assert.equal(ambiguous.counts.ambiguous, 1)
|
|
assert.deepEqual(ambiguous.relations[0].resultCandidates, intermediate.refs.map((ref) => ref.persistentId))
|
|
})
|
|
|
|
test('native OCCT provider protocol maps worker records to facade source IDs', () => {
|
|
const records = mapNativeOcctHistoryRecords({
|
|
provider: 'occt-native',
|
|
occtVersion: '8.0.0',
|
|
hasModified: true,
|
|
hasGenerated: false,
|
|
hasDeleted: true,
|
|
records: [
|
|
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 1 },
|
|
{ relation: 'deleted', source: 'tool', kind: 'face', sourceIndex: 2, resultIndex: -1 },
|
|
],
|
|
}, { object: 'base', tool: 'tool' })
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [1] },
|
|
{ sourceObjectId: 'tool', sourceKind: 'face', sourceIndex: 2, relation: 'deleted' },
|
|
])
|
|
})
|
|
|
|
test('native OCCT STEP bridge serializes handles before invoking the provider', async () => {
|
|
const calls: string[] = []
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => { calls.push(shape.id); return { format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` } } },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: (objectStep, toolStep, operation) => {
|
|
assert.match(objectStep, /base-shape/)
|
|
assert.match(toolStep, /tool-shape/)
|
|
assert.equal(operation, 'cut')
|
|
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] }
|
|
},
|
|
},
|
|
)
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'cut-1', operation: 'cut', inputs: [
|
|
{ objectId: 'base', shape: { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
|
|
{ objectId: 'tool', shape: { id: 'tool-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
|
|
], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } })
|
|
assert.deepEqual(calls, ['base-shape', 'tool-shape'])
|
|
assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }])
|
|
})
|
|
|
|
test('native OCCT Pad STEP bridge maps profile cross-kind history', async () => {
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => ({ format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` }) },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: () => { throw new Error('Pad bridge must not call Boolean history.') },
|
|
prismHistoryFromStep: (profileStep, dx, dy, dz) => {
|
|
assert.match(profileStep, /profile-shape/)
|
|
assert.deepEqual([dx, dy, dz], [0, 0, 5])
|
|
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'generated', source: 'object', kind: 'vertex', resultKind: 'edge', sourceIndex: 0, resultIndex: 0 }] }
|
|
},
|
|
},
|
|
)
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'pad-1', operation: 'pad', direction: [0, 0, 5], inputs: [{ objectId: 'profile', shape: { id: 'profile-shape', kernel: 'bitbybit-occt', kind: 'face', documentId: 'doc', documentVersion: 1 } }], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } })
|
|
assert.deepEqual(records, [{ sourceObjectId: 'profile', sourceKind: 'vertex', sourceIndex: 0, relation: 'generated', resultKind: 'edge', resultIndexes: [0] }])
|
|
})
|
|
|
|
test('native OCCT Pocket STEP bridge serializes base and profile with direction', async () => {
|
|
const calls: string[] = []
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => { calls.push(shape.id); return { format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` } } },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: () => { throw new Error('Pocket bridge must not call Boolean history.') },
|
|
pocketHistoryFromStep: (baseStep, profileStep, dx, dy, dz) => {
|
|
assert.match(baseStep, /base-shape/)
|
|
assert.match(profileStep, /profile-shape/)
|
|
assert.deepEqual([dx, dy, dz], [0, 0, 5])
|
|
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: true, records: [
|
|
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 },
|
|
{ relation: 'generated', source: 'tool', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 1 },
|
|
{ relation: 'deleted', source: 'tool', kind: 'vertex', sourceIndex: 1, resultIndex: -1 },
|
|
] }
|
|
},
|
|
},
|
|
)
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'pocket-1', operation: 'pocket', direction: [0, 0, 5], inputs: [
|
|
{ objectId: 'base', shape: { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
|
|
{ objectId: 'profile', shape: { id: 'profile-shape', kernel: 'bitbybit-occt', kind: 'face', documentId: 'doc', documentVersion: 1 } },
|
|
], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } })
|
|
assert.deepEqual(calls, ['base-shape', 'profile-shape'])
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
|
|
{ sourceObjectId: 'profile', sourceKind: 'edge', sourceIndex: 0, relation: 'generated', resultKind: 'face', resultIndexes: [1] },
|
|
{ sourceObjectId: 'profile', sourceKind: 'vertex', sourceIndex: 1, relation: 'deleted' },
|
|
])
|
|
})
|
|
|
|
test('native OCCT Revolution STEP bridge serializes profile, axis and angle', async () => {
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => ({ format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` }) },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: () => { throw new Error('Revolution bridge must not call Boolean history.') },
|
|
revolutionHistoryFromStep: (profileStep, ox, oy, oz, dx, dy, dz, angle) => {
|
|
assert.match(profileStep, /profile-shape/)
|
|
assert.deepEqual([ox, oy, oz, dx, dy, dz, angle], [-1, 0, 0, 0, 1, 0, 180])
|
|
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'generated', source: 'object', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 0 }] }
|
|
},
|
|
},
|
|
)
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'revolution-1', operation: 'revolution', axisOrigin: [-1, 0, 0], direction: [0, 1, 0], angle: 180, inputs: [{ objectId: 'profile', shape: { id: 'profile-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } }], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } })
|
|
assert.deepEqual(records, [{ sourceObjectId: 'profile', sourceKind: 'edge', sourceIndex: 0, relation: 'generated', resultKind: 'face', resultIndexes: [0] }])
|
|
})
|
|
|
|
test('native OCCT Groove STEP bridge serializes base, profile, axis and angle', async () => {
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => ({ format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` }) },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: () => { throw new Error('Groove bridge must not call Boolean history directly.') },
|
|
grooveHistoryFromStep: (baseStep, profileStep, ox, oy, oz, dx, dy, dz, angle) => {
|
|
assert.match(baseStep, /base-shape/)
|
|
assert.match(profileStep, /profile-shape/)
|
|
assert.deepEqual([ox, oy, oz, dx, dy, dz, angle], [-1, 0, 0, 0, 1, 0, 360])
|
|
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: true, records: [
|
|
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 },
|
|
{ relation: 'deleted', source: 'tool', kind: 'face', sourceIndex: 0, resultIndex: -1 },
|
|
] }
|
|
},
|
|
},
|
|
)
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'groove-1', operation: 'groove', axisOrigin: [-1, 0, 0], direction: [0, 1, 0], angle: 360, inputs: [
|
|
{ objectId: 'base', shape: { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
|
|
{ objectId: 'profile', shape: { id: 'profile-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } },
|
|
], result: { id: 'result-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } })
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
|
|
{ sourceObjectId: 'profile', sourceKind: 'face', sourceIndex: 0, relation: 'deleted' },
|
|
])
|
|
})
|
|
|
|
test('native OCCT Fillet STEP bridge serializes one base and radius', async () => {
|
|
let captured = { base: '', radius: 0 }
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => ({ format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` }) },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: () => { throw new Error('Fillet bridge must not call Boolean history.') },
|
|
filletHistoryFromStep: (baseStep, radius) => { captured = { base: baseStep, radius }; return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } },
|
|
},
|
|
)
|
|
const shape = { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } as ShapeHandle
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'fillet-1', operation: 'fillet', radius: 0.4, inputs: [{ objectId: 'base', shape }], result: shape })
|
|
assert.match(captured.base, /base-shape/)
|
|
assert.equal(captured.radius, 0.4)
|
|
assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }])
|
|
})
|
|
|
|
test('native OCCT Mirrored STEP bridge serializes one base and a mirror plane', async () => {
|
|
let captured: number[] = []
|
|
const bridge = createNativeOcctStepHistoryBridge(
|
|
{ exportStep: async (shape) => ({ format: 'step', fileName: `${shape.id}.step`, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` }) },
|
|
{
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: () => { throw new Error('Mirrored bridge must not call Boolean history directly.') },
|
|
mirroredHistoryFromStep: (baseStep, ...plane) => {
|
|
assert.match(baseStep, /base-shape/)
|
|
captured = plane
|
|
return { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] }
|
|
},
|
|
},
|
|
)
|
|
const shape = { id: 'base-shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 } as ShapeHandle
|
|
const records = await bridge({ documentId: 'doc', documentVersion: 1, operationId: 'mirrored-1', operation: 'mirrored', axisOrigin: [0, 0, 0], direction: [1, 0, 0], inputs: [{ objectId: 'base', shape }], result: shape })
|
|
assert.deepEqual(captured, [0, 0, 0, 1, 0, 0])
|
|
assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }])
|
|
await assert.rejects(() => bridge({ documentId: 'doc', documentVersion: 1, operationId: 'mirrored-invalid', operation: 'mirrored', axisOrigin: [0, 0, 0], direction: [0, 0, 0], inputs: [{ objectId: 'base', shape }], result: shape }), /finite mirror plane/)
|
|
})
|
|
|
|
test('native OCCT history protocol validates STEP context and isolates stale generations', async () => {
|
|
const provider = new DirectNativeOcctHistoryProvider({
|
|
occtVersion: () => '8.0.0',
|
|
booleanHistoryFromStep: (objectStep, toolStep, operation) => ({
|
|
provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false,
|
|
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' })
|
|
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')
|
|
assert.equal(execution.response?.protocolVersion, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION)
|
|
assert.equal(execution.response?.history.records[0].relation, 'modified')
|
|
await assert.rejects(() => provider.capture({ protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'bad', documentId: 'doc', documentVersion: 2, operationId: 'bad', operation: 'cut', objectStep: 'not-step', toolStep: 'ISO-10303-21; tool' }, new AbortController().signal), /STEP text/)
|
|
await assert.rejects(() => provider.capture({ protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'bad-operation', documentId: 'doc', documentVersion: 2, operationId: 'bad-operation', operation: 'unknown' as never, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }, new AbortController().signal), /Unsupported native OCCT history operation/)
|
|
const stale = new NativeOcctHistoryCoordinator(() => 3)
|
|
const staleExecution = await stale.capture(provider, { documentId: 'doc', documentVersion: 2, operationId: 'cut-2', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
|
|
assert.equal(staleExecution.status, 'stale')
|
|
})
|
|
|
|
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' })
|
|
})
|
|
|
|
test('native OCCT history coordinator reports timeout and cancellation', async () => {
|
|
const hanging: NativeOcctHistoryProvider = { capabilities: () => ({ providerId: 'test', providerVersion: '1', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' }), capture: async (_request, signal) => await new Promise<never>((_, reject) => { signal.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true }) }) }
|
|
const timedOut = await new NativeOcctHistoryCoordinator(() => 1, 5).capture(hanging, { documentId: 'doc', documentVersion: 1, operationId: 'cut-timeout', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
|
|
assert.equal(timedOut.status, 'timed-out')
|
|
const coordinator = new NativeOcctHistoryCoordinator(() => 1, 120_000)
|
|
const pending = coordinator.capture(hanging, { documentId: 'doc', documentVersion: 1, operationId: 'cut-cancel', operation: 'cut', objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' })
|
|
coordinator.cancel()
|
|
assert.equal((await pending).status, 'cancelled')
|
|
})
|
|
|
|
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>()
|
|
const fakeWorker = {
|
|
postMessage(message: { type: string; request?: { requestId: string; protocolVersion: number; documentId: string; documentVersion: number; operationId: string; objectStep: string; toolStep: string; operation: 'cut' } }) {
|
|
if (message.type === 'initialize') {
|
|
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' } } } as MessageEvent)))
|
|
} else if (message.type === 'capture' && message.request) {
|
|
const request = message.request
|
|
const 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: '8.0.0', availability: 'available', operations: ['fuse', 'cut', 'common'], transport: 'step-text' },
|
|
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [] },
|
|
}
|
|
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'response', response } } as MessageEvent)))
|
|
}
|
|
},
|
|
terminate() {},
|
|
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).add(listener as never) },
|
|
removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).delete(listener as never) },
|
|
}
|
|
const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: () => fakeWorker })
|
|
const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-1', documentId: 'doc', documentVersion: 4, operationId: 'cut-worker', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }
|
|
const response = await provider.capture(request, new AbortController().signal)
|
|
assert.equal(response.requestId, request.requestId)
|
|
assert.equal(response.documentVersion, request.documentVersion)
|
|
assert.equal(provider.capabilities().availability, 'available')
|
|
provider.dispose()
|
|
})
|
|
|
|
test('native OCCT Worker provider propagates AbortSignal cancellation to the worker', async () => {
|
|
const messageListeners = new Set<(event: MessageEvent) => void>()
|
|
const cancelRequests: string[] = []
|
|
const fakeWorker = {
|
|
postMessage(message: { type: string; requestId?: string }) {
|
|
if (message.type === 'initialize') {
|
|
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' } } } as MessageEvent)))
|
|
} else if (message.type === 'cancel' && message.requestId) cancelRequests.push(message.requestId)
|
|
},
|
|
terminate() {},
|
|
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { if (type === 'message') messageListeners.add(listener as never) },
|
|
removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { if (type === 'message') messageListeners.delete(listener as never) },
|
|
}
|
|
const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: () => fakeWorker })
|
|
const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-cancel-1', documentId: 'doc', documentVersion: 4, operationId: 'cut-worker-cancel', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }
|
|
const controller = new AbortController()
|
|
const pending = provider.capture(request, controller.signal)
|
|
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
controller.abort()
|
|
await assert.rejects(pending, (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
|
|
assert.deepEqual(cancelRequests, [request.requestId])
|
|
provider.dispose()
|
|
})
|
|
|
|
test('native OCCT Worker provider recreates a crashed worker before retrying initialization', async () => {
|
|
const workers: Array<{ postMessage: (message: { type: string; request?: { requestId: string; protocolVersion: number; documentId: string; documentVersion: number; operationId: string; objectStep: string; toolStep: string; operation: 'cut' } }) => void; terminate: () => void; addEventListener: (type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) => void; removeEventListener: (type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) => void }> = []
|
|
const makeWorker = () => {
|
|
const messageListeners = new Set<(event: MessageEvent) => void>()
|
|
const errorListeners = new Set<(event: ErrorEvent) => void>()
|
|
const worker = {
|
|
postMessage(message: { type: string; request?: { requestId: string; protocolVersion: number; documentId: string; documentVersion: number; operationId: string; objectStep: string; toolStep: string; operation: 'cut' } }) {
|
|
if (message.type === 'initialize' && workers.length === 1) queueMicrotask(() => errorListeners.forEach((listener) => listener({ message: 'worker crashed' } as ErrorEvent)))
|
|
if (message.type === 'initialize' && workers.length > 1) queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' } } } as MessageEvent)))
|
|
},
|
|
terminate() {},
|
|
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).add(listener as never) },
|
|
removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void) { (type === 'message' ? messageListeners : errorListeners).delete(listener as never) },
|
|
}
|
|
workers.push(worker)
|
|
return worker
|
|
}
|
|
const provider = new NativeOcctHistoryWorkerProvider({ workerFactory: makeWorker, initializationTimeoutMs: 100 })
|
|
const request = { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: 'worker-retry-1', documentId: 'doc', documentVersion: 1, operationId: 'cut-worker-retry', operation: 'cut' as const, objectStep: 'ISO-10303-21; object', toolStep: 'ISO-10303-21; tool' }
|
|
await assert.rejects(() => provider.initialize(), /worker crashed/)
|
|
assert.equal(provider.capabilities().availability, 'unavailable')
|
|
const capabilities = await provider.initialize()
|
|
assert.equal(capabilities.availability, 'available')
|
|
assert.equal(workers.length, 2)
|
|
provider.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes configured native history through STEP and the facade mapper', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
let capturedRequestId = ''
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['cut'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
capturedRequestId = request.requestId
|
|
assert.match(request.objectStep, /base/)
|
|
assert.match(request.toolStep, /tool/)
|
|
return {
|
|
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
|
|
requestId: request.requestId,
|
|
documentId: request.documentId,
|
|
documentVersion: request.documentVersion,
|
|
operationId: request.operationId,
|
|
provider: provider.capabilities(),
|
|
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: true, records: [
|
|
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 },
|
|
{ relation: 'deleted', source: 'tool', kind: 'edge', sourceIndex: 1 },
|
|
] },
|
|
}
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'cut-runtime', operation: 'cut', inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'tool', shape: handle('tool') }], result: handle('result') })
|
|
assert.match(capturedRequestId, /^native-history-/)
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
|
|
{ sourceObjectId: 'tool', sourceKind: 'edge', sourceIndex: 1, relation: 'deleted' },
|
|
])
|
|
runtime.configureNativeHistory(null)
|
|
assert.equal(runtime.nativeHistoryCapabilities().availability, 'unavailable')
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes Pocket native history through base/profile STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['pocket'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'pocket')
|
|
assert.match(request.objectStep, /base/)
|
|
assert.match(request.toolStep || '', /profile/)
|
|
assert.deepEqual(request.direction, [0, 0, 5])
|
|
return {
|
|
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
|
|
requestId: request.requestId,
|
|
documentId: request.documentId,
|
|
documentVersion: request.documentVersion,
|
|
operationId: request.operationId,
|
|
provider: provider.capabilities(),
|
|
history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] },
|
|
}
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string, kind: ShapeHandle['kind'] = 'solid'): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind, documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'pocket-runtime', operation: 'pocket', direction: [0, 0, 5], inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'profile', shape: handle('profile', 'face') }], result: handle('result') })
|
|
assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime preserves Pocket tool-builder and Cut stages', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
const handle = (id: string, kind: ShapeHandle['kind'] = 'solid'): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind, documentId: 'doc', documentVersion: 3 })
|
|
const topology = (shapeId: string): SubshapeTopology => {
|
|
const created = createSubshapeRefs(shapeId, 3, [{ vertexCoord: [0, 0, 0, 1, 0, 0, 0, 1, 0], normalCoord: [], triIndexes: [0, 1, 2] }])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
}
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
runtime.importShape = async (input) => handle(input.text.includes('tool') ? 'tool-import' : 'cut-import')
|
|
runtime.topology = async (shape) => topology(shape.id)
|
|
runtime.release = async () => {}
|
|
const operations: string[] = []
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['pocket', 'pad', 'cut'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
operations.push(request.operation)
|
|
const isTool = request.operation === 'pad'
|
|
if (isTool) assert.match(request.objectStep, /profile/)
|
|
else { assert.match(request.objectStep, /base/); assert.match(request.toolStep ?? '', /tool/) }
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, resultStep: `ISO-10303-21; ${isTool ? 'tool' : 'cut'}`, records: isTool ? [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] : [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }, { relation: 'modified', source: 'tool', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'pocket-staged', operation: 'pocket', direction: [0, 0, 5], inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'profile', shape: handle('profile', 'face') }], stages: [{ stageId: 'pocket:stage:0', operation: 'pocket', inputObjectIds: ['base', 'profile'], resultObjectId: 'pocket', ordinal: 0 }], result: handle('pocket') })
|
|
assert.deepEqual(operations, ['pad', 'cut'])
|
|
assert.deepEqual(records.stageCaptures?.map(({ operation, inputObjectIds, resultObjectId }) => ({ operation, inputObjectIds, resultObjectId })), [
|
|
{ operation: 'pad', inputObjectIds: ['profile'], resultObjectId: 'pocket-staged:native-stage:0:result' },
|
|
{ operation: 'cut', inputObjectIds: ['base', 'pocket-staged:native-stage:0:result'], resultObjectId: 'pocket' },
|
|
])
|
|
assert.equal(records[1].sourceObjectId, 'pocket-staged:native-stage:0:result')
|
|
assert.equal(records[1].sourceStageId, 'pocket-staged:native-stage:0')
|
|
assert.equal(records[1].resultStageId, 'pocket-staged:native-stage:1')
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime preserves two-sided additive and subtractive native stage DAGs', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
const handle = (id: string, kind: ShapeHandle['kind'] = 'solid'): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind, documentId: 'doc', documentVersion: 3 })
|
|
const topology = (shapeId: string): SubshapeTopology => {
|
|
const created = createSubshapeRefs(shapeId, 3, [{ vertexCoord: [0, 0, 0, 1, 0, 0, 0, 1, 0], normalCoord: [], triIndexes: [0, 1, 2] }])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
}
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
runtime.importShape = async (input) => handle(`import-${input.text.split(' ').at(-1)}`)
|
|
runtime.topology = async (shape) => topology(shape.id)
|
|
runtime.release = async () => {}
|
|
const requests: Array<{ operation: string; direction?: Point3; angle?: number }> = []
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['rotate', 'pad', 'pocket', 'revolution', 'groove', 'fuse', 'cut'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
requests.push({ operation: request.operation, direction: request.direction, angle: request.angle })
|
|
const isBoolean = request.operation === 'fuse' || request.operation === 'cut'
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, resultStep: `ISO-10303-21; ${request.operation}-${request.operationId}`, records: isBoolean ? [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }, { relation: 'modified', source: 'tool', kind: 'face', sourceIndex: 0, resultIndex: 0 }] : [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const padRecords = await runtime.topologyHistory({
|
|
documentId: 'doc', documentVersion: 3, operationId: 'pad-two-sided', operation: 'pad',
|
|
featureSides: [{ direction: [0, 0, 2] }, { direction: [0, 0, -1] }], direction: [0, 0, 2],
|
|
inputs: [{ objectId: 'profile', shape: handle('profile', 'face') }],
|
|
stages: [{ stageId: 'pad:stage:0', operation: 'pad', inputObjectIds: ['profile'], resultObjectId: 'pad-feature', ordinal: 0 }], result: handle('pad-feature'),
|
|
})
|
|
assert.deepEqual(requests.map(({ operation }) => operation), ['pad', 'pad', 'fuse'])
|
|
assert.deepEqual(requests.slice(0, 2).map(({ direction }) => direction), [[0, 0, 2], [0, 0, -1]])
|
|
assert.deepEqual(padRecords.stageCaptures?.map(({ operation, ordinal }) => ({ operation, ordinal })), [{ operation: 'pad', ordinal: 0 }, { operation: 'pad', ordinal: 1 }, { operation: 'fuse', ordinal: 2 }])
|
|
assert.equal(padRecords.stageCaptures?.at(-1)?.resultObjectId, 'pad-feature')
|
|
|
|
requests.length = 0
|
|
const grooveRecords = await runtime.topologyHistory({
|
|
documentId: 'doc', documentVersion: 3, operationId: 'groove-two-angles', operation: 'groove', axisOrigin: [0, 0, 0],
|
|
featureSides: [{ direction: [0, 1, 0], angle: 120 }, { direction: [0, -1, 0], angle: 60 }], direction: [0, 1, 0], angle: 120,
|
|
inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'profile', shape: handle('profile', 'face') }],
|
|
stages: [{ stageId: 'groove:stage:0', operation: 'groove', inputObjectIds: ['base', 'profile'], resultObjectId: 'groove-feature', ordinal: 0 }], result: handle('groove-feature'),
|
|
})
|
|
assert.deepEqual(requests.map(({ operation }) => operation), ['rotate', 'revolution', 'cut'])
|
|
assert.deepEqual(requests.slice(0, 2).map(({ operation, direction, angle }) => ({ operation, direction, angle })), [{ operation: 'rotate', direction: [0, 1, 0], angle: -60 }, { operation: 'revolution', direction: [0, 1, 0], angle: 180 }])
|
|
assert.deepEqual(grooveRecords.stageCaptures?.map(({ operation, ordinal }) => ({ operation, ordinal })), [{ operation: 'rotate', ordinal: 0 }, { operation: 'revolution', ordinal: 1 }, { operation: 'cut', ordinal: 2 }])
|
|
assert.deepEqual(grooveRecords.stageCaptures?.at(-1)?.inputObjectIds, ['base', 'groove-two-angles:native-stage:1:result'])
|
|
assert.equal(grooveRecords.stageCaptures?.at(-1)?.resultObjectId, 'groove-feature')
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes two-section Loft native history through STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['loft'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'loft'); assert.match(request.objectStep, /first/); assert.match(request.toolStep || '', /second/); assert.equal(request.ruled, false)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: false, hasGenerated: true, hasDeleted: false, records: [{ relation: 'generated', source: 'object', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 0 }, { relation: 'generated', source: 'tool', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 1 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'face', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'loft-runtime', operation: 'loft', ruled: false, inputs: [{ objectId: 'first', shape: handle('first') }, { objectId: 'second', shape: handle('second') }], result: handle('result') })
|
|
assert.deepEqual(records.map((record) => record.sourceObjectId), ['first', 'second'])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes Pipe profile and spine native history through STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['pipe'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'pipe'); assert.match(request.objectStep, /profile/); assert.match(request.toolStep || '', /spine/)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', resultKind: 'face', sourceIndex: 0, resultIndex: 0 }, { relation: 'generated', source: 'tool', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 1 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'face', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'pipe-runtime', operation: 'pipe', inputs: [{ objectId: 'profile', shape: handle('profile') }, { objectId: 'spine', shape: handle('spine') }], result: handle('result') })
|
|
assert.deepEqual(records.map((record) => record.sourceObjectId), ['profile', 'spine'])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes Revolution native history through profile STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['revolution'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'revolution')
|
|
assert.match(request.objectStep, /profile/)
|
|
assert.deepEqual(request.axisOrigin, [-1, 0, 0])
|
|
assert.deepEqual(request.direction, [0, 1, 0])
|
|
assert.equal(request.angle, 180)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: false, hasGenerated: true, hasDeleted: false, records: [{ relation: 'generated', source: 'object', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 0 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'revolution-runtime', operation: 'revolution', axisOrigin: [-1, 0, 0], direction: [0, 1, 0], angle: 180, inputs: [{ objectId: 'profile', shape: handle('profile') }], result: handle('result') })
|
|
assert.deepEqual(records, [{ sourceObjectId: 'profile', sourceKind: 'edge', sourceIndex: 0, relation: 'generated', resultKind: 'face', resultIndexes: [0] }])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes Groove native history through base/profile STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['groove'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'groove')
|
|
assert.match(request.objectStep, /base/)
|
|
assert.match(request.toolStep || '', /profile/)
|
|
assert.deepEqual(request.axisOrigin, [4, 0, 5])
|
|
assert.deepEqual(request.direction, [0, 1, 0])
|
|
assert.equal(request.angle, 360)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: true, records: [
|
|
{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 },
|
|
{ relation: 'deleted', source: 'tool', kind: 'face', sourceIndex: 0 },
|
|
] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string, kind: ShapeHandle['kind'] = 'solid'): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind, documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'groove-runtime', operation: 'groove', axisOrigin: [4, 0, 5], direction: [0, 1, 0], angle: 360, inputs: [{ objectId: 'base', shape: handle('base') }, { objectId: 'profile', shape: handle('profile', 'face') }], result: handle('result') })
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
|
|
{ sourceObjectId: 'profile', sourceKind: 'face', sourceIndex: 0, relation: 'deleted' },
|
|
])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes Fillet native history through base STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['fillet'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'fillet')
|
|
assert.match(request.objectStep, /base/)
|
|
assert.equal(request.radius, 0.4)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }, { relation: 'generated', source: 'object', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 1 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'fillet-runtime', operation: 'fillet', radius: 0.4, inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') })
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] },
|
|
{ sourceObjectId: 'base', sourceKind: 'edge', sourceIndex: 0, relation: 'generated', resultKind: 'face', resultIndexes: [1] },
|
|
])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes single-face Draft native history through base STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['draft'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'draft')
|
|
assert.match(request.objectStep, /base/)
|
|
assert.equal(request.faceIndex, 2)
|
|
assert.equal(request.angle, 5)
|
|
assert.deepEqual(request.direction, [0, 0, 1])
|
|
assert.deepEqual(request.axisOrigin, [0, 0, 0])
|
|
assert.deepEqual(request.neutralPlaneDirection, [0, 0, 1])
|
|
assert.equal(request.reversed, false)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 2, resultIndex: 2 }, { relation: 'generated', source: 'object', kind: 'edge', resultKind: 'face', sourceIndex: 0, resultIndex: 6 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'draft-runtime', operation: 'draft', faceIndexes: [2], angle: 5, direction: [0, 0, 1], axisOrigin: [0, 0, 0], neutralPlaneDirection: [0, 0, 1], reversed: false, inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') })
|
|
assert.deepEqual(records, [
|
|
{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 2, relation: 'modified', resultKind: 'face', resultIndexes: [2] },
|
|
{ sourceObjectId: 'base', sourceKind: 'edge', sourceIndex: 0, relation: 'generated', resultKind: 'face', resultIndexes: [6] },
|
|
])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes Mirrored native history through base STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['mirrored'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'mirrored')
|
|
assert.match(request.objectStep, /base/)
|
|
assert.deepEqual(request.axisOrigin, [0, 0, 0])
|
|
assert.deepEqual(request.direction, [1, 0, 0])
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'mirrored-runtime', operation: 'mirrored', axisOrigin: [0, 0, 0], direction: [1, 0, 0], inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') })
|
|
assert.deepEqual(records, [{ sourceObjectId: 'base', sourceKind: 'face', sourceIndex: 0, relation: 'modified', resultKind: 'face', resultIndexes: [0] }])
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes one-step MultiTransform native history through base STEP', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['multi-transform'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'multi-transform')
|
|
assert.equal(request.transformKind, 'linear')
|
|
assert.deepEqual(request.direction, [1, 0, 0])
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'multi-transform-runtime', operation: 'multi-transform', transformKind: 'linear', direction: [1, 0, 0], inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') })
|
|
assert.equal(records[0].sourceObjectId, 'base')
|
|
await assert.rejects(() => runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'multi-transform-invalid', operation: 'multi-transform', transformKind: 'linear', direction: [0, 0, 0], inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') }), /translation vector/)
|
|
await assert.rejects(() => runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'multi-transform-unknown', operation: 'multi-transform', transformKind: 'unknown' as never, direction: [1, 0, 0], inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') }), /supported transform step/)
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime routes ordered MultiTransform history without losing Base ownership', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
const transforms = [
|
|
{ type: 'linear' as const, direction: [1, 0, 0] as [number, number, number] },
|
|
{ type: 'mirrored' as const, axisOrigin: [0, 0, 0] as [number, number, number], direction: [1, 0, 0] as [number, number, number] },
|
|
]
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['multi-transform'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
assert.equal(request.operation, 'multi-transform')
|
|
assert.deepEqual(request.transforms, transforms)
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: true, hasDeleted: false, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }, { relation: 'generated', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 1 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const records = await runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'multi-transform-ordered', operation: 'multi-transform', transforms, inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') })
|
|
assert.deepEqual(records.map((record) => record.sourceObjectId), ['base', 'base'])
|
|
await assert.rejects(() => runtime.topologyHistory({ documentId: 'doc', documentVersion: 3, operationId: 'multi-transform-ordered-invalid', operation: 'multi-transform', transforms: [{ type: 'unknown' } as never, transforms[1]], inputs: [{ objectId: 'base', shape: handle('base') }], result: handle('result') }), /unsupported transform step/)
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('Bitbybit geometry runtime captures every ordered MultiTransform native builder result', async () => {
|
|
const runtime = new BitbybitGeometryRuntime()
|
|
const handle = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 3 })
|
|
const topology = (shapeId: string): SubshapeTopology => {
|
|
const created = createSubshapeRefs(shapeId, 3, [{ vertexCoord: [0, 0, 0, 1, 0, 0, 0, 1, 0], normalCoord: [], triIndexes: [0, 1, 2] }])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
}
|
|
runtime.exportStep = async (shape, fileName = `${shape.id}.step`) => ({ format: 'step', fileName, mediaType: 'application/step', text: `ISO-10303-21; ${shape.id}` })
|
|
runtime.importShape = async (input) => handle(`imported-${input.text.split('stage-')[1]}`)
|
|
runtime.topology = async (shape) => topology(shape.id)
|
|
runtime.release = async () => {}
|
|
const requests: Array<{ operation: string; objectStep: string }> = []
|
|
const provider: NativeOcctHistoryProvider = {
|
|
capabilities: () => ({ providerId: 'occt-native.history-step', providerVersion: '8.0.0-embind', occtVersion: '8.0.0', availability: 'available', operations: ['multi-transform', 'linear-pattern', 'mirrored'], transport: 'step-text' }),
|
|
capture: async (request) => {
|
|
requests.push({ operation: request.operation, objectStep: request.objectStep })
|
|
const ordinal = requests.length - 1
|
|
return { protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, operationId: request.operationId, provider: provider.capabilities(), history: { provider: 'occt-native', occtVersion: '8.0.0', hasModified: true, hasGenerated: false, hasDeleted: false, resultStep: `ISO-10303-21; stage-${ordinal}`, records: [{ relation: 'modified', source: 'object', kind: 'face', sourceIndex: 0, resultIndex: 0 }] } }
|
|
},
|
|
}
|
|
runtime.configureNativeHistory(provider, 1000)
|
|
const transforms = [
|
|
{ type: 'linear' as const, direction: [10, 0, 0] as [number, number, number] },
|
|
{ type: 'mirrored' as const, axisOrigin: [0, 0, 0] as [number, number, number], direction: [1, 0, 0] as [number, number, number] },
|
|
]
|
|
const records = await runtime.topologyHistory({
|
|
documentId: 'doc',
|
|
documentVersion: 3,
|
|
operationId: 'multi-transform-staged',
|
|
operation: 'multi-transform',
|
|
transforms,
|
|
inputs: [{ objectId: 'base', shape: handle('base') }],
|
|
stages: [{ stageId: 'feature:stage:0', operation: 'multi-transform', inputObjectIds: ['base'], resultObjectId: 'feature', ordinal: 0 }],
|
|
result: handle('feature'),
|
|
})
|
|
assert.deepEqual(requests, [
|
|
{ operation: 'linear-pattern', objectStep: 'ISO-10303-21; base' },
|
|
{ operation: 'mirrored', objectStep: 'ISO-10303-21; stage-0' },
|
|
])
|
|
assert.equal(records.stageCaptures?.length, 2)
|
|
assert.deepEqual(records.stageCaptures?.map(({ stageId, inputObjectIds, resultObjectId }) => ({ stageId, inputObjectIds, resultObjectId })), [
|
|
{ stageId: 'multi-transform-staged:native-stage:0', inputObjectIds: ['base'], resultObjectId: 'multi-transform-staged:native-stage:0:result' },
|
|
{ stageId: 'multi-transform-staged:native-stage:1', inputObjectIds: ['multi-transform-staged:native-stage:0:result'], resultObjectId: 'feature' },
|
|
])
|
|
assert.equal(Object.keys(records).includes('stageCaptures'), false)
|
|
assert.equal(records[0].sourceStageId, 'multi-transform-staged:native-stage:0')
|
|
assert.equal(records[0].resultStageId, 'multi-transform-staged:native-stage:1')
|
|
runtime.dispose()
|
|
})
|
|
|
|
test('edge and vertex topology signatures are orientation/index independent', () => {
|
|
const edge = signatureForEdge([0, 0, 0], [2, 0, 0])
|
|
assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash)
|
|
assert.equal(signatureForVertex([1, 2, 3]).hash, signatureForVertex([1.000001, 2, 3], 1e-5).hash)
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const edges = createEdgeSubshapeRefs('shape-topology', 3, [face])
|
|
const vertices = createVertexSubshapeRefs('shape-topology', 3, [face])
|
|
assert.equal(edges.refs.length, 5)
|
|
assert.equal(vertices.refs.length, 4)
|
|
assert.ok(edges.refs.every((ref) => ref.kind === 'edge' && ref.persistentId.startsWith('topo-edge-')))
|
|
assert.ok(vertices.refs.every((ref) => ref.kind === 'vertex' && ref.persistentId.startsWith('topo-vertex-')))
|
|
})
|
|
|
|
test('analytic topology signatures remain independent of tessellation and preserve kernel adjacency evidence', () => {
|
|
const face = signatureForAnalyticFace({ surfaceType: 'Plane', area: 200, adjacentFaceCount: 4, edgeCount: 4, wireCount: 1 })
|
|
const sameFace = signatureForAnalyticFace({ surfaceType: 'Plane', area: 200, adjacentFaceCount: 4, edgeCount: 4, wireCount: 1, centroid: [100, 200, 300] })
|
|
assert.equal(face.hash, sameFace.hash)
|
|
assert.equal(face.analytic?.type, 'Plane')
|
|
const edge = signatureForAnalyticEdge({ curveType: 'Circle', length: 2 * Math.PI, parameterRange: [0, 2 * Math.PI], adjacentFaceTypes: ['Cylinder', 'Plane'] })
|
|
const reorderedEdge = signatureForAnalyticEdge({ curveType: 'Circle', length: 2 * Math.PI, parameterRange: [2 * Math.PI, 0], adjacentFaceTypes: ['Plane', 'Cylinder'] })
|
|
assert.equal(edge.hash, reorderedEdge.hash)
|
|
const vertex = signatureForAnalyticVertex({ point: [1, 2, 3], incidentEdgeCount: 3 })
|
|
assert.equal(vertex.hash, signatureForAnalyticVertex({ point: [100, 200, 300], incidentEdgeCount: 3 }).hash)
|
|
const topology = createAnalyticSubshapeRefs('analytic-shape', 4, [
|
|
{ surfaceType: 'Plane', area: 200, adjacentFaceCount: 4, edgeCount: 4 },
|
|
{ surfaceType: 'Plane', area: 200, adjacentFaceCount: 4, edgeCount: 4 },
|
|
], [
|
|
{ curveType: 'Line', length: 10, adjacentFaceTypes: ['Plane', 'Plane'] },
|
|
], [
|
|
{ point: [0, 0, 0], incidentEdgeCount: 3 },
|
|
])
|
|
assert.equal(topology.faces.refs[0].status, 'ambiguous')
|
|
assert.equal(topology.edges.refs[0].status, 'stable')
|
|
assert.equal(topology.vertices.refs[0].status, 'stable')
|
|
const topologyRefs = { faces: topology.faces.refs, edges: topology.edges.refs, vertices: topology.vertices.refs }
|
|
const adjacency = createTopologyAdjacency(topologyRefs, {
|
|
faceNeighbors: [[1], [0]],
|
|
faceEdges: [[0], [0]],
|
|
edgeFaces: [[0, 1]],
|
|
edgeVertices: [[0]],
|
|
vertexEdges: [[0]],
|
|
})
|
|
assert.deepEqual(adjacency.faceNeighbors[topology.faces.refs[0].persistentId], [topology.faces.refs[1].persistentId])
|
|
assert.deepEqual(adjacency.edgeFaces[topology.edges.refs[0].persistentId], [topology.faces.refs[0].persistentId, topology.faces.refs[1].persistentId].sort())
|
|
assert.throws(() => createTopologyAdjacency(topologyRefs, { faceEdges: [[2], []] }), /out of range/)
|
|
})
|
|
|
|
test('topology migration uses persisted adjacency as a conservative confidence signal', () => {
|
|
const previous = createAnalyticSubshapeRefs('previous', 1, [
|
|
{ surfaceType: 'Plane', area: 10, adjacentFaceCount: 1, edgeCount: 1 },
|
|
{ surfaceType: 'Plane', area: 20, adjacentFaceCount: 1, edgeCount: 1 },
|
|
], [], [])
|
|
const current = createAnalyticSubshapeRefs('current', 2, [
|
|
{ surfaceType: 'Plane', area: 10, adjacentFaceCount: 1, edgeCount: 1 },
|
|
{ surfaceType: 'Plane', area: 20, adjacentFaceCount: 1, edgeCount: 1 },
|
|
], [], [])
|
|
const previousEntries = previous.faces.refs.map((ref, index) => ({ ref, signature: previous.faces.signatures[index] }))
|
|
const currentEntries = current.faces.refs.map((ref, index) => ({ ref, signature: current.faces.signatures[index] }))
|
|
const linked = { faceNeighbors: { [previous.faces.refs[0].persistentId]: [previous.faces.refs[1].persistentId], [previous.faces.refs[1].persistentId]: [previous.faces.refs[0].persistentId] }, faceEdges: {}, edgeFaces: {}, edgeVertices: {}, vertexEdges: {} }
|
|
const broken = { faceNeighbors: { [current.faces.refs[0].persistentId]: [], [current.faces.refs[1].persistentId]: [] }, faceEdges: {}, edgeFaces: {}, edgeVertices: {}, vertexEdges: {} }
|
|
const matches = matchSubshapesWithAdjacency(previousEntries, currentEntries, linked, broken)
|
|
assert.equal(matches[0].status, 'ambiguous')
|
|
assert.equal(matches[0].adjacencyScore, 0)
|
|
assert.equal(matches[1].status, 'ambiguous')
|
|
})
|
|
|
|
test('topology migration resolves repeated signatures only when adjacency fingerprints are unique', () => {
|
|
const face = { surfaceType: 'Plane', area: 10, adjacentFaceCount: 0, edgeCount: 0 }
|
|
const distinct = { surfaceType: 'Plane', area: 20, adjacentFaceCount: 0, edgeCount: 0 }
|
|
const previous = createAnalyticSubshapeRefs('previous-adjacency', 1, [face, face, distinct], [], [])
|
|
const current = createAnalyticSubshapeRefs('current-adjacency', 2, [face, face, distinct], [], [])
|
|
const previousEntries = previous.faces.refs.map((ref, index) => ({ ref, signature: previous.faces.signatures[index] }))
|
|
const currentEntries = current.faces.refs.map((ref, index) => ({ ref, signature: current.faces.signatures[index] }))
|
|
const previousAdjacency = createTopologyAdjacency({ faces: previous.faces.refs, edges: [], vertices: [] }, { faceNeighbors: [[2], [], [0]] })
|
|
const currentAdjacency = createTopologyAdjacency({ faces: current.faces.refs, edges: [], vertices: [] }, { faceNeighbors: [[], [2], [1]] })
|
|
const matches = matchSubshapesWithAdjacency(previousEntries, currentEntries, previousAdjacency, currentAdjacency)
|
|
assert.equal(matches[0].status, 'stable')
|
|
assert.equal(matches[0].previousId, previous.faces.refs[1].persistentId)
|
|
assert.equal(matches[1].status, 'stable')
|
|
assert.equal(matches[1].previousId, previous.faces.refs[0].persistentId)
|
|
assert.equal(matches[2].status, 'stable')
|
|
})
|
|
|
|
test('topology persistence validation rejects transient and unknown adjacency IDs', () => {
|
|
const refs = createAnalyticSubshapeRefs('persisted', 1, [{ surfaceType: 'Plane', area: 10, adjacentFaceCount: 0, edgeCount: 0 }], [], [])
|
|
const entries = refs.faces.refs.map((ref, index) => ({ ref, signature: refs.faces.signatures[index] }))
|
|
const base = {
|
|
shapeId: 'persisted', documentVersion: 1, generation: 1, entries,
|
|
migration: { previousGeneration: null, matches: entries.map(({ ref }) => ({ current: ref, score: 1, status: 'stable' as const })) },
|
|
history: captureSignatureTopologyHistory('persisted:1', [], entries),
|
|
}
|
|
validateTopologySnapshotForPersistence({ ...base, adjacency: { faceNeighbors: { [entries[0].ref.persistentId]: [] }, faceEdges: {}, edgeFaces: {}, edgeVertices: {}, vertexEdges: {} } })
|
|
assert.throws(() => validateTopologySnapshotForPersistence({ ...base, entries: [{ ...entries[0], ref: { ...entries[0].ref, persistentId: 'faceIndex:0' } }] }), /transient/)
|
|
assert.throws(() => validateTopologySnapshotForPersistence({ ...base, adjacency: { faceNeighbors: { [entries[0].ref.persistentId]: ['missing'] }, faceEdges: {}, edgeFaces: {}, edgeVertices: {}, vertexEdges: {} } }), /unknown persistent ID/)
|
|
})
|
|
|
|
test('ElementMap2 writer creates FreeCAD 1.1.1 tokens from explicit naming history evidence', () => {
|
|
const postfixes = ['Edge', 'Face', ';:G;XTR;:Habc:7,E']
|
|
const indexed = createElementMap2NameToken({ name: 'Edge10', indexedName: { type: 'Edge', index: 10 }, postfix: postfixes[2], stringIds: [0xb, 0xd] }, postfixes)
|
|
const stringId = createElementMap2NameToken({ name: '#16:2', prefixStringId: 0x16, postfix: postfixes[2], stringIds: [0x16, 0xb, 0xd] }, postfixes)
|
|
const literal = createElementMap2NameToken({ name: 'plain', stringIds: [2] }, postfixes)
|
|
assert.equal(indexed.raw, ':1.a.3.b.d')
|
|
assert.equal(stringId.raw, '$#16:2.3.b.d')
|
|
assert.equal(literal.raw, ';plain.0.2')
|
|
assert.equal(formatElementMap2NameToken(indexed), indexed.raw)
|
|
assert.equal(createElementMap2NameToken(elementMap2NameTokenToReference(stringId, postfixes), postfixes).raw, stringId.raw)
|
|
assert.equal(createElementMap2NameEntry([{ name: '#16:2', prefixStringId: 0x16, postfix: postfixes[2], stringIds: [0x16] }], postfixes).tokens[0].raw, '$#16:2.3')
|
|
assert.throws(() => createElementMap2NameToken({ name: 'Edge10', indexedName: { type: 'Missing', index: 10 } }, postfixes), /absent from the postfix table/)
|
|
|
|
const document = {
|
|
schemaVersion: 2 as const,
|
|
nativeVersion: 1 as const,
|
|
rootId: 1,
|
|
postfixes,
|
|
maps: [{ index: 1, id: 1, typeCount: 1, sections: [{ name: 'Edge', children: [], names: [{ tokens: [indexed, stringId, literal], trailing: '0' as const }] }] }],
|
|
rootMapIndex: 1,
|
|
}
|
|
assert.equal(validateElementMap2(document).valid, true)
|
|
const reparsed = parseElementMap2(writeElementMap2(document))
|
|
assert.deepEqual(reparsed.maps[0].sections[0].names[0].tokens.map((token) => token.raw), [':1.a.3.b.d', '$#16:2.3.b.d', ';plain.0.2'])
|
|
const childResource = 'BeginElementMap v1\n1 PostfixCount 1\nEdge\nMapCount 2\nElementMap 1 2 1\nEdge\nChildCount 0\nNameCount 0\nEndMap\nElementMap 2 1 1\nEdge\nChildCount 1\n1 0 1 42 1 ;:H2,E 0.17.31\nNameCount 0\nEndMap\n'
|
|
const childDocument = parseElementMap2(childResource)
|
|
assert.deepEqual(childDocument.maps[1].sections[0].children[0].stringIds, [17, 31])
|
|
assert.match(writeElementMap2(childDocument), /;:H2,E 0\.17\.31/)
|
|
const appended = appendElementMap2NameEntry({ ...document, postfixes: [], maps: document.maps.map((map) => ({ ...map, sections: map.sections.map((section) => ({ ...section, names: [] })) })) }, 1, 'Edge', [{ name: 'Edge10', indexedName: { type: 'Edge', index: 10 }, postfix: ';:M;CUT;:Habc:7,E' }])
|
|
assert.deepEqual(appended.postfixes, ['Edge', ';:M;CUT;:Habc:7,E'])
|
|
assert.equal(appended.maps[0].sections[0].names[0].tokens[0].raw, ':1.a.2')
|
|
})
|
|
|
|
test('ElementMap names follow FreeCAD FaceN EdgeN VertexN rules and retain native lineage', () => {
|
|
assert.equal(formatElementMapName('face', 3), 'Face3')
|
|
assert.deepEqual(parseElementMapName('Edge12'), { kind: 'edge', index: 12 })
|
|
assert.equal(parseElementMapName('Face0'), null)
|
|
const faceEntry = (persistentId: string): ObjectTopologySnapshot['entries'][number] => ({
|
|
ref: { shapeId: 'result', kind: 'face', persistentId, topologyVersion: 2, status: 'stable' },
|
|
signature: { kind: 'face', canonical: persistentId, hash: persistentId, centroid: [0, 0, 0], bounds: { min: [0, 0, 0], max: [1, 1, 1] }, area: 1, normal: [0, 0, 1] },
|
|
})
|
|
const previous: ObjectTopologySnapshot['elementMap'] = { schemaVersion: 1, entries: [{ name: 'Face1', objectId: 'base', kind: 'face', persistentId: 'old-face', status: 'stable' }, { name: 'Face2', objectId: 'base', kind: 'face', persistentId: 'deleted-face', status: 'stable' }] }
|
|
const history = { operationId: 'cut:1', provider: 'occt-native' as const, relations: [
|
|
{ relation: 'modified' as const, sourceObjectId: 'base', sourcePersistentId: 'old-face', resultPersistentId: 'new-face', score: 1 },
|
|
{ relation: 'deleted' as const, sourceObjectId: 'base', sourcePersistentId: 'deleted-face', score: 1 },
|
|
], counts: { preserved: 0, modified: 1, generated: 0, deleted: 1, ambiguous: 0 } }
|
|
const map = createElementMapSnapshot(undefined, 'result', [faceEntry('new-face')], history, new Map([['base', previous]]))
|
|
assert.deepEqual(map.entries.map((entry) => [entry.name, entry.status, entry.persistentId]), [['Face1', 'stable', 'new-face'], ['Face2', 'deleted', 'deleted-face']])
|
|
assert.doesNotThrow(() => validateTopologySnapshotForPersistence({ shapeId: 'result', documentVersion: 2, generation: 1, entries: [faceEntry('new-face')], migration: { previousGeneration: null, matches: [] }, history, elementMap: map }))
|
|
})
|
|
|
|
test('StringHasher v1 codec preserves FreeCAD relative and multiline evidence', () => {
|
|
const text = [
|
|
'StringTableStart v1 6',
|
|
'-36.0 0:;SKT',
|
|
'-1.c.1 g1123v1',
|
|
'-1.c.0 g1126v2',
|
|
'-1.0 0:g',
|
|
'-1.1c.3.1',
|
|
'-1.0 1:first line\nsecond line',
|
|
'',
|
|
].join('\n')
|
|
const parsed = parseStringHasherTable(text)
|
|
assert.equal(parsed.entries.length, 6)
|
|
assert.deepEqual(parsed.entries[1].relatedIds, [0x36])
|
|
assert.deepEqual(parsed.entries[4].relatedIds, [0x37, 0x39])
|
|
assert.equal(parsed.entries[5].data, 'first line\nsecond line')
|
|
assert.equal(validateStringHasherTable(parsed).valid, true)
|
|
const migrated = migrateStringHasherSchema(parsed)
|
|
assert.equal(migrated.schemaVersion, 2)
|
|
assert.equal(writeStringHasherTable(migrated), text)
|
|
assert.deepEqual(Array.from(serializeStringHasherTableResource(migrated)), Array.from(new TextEncoder().encode(text)))
|
|
const longLines = { schemaVersion: 2 as const, nativeVersion: 1 as const, entries: [{ id: 1, flags: 0, relatedIds: [], data: Array.from({ length: 11 }, (_, index) => `line-${index}`).join('\n'), postfix: '' }] }
|
|
const longText = writeStringHasherTable(longLines)
|
|
assert.match(longText, /-1\.0 a:line-0/)
|
|
assert.deepEqual(parseStringHasherTable(longText).entries[0].data, longLines.entries[0].data)
|
|
const trailingData = { schemaVersion: 2 as const, nativeVersion: 1 as const, entries: [{ id: 1, flags: 0, relatedIds: [], data: 'line-ending\n', postfix: '' }] }
|
|
assert.equal(writeStringHasherTable(trailingData), 'StringTableStart v1 1\n-1.0 1:line-ending\n\n')
|
|
assert.equal(parseStringHasherTable(writeStringHasherTable(trailingData)).entries[0].data, trailingData.entries[0].data)
|
|
})
|
|
|
|
test('ElementMap2 StringHasher evidence requires prefix and suffix IDs to exist', () => {
|
|
const map = 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 table = parseStringHasherTable(['StringTableStart v1 2', '-36.0 0:prefix', '-1.0 0:suffix', ''].join('\n'))
|
|
assert.deepEqual(validateElementMap2StringHasherEvidence(map, table), [])
|
|
const missing = parseStringHasherTable('StringTableStart v1 0\n')
|
|
assert.equal(validateElementMap2StringHasherEvidence(map, missing).length, 3)
|
|
})
|
|
|
|
test('native naming evidence preserves MappedNameRef provenance and multi-stage ambiguity', () => {
|
|
const postfixes = ['Edge', 'Face', ';:Hnative,E']
|
|
const table = { schemaVersion: 2 as const, nativeVersion: 1 as const, entries: [
|
|
{ id: 0x10, flags: 0, relatedIds: [], data: 'edge', postfix: '' },
|
|
{ id: 0x11, flags: 0, relatedIds: [], data: 'face', postfix: '' },
|
|
] }
|
|
const stage0 = createNativeStageNamingEvidence({
|
|
stageId: 'native:stage:0',
|
|
resultObjectId: 'stage-0-result',
|
|
status: 'native-evidence',
|
|
stringHasher: table,
|
|
mappedNames: [{ kind: 'edge', resultIndex: 0, resultPersistentId: 'edge-0', relation: 'generated', reference: { name: 'Edge1', postfix: postfixes[2], stringIds: [0x10] } }],
|
|
})
|
|
const stage1 = createNativeStageNamingEvidence({
|
|
stageId: 'native:stage:1',
|
|
resultObjectId: 'final-result',
|
|
status: 'native-evidence',
|
|
stringHasher: table,
|
|
mappedNames: [{ kind: 'edge', resultIndex: 0, resultPersistentId: 'edge-final', relation: 'ambiguous', reference: { name: 'Edge1', postfix: postfixes[2], stringIds: [0x10] }, sourceRefs: [{ objectId: 'stage-0-result', persistentId: 'edge-0', stageId: 'native:stage:0' }], candidates: [{ objectId: 'base-a', persistentId: 'edge-a' }, { objectId: 'base-b', persistentId: 'edge-b' }] }],
|
|
})
|
|
assert.equal(validateNativeNamingEvidence(stage0).valid, true)
|
|
assert.equal(validateNativeNamingEvidence(stage1).valid, true)
|
|
const mapping = createElementMap2MultiStageNameMapping([stage0, stage1], postfixes)
|
|
assert.deepEqual(mapping.stages.map((stage) => stage.inputStageIds), [[], ['native:stage:0']])
|
|
assert.equal(mapping.stages[1].entries[0].status, 'ambiguous')
|
|
assert.deepEqual(mapping.stages[1].entries[0].candidates?.map((candidate) => candidate.objectId), ['base-a', 'base-b'])
|
|
const reparsed = parseElementMap2MultiStageNameMapping(writeElementMap2MultiStageNameMapping(mapping))
|
|
assert.deepEqual(validateElementMap2MultiStageNameMapping(reparsed), [])
|
|
assert.equal(reparsed.stages[1].entries[0].token.raw, ';Edge1.3.10')
|
|
assert.equal(createFinalShapeOnlyNamingEvidence('stage-final-only', 'shape').status, 'final-shape-only')
|
|
assert.throws(() => createElementMap2MultiStageNameMapping([{ ...createFinalShapeOnlyNamingEvidence('stage-final-only', 'shape'), mappedNames: [{ kind: 'edge', resultIndex: 0, resultPersistentId: 'edge-final', reference: { name: 'Edge1' } }] } as never], postfixes), /final-shape-only stages cannot carry generated mapped-name evidence/)
|
|
assert.throws(() => createNativeStageNamingEvidence({ stageId: 'bad', resultObjectId: 'shape', status: 'native-evidence', mappedNames: [] }), /requires mapped name references/)
|
|
})
|
|
|
|
test('FCStd Shape properties emit the live topology ElementMap when no opaque map overrides it', () => {
|
|
const entry = { ref: { shapeId: 'Body-shape', kind: 'face' as const, persistentId: 'topo-face-live', topologyVersion: 1, status: 'stable' as const }, signature: { kind: 'face' as const, canonical: 'live-face', hash: 'live-face', centroid: [0, 0, 0] as [number, number, number], bounds: { min: [0, 0, 0] as [number, number, number], max: [1, 1, 0] as [number, number, number] }, area: 1, normal: [0, 0, 1] as [number, number, number] } }
|
|
const history = captureSignatureTopologyHistory('Body:1', [], [entry])
|
|
const map = createElementMapSnapshot(undefined, 'Body', [entry], history)
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'element-map-fcstd', label: 'ElementMap FCStd', objects: [{ id: 'Body', typeId: 'Part::Feature', properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: 'Part/Body.Shape.brp', format: 'brep' } }], topology: { shapeId: 'Body-shape', documentVersion: 1, generation: 1, entries: [entry], migration: { previousGeneration: null, matches: [] }, history, elementMap: map } }], tree: [{ id: 'Body', label: 'Body', type: 'feature' }] }
|
|
const archive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Body.Shape.brp': new Uint8Array([1, 2, 3]) } })
|
|
const xml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(xml, /<ElementMap new="1" count="1"><Element key="Face1" value="Face1"\/><\/ElementMap>/)
|
|
})
|
|
|
|
test('topology mutation replay audits 100 models and 1000 deterministic mutations', () => {
|
|
const report = runTopologyMutationReplay({ models: 100, mutationsPerModel: 10, seed: 0x20260803 })
|
|
assert.equal(report.cases, 1000)
|
|
assert.equal(report.wrongBindings, 0)
|
|
assert.ok(report.stable > 0)
|
|
assert.ok(report.ambiguous > 0)
|
|
assert.ok(report.new > 0)
|
|
assert.ok(report.deleted > 0)
|
|
})
|
|
|
|
test('basic sketch solver applies geometric constraints and reports remaining degrees of freedom', () => {
|
|
const sketch = createSketch('sketch-test', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
{ id: 'length-1', type: 'distance', first: { geometryId: 'line-1', point: 'start' }, second: { geometryId: 'line-1', point: 'end' }, value: 5 },
|
|
])
|
|
const result = solveSketch(sketch)
|
|
assert.equal(result.status, 'under-constrained')
|
|
assert.ok(result.residual <= 1e-7)
|
|
assert.equal(result.snapshot.geometry[0].type, 'line')
|
|
assert.equal((result.snapshot.geometry[0] as Extract<typeof result.snapshot.geometry[number], { type: 'line' }>).end.x, 5)
|
|
|
|
const conflict = solveSketch(createSketch('conflict', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }], [
|
|
{ id: 'block-1', type: 'block', geometryId: 'line-1' },
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
]))
|
|
assert.equal(conflict.status, 'conflicting')
|
|
assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED'))
|
|
})
|
|
|
|
test('Sketcher reference dimensions remain read-only and duplicate driving constraints are diagnosed', () => {
|
|
const sketch = createSketch('reference-dimension', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 1 } }], [
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
{ id: 'reference-length', type: 'distance', first: { geometryId: 'line-1', point: 'start' }, second: { geometryId: 'line-1', point: 'end' }, value: 99, driving: false },
|
|
{ id: 'horizontal-duplicate', type: 'horizontal', geometryId: 'line-1' },
|
|
])
|
|
const result = solveSketch(sketch)
|
|
assert.equal(result.status, 'under-constrained')
|
|
assert.equal(result.degreesOfFreedom, 3)
|
|
assert.equal((result.snapshot.geometry[0] as Extract<typeof result.snapshot.geometry[number], { type: 'line' }>).end.x, 2)
|
|
assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === 'REDUNDANT_CONSTRAINT' && diagnostic.constraintId === 'horizontal-duplicate'))
|
|
assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === 'REFERENCE_DIMENSION' && diagnostic.constraintId === 'reference-length'))
|
|
})
|
|
|
|
test('Sketcher dimensional constraints classify duplicate and conflicting driving dimensions', () => {
|
|
const duplicate = solveSketch(createSketch('duplicate-dimension', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [
|
|
{ id: 'length-1', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
{ id: 'length-2', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
]))
|
|
assert.equal(duplicate.status, 'under-constrained')
|
|
assert.ok(duplicate.diagnostics.some((diagnostic) => diagnostic.code === 'REDUNDANT_CONSTRAINT' && diagnostic.constraintId === 'length-2'))
|
|
const conflict = solveSketch(createSketch('conflicting-dimensions', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [
|
|
{ id: 'length-1', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
{ id: 'length-2', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 8 },
|
|
]))
|
|
assert.equal(conflict.status, 'conflicting')
|
|
assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'CONSTRAINT_CONFLICT' && diagnostic.constraintId === 'length-2'))
|
|
assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED'))
|
|
})
|
|
|
|
test('Sketcher solver supports diameter, symmetric and tangent constraints at the model boundary', () => {
|
|
const sketch = createSketch('advanced-constraints', [
|
|
{ id: 'circle-1', type: 'circle', center: { x: 0, y: 1 }, radius: 1 },
|
|
{ id: 'line-1', type: 'line', start: { x: -5, y: 0 }, end: { x: 5, y: 0 } },
|
|
{ id: 'point-a', type: 'point', position: { x: -2, y: 2 } },
|
|
{ id: 'point-b', type: 'point', position: { x: 4, y: 0 } },
|
|
{ id: 'center', type: 'point', position: { x: 1, y: 1 } },
|
|
], [
|
|
{ id: 'diameter-1', type: 'diameter', geometryId: 'circle-1', value: 4 },
|
|
{ id: 'tangent-1', type: 'tangent', firstGeometryId: 'line-1', secondGeometryId: 'circle-1' },
|
|
{ id: 'symmetric-1', type: 'symmetric', first: { geometryId: 'point-a', point: 'position' }, second: { geometryId: 'point-b', point: 'position' }, center: { geometryId: 'center', point: 'position' } },
|
|
])
|
|
const result = solveSketch(sketch)
|
|
assert.ok(result.residual <= 1e-7)
|
|
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).radius, 2)
|
|
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).center.y, 2)
|
|
})
|
|
|
|
test('Sketcher solver supports FreeCAD parallel, perpendicular and point-on-object constraints', () => {
|
|
const sketch = createSketch('line-relations', [
|
|
{ id: 'axis', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
|
{ id: 'parallel-line', type: 'line', start: { x: 0, y: 2 }, end: { x: 1, y: 3 } },
|
|
{ id: 'perpendicular-line', type: 'line', start: { x: 2, y: 0 }, end: { x: 4, y: 1 } },
|
|
{ id: 'circle', type: 'circle', center: { x: 0, y: 0 }, radius: 2 },
|
|
{ id: 'point', type: 'point', position: { x: 5, y: 0 } },
|
|
], [
|
|
{ id: 'parallel', type: 'parallel', firstGeometryId: 'axis', secondGeometryId: 'parallel-line' },
|
|
{ id: 'perpendicular', type: 'perpendicular', firstGeometryId: 'axis', secondGeometryId: 'perpendicular-line' },
|
|
{ id: 'point-on-circle', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'circle' },
|
|
])
|
|
const result = solveSketch(sketch)
|
|
assert.equal(result.status, 'under-constrained')
|
|
assert.ok(result.residual <= 1e-7)
|
|
const parallel = result.snapshot.geometry.find((geometry) => geometry.id === 'parallel-line')
|
|
const perpendicular = result.snapshot.geometry.find((geometry) => geometry.id === 'perpendicular-line')
|
|
const point = result.snapshot.geometry.find((geometry) => geometry.id === 'point')
|
|
assert.equal(parallel?.type === 'line' ? parallel.end.y : NaN, 2)
|
|
assert.ok(Math.abs(perpendicular?.type === 'line' ? perpendicular.end.x - 2 : Infinity) <= 1e-7)
|
|
assert.deepEqual(point?.type === 'point' ? point.position : null, { x: 2, y: 0 })
|
|
|
|
const unsupported = solveSketch(createSketch('invalid-parallel', [
|
|
{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
|
|
{ id: 'circle', type: 'circle', center: { x: 0, y: 0 }, radius: 1 },
|
|
], [{ id: 'invalid', type: 'parallel', firstGeometryId: 'line', secondGeometryId: 'circle' }]))
|
|
assert.equal(unsupported.status, 'conflicting')
|
|
})
|
|
|
|
test('Sketcher block constraint accounts for the complete geometry variable count', () => {
|
|
const line = solveSketch(createSketch('blocked-line', [
|
|
{ id: 'line', type: 'line', start: { x: 1, y: 2 }, end: { x: 4, y: 6 } },
|
|
], [{ id: 'block-line', type: 'block', geometryId: 'line' }]))
|
|
assert.equal(line.status, 'solved')
|
|
assert.equal(line.degreesOfFreedom, 0)
|
|
|
|
const circle = solveSketch(createSketch('blocked-circle', [
|
|
{ id: 'circle', type: 'circle', center: { x: 1, y: 2 }, radius: 3 },
|
|
], [{ id: 'block-circle', type: 'block', geometryId: 'circle' }]))
|
|
assert.equal(circle.status, 'solved')
|
|
assert.equal(circle.degreesOfFreedom, 0)
|
|
})
|
|
|
|
test('Sketcher model round-trips ellipse and B-spline data without claiming solver support', () => {
|
|
const source = createSketch('advanced-curves', [
|
|
{ id: 'ellipse-1', type: 'ellipse', center: { x: 1, y: 2 }, majorRadius: 8, minorRadius: 3, rotation: 0.25, construction: true },
|
|
{ id: 'bspline-1', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 4 }, { x: 6, y: 2 }, { x: 8, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1] },
|
|
], [{ id: 'block-ellipse', type: 'block', geometryId: 'ellipse-1' }])
|
|
const cloned = cloneSketch(source)
|
|
const ellipse = cloned.geometry[0]
|
|
const spline = cloned.geometry[1]
|
|
assert.equal(ellipse.type, 'ellipse')
|
|
assert.equal(spline.type, 'bspline')
|
|
if (ellipse.type === 'ellipse') ellipse.center.x = 99
|
|
if (spline.type === 'bspline') { spline.controlPoints[0].x = 99; spline.weights![0] = 2; spline.knots![0] = 3 }
|
|
assert.deepEqual(source.geometry.map((geometry) => geometry.id), ['ellipse-1', 'bspline-1'])
|
|
assert.equal(source.geometry[0].construction, true)
|
|
assert.deepEqual(source.geometry[0], { id: 'ellipse-1', type: 'ellipse', center: { x: 1, y: 2 }, majorRadius: 8, minorRadius: 3, rotation: 0.25, construction: true })
|
|
assert.deepEqual(source.geometry[1], { id: 'bspline-1', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 4 }, { x: 6, y: 2 }, { x: 8, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1] })
|
|
const result = solveSketch(source)
|
|
assert.equal(result.status, 'invalid')
|
|
assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ['UNSUPPORTED_GEOMETRY', 'UNSUPPORTED_GEOMETRY'])
|
|
})
|
|
|
|
test('Sketcher geometry validation preserves periodic B-spline structure and rejects malformed curve data', () => {
|
|
const periodic = { id: 'periodic-spline', type: 'bspline' as const, degree: 2, periodic: true, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 1 }, { x: 4, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 1, 2, 2, 2] }
|
|
assert.doesNotThrow(() => validateSketchGeometry(periodic))
|
|
const source = createSketch('periodic-model', [periodic])
|
|
const cloned = cloneSketch(source)
|
|
assert.equal(sketchGeometrySignature(source.geometry[0]), sketchGeometrySignature(cloned.geometry[0]))
|
|
if (cloned.geometry[0].type === 'bspline') cloned.geometry[0].controlPoints[0].x = 99
|
|
assert.equal((source.geometry[0].type === 'bspline' ? source.geometry[0].controlPoints[0].x : NaN), 0)
|
|
assert.throws(() => validateSketchGeometry({ ...periodic, knots: [0, 1] }), /knots/)
|
|
assert.throws(() => validateSketchGeometry({ ...periodic, weights: [1, 1] }), /weights/)
|
|
assert.throws(() => createSketch('duplicate-geometry', [{ ...periodic }, { ...periodic }]), /Duplicate sketch geometry ID/)
|
|
})
|
|
|
|
test('B-spline editing preserves stable IDs and validates poles, weights, knots and degree atomically', () => {
|
|
const source = { id: 'spline-edit', type: 'bspline' as const, degree: 2, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 0 }], weights: [1, 1, 1], knots: [0, 0, 0, 1, 1, 1] }
|
|
const edited = editBsplineGeometry(source, { controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 4, y: 0 }], weights: [1, 0.5, 1], periodic: true })
|
|
assert.equal(edited.id, source.id)
|
|
assert.equal(edited.periodic, true)
|
|
assert.deepEqual(edited.controlPoints[1], { x: 2, y: 3 })
|
|
assert.deepEqual(source.controlPoints[1], { x: 1, y: 2 })
|
|
assert.throws(() => editBsplineGeometry(source, { degree: 3 }), /degree \+ 1/)
|
|
assert.throws(() => editBsplineGeometry(source, { weights: [1, 0, 1] }), /weights/)
|
|
assert.throws(() => editBsplineGeometry(source, { knots: [0, 1] }), /knots/)
|
|
const snapshot = createSketch('spline-editor', [source])
|
|
const next = editSketchBspline(snapshot, 'spline-edit', { weights: [1, 0.75, 1] })
|
|
assert.deepEqual((next.geometry[0].type === 'bspline' ? next.geometry[0].weights : []), [1, 0.75, 1])
|
|
assert.deepEqual((snapshot.geometry[0].type === 'bspline' ? snapshot.geometry[0].weights : []), [1, 1, 1])
|
|
assert.throws(() => editSketchBspline(snapshot, 'missing', {}), /does not exist/)
|
|
})
|
|
|
|
test('Sketch point drag is transactional and re-solves driving constraints', () => {
|
|
const source = createSketch('drag-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 1 } }], [{ id: 'horizontal', type: 'horizontal', geometryId: 'line' }])
|
|
const result = dragSketchPoint(source, { geometryId: 'line', point: 'end' }, { x: 8, y: 4 })
|
|
const line = result.snapshot.geometry[0]
|
|
assert.equal(line.type, 'line')
|
|
if (line.type === 'line') {
|
|
assert.deepEqual(line.start, { x: 0, y: 4 })
|
|
assert.deepEqual(line.end, { x: 8, y: 4 })
|
|
}
|
|
assert.deepEqual(source.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 1 } })
|
|
assert.throws(() => dragSketchPoint(source, { geometryId: 'missing', point: 'end' }, { x: 1, y: 1 }), /does not exist/)
|
|
assert.throws(() => dragSketchPoint(source, { geometryId: 'line', point: 'center' }, { x: 1, y: 1 }), /not valid/)
|
|
})
|
|
|
|
test('Sketch line split preserves the source ID and rejects unsafe constraint loss', () => {
|
|
const source = createSketch('split-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, construction: true }])
|
|
const split = splitSketchLine(source, 'line', { x: 4, y: 0 }, 'line-2')
|
|
assert.deepEqual(split.geometry, [
|
|
{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 0 }, construction: true },
|
|
{ id: 'line-2', type: 'line', start: { x: 4, y: 0 }, end: { x: 10, y: 0 }, construction: true },
|
|
])
|
|
assert.deepEqual(split.constraints, [{ id: 'split-coincident-line-line-2', type: 'coincident', first: { geometryId: 'line', point: 'end' }, second: { geometryId: 'line-2', point: 'start' } }])
|
|
assert.deepEqual(source.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, construction: true })
|
|
assert.throws(() => splitSketchLine(source, 'line', { x: 12, y: 0 }, 'outside'), /strictly inside/)
|
|
assert.throws(() => splitSketchLine(source, 'line', { x: 4, y: 1 }, 'off-line'), /strictly inside/)
|
|
const constrained = createSketch('split-constrained', source.geometry, [{ id: 'horizontal', type: 'horizontal', geometryId: 'line' }])
|
|
assert.throws(() => splitSketchLine(constrained, 'line', { x: 4, y: 0 }, 'line-2'), /constraint migration/)
|
|
})
|
|
|
|
test('Sketch line extension is collinear, directional and transactional', () => {
|
|
const source = createSketch('extend-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }])
|
|
const end = extendSketchLine(source, 'line', 'end', { x: 15, y: 0 })
|
|
const start = extendSketchLine(source, 'line', 'start', { x: -3, y: 0 })
|
|
assert.deepEqual(end.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 15, y: 0 } })
|
|
assert.deepEqual(start.geometry[0], { id: 'line', type: 'line', start: { x: -3, y: 0 }, end: { x: 10, y: 0 } })
|
|
assert.deepEqual(source.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } })
|
|
assert.throws(() => extendSketchLine(source, 'line', 'end', { x: 8, y: 0 }), /beyond/)
|
|
assert.throws(() => extendSketchLine(source, 'line', 'end', { x: 15, y: 1 }), /collinear/)
|
|
})
|
|
|
|
test('Sketch line trim shortens only the selected endpoint transactionally', () => {
|
|
const source = createSketch('trim-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }])
|
|
assert.deepEqual(trimSketchLine(source, 'line', 'end', { x: 7, y: 0 }).geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 7, y: 0 } })
|
|
assert.deepEqual(trimSketchLine(source, 'line', 'start', { x: 2, y: 0 }).geometry[0], { id: 'line', type: 'line', start: { x: 2, y: 0 }, end: { x: 10, y: 0 } })
|
|
assert.deepEqual(source.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } })
|
|
assert.throws(() => trimSketchLine(source, 'line', 'end', { x: 11, y: 0 }), /strictly inside/)
|
|
const withCutter = createSketch('trim-intersection', [source.geometry[0], { id: 'cutter', type: 'line', start: { x: 7, y: -2 }, end: { x: 7, y: 2 } }])
|
|
const trimmedAtIntersection = trimSketchLine(withCutter, 'line', 'end', { x: 7, y: 0 })
|
|
assert.deepEqual(trimmedAtIntersection.constraints, [{ id: 'trim-point-on-object-line-end-cutter', type: 'pointOnObject', point: { geometryId: 'line', point: 'end' }, geometryId: 'cutter' }])
|
|
})
|
|
|
|
test('Sketch autoconstraint suggestions are deterministic, tolerance-bound and non-mutating', () => {
|
|
const source = createSketch('auto-editor', [
|
|
{ id: 'line-a', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 0.0001 } },
|
|
{ id: 'line-b', type: 'line', start: { x: 5.0001, y: 0 }, end: { x: 5, y: 4 } },
|
|
])
|
|
const suggestions = suggestSketchAutoConstraints(source, 'line-a', 0.001)
|
|
assert.deepEqual(suggestions.map((suggestion) => suggestion.reason), ['coincident', 'horizontal'])
|
|
assert.deepEqual(source.constraints, [])
|
|
const withHorizontal = createSketch('auto-dedup', source.geometry, [{ id: 'manual-horizontal', type: 'horizontal', geometryId: 'line-a' }])
|
|
assert.deepEqual(suggestSketchAutoConstraints(withHorizontal, 'line-a', 0.001).map((suggestion) => suggestion.reason), ['coincident'])
|
|
assert.deepEqual(suggestSketchAutoConstraints(source, 'line-a', 0.00001), [])
|
|
assert.throws(() => suggestSketchAutoConstraints(source, 'missing'), /does not exist/)
|
|
const applied = applySketchAutoConstraints(source, suggestions)
|
|
assert.deepEqual(applied.constraints.map((constraint) => constraint.id), suggestions.map((suggestion) => suggestion.id))
|
|
assert.deepEqual(source.constraints, [])
|
|
assert.throws(() => applySketchAutoConstraints(applied, suggestions), /already exists/)
|
|
})
|
|
|
|
test('Sketch editor event replay is deterministic with undo and redo history', () => {
|
|
const source = createSketch('replay-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 0 } }])
|
|
const events = [
|
|
{ type: 'drag' as const, point: { geometryId: 'line', point: 'end' as const }, target: { x: 8, y: 0 } },
|
|
{ type: 'undo' as const },
|
|
{ type: 'redo' as const },
|
|
{ type: 'trim' as const, geometryId: 'line', endpoint: 'end' as const, target: { x: 6, y: 0 } },
|
|
]
|
|
const first = replaySketchEditorEvents(source, events)
|
|
const second = replaySketchEditorEvents(source, events)
|
|
assert.equal(first.applied, 2)
|
|
assert.equal(first.undone, 1)
|
|
assert.equal(first.redone, 1)
|
|
assert.equal(sketchGeometrySignature(first.snapshot.geometry[0]), sketchGeometrySignature(second.snapshot.geometry[0]))
|
|
assert.deepEqual(first.snapshot.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 6, y: 0 } })
|
|
})
|
|
|
|
test('Sketch editor interaction session maps pointer gestures and keyboard history transactionally', () => {
|
|
const session = new SketchEditorInteractionSession(createSketch('interaction-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 1 } }], [{ id: 'horizontal', type: 'horizontal', geometryId: 'line' }]))
|
|
session.setTool('drag')
|
|
session.pointerDown({ position: { x: 5, y: 1 }, target: { geometryId: 'line', point: 'end' } })
|
|
assert.equal(session.pointerUp({ position: { x: 8, y: 2 } }), true)
|
|
const dragged = session.getSnapshot().geometry[0]
|
|
assert.equal(dragged.type, 'line')
|
|
if (dragged.type === 'line') assert.deepEqual(dragged, { id: 'line', type: 'line', start: { x: 0, y: 2 }, end: { x: 8, y: 2 } })
|
|
assert.equal(session.keyDown({ key: 'z', ctrlKey: true }), true)
|
|
assert.equal((session.getSnapshot().geometry[0] as Extract<SketchGeometry, { type: 'line' }>).end.x, 5)
|
|
assert.equal(session.keyDown({ key: 'y', ctrlKey: true }), true)
|
|
assert.equal((session.getSnapshot().geometry[0] as Extract<SketchGeometry, { type: 'line' }>).end.x, 8)
|
|
session.pointerDown({ position: { x: 8, y: 2 }, target: { geometryId: 'line', point: 'end' } })
|
|
assert.equal(session.keyDown({ key: 'Escape' }), true)
|
|
assert.equal(session.getState().pointerActive, false)
|
|
assert.equal(session.getTool(), 'select')
|
|
assert.deepEqual({ applied: session.getState().applied, undone: session.getState().undone, redone: session.getState().redone }, { applied: 1, undone: 1, redone: 1 })
|
|
})
|
|
|
|
test('Sketch geometry deletion is transactional and reference-safe', () => {
|
|
const source = createSketch('delete-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, { id: 'free', type: 'point', position: { x: 2, y: 2 } }])
|
|
const deleted = deleteSketchGeometry(source, 'free')
|
|
assert.deepEqual(deleted.geometry.map((geometry) => geometry.id), ['line'])
|
|
assert.equal(source.geometry.length, 2)
|
|
const constrained = createSketch('delete-constrained', source.geometry, [{ id: 'horizontal', type: 'horizontal', geometryId: 'line' }])
|
|
assert.throws(() => deleteSketchGeometry(constrained, 'line'), /constrained/)
|
|
})
|
|
|
|
test('Sketch construction toggle is transactional and replayable', () => {
|
|
const source = createSketch('construction-editor', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }])
|
|
const toggled = setSketchConstruction(source, 'line', true)
|
|
assert.equal(toggled.geometry[0].construction, true)
|
|
assert.equal(source.geometry[0].construction, undefined)
|
|
const replay = replaySketchEditorEvents(source, [{ type: 'construction', geometryId: 'line', construction: true }, { type: 'undo' }, { type: 'redo' }])
|
|
assert.equal(replay.snapshot.geometry[0].construction, true)
|
|
assert.throws(() => setSketchConstruction(source, 'missing', true), /does not exist/)
|
|
})
|
|
|
|
test('Sketcher preserves FreeCAD advanced constraint proxies with explicit diagnostics', () => {
|
|
const source = createSketch('advanced-constraints', [{ id: 'spline', type: 'bspline', degree: 2, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 0 }], weights: [1, 1, 1], knots: [0, 0, 0, 1, 1, 1] }], [
|
|
{ id: 'weight-1', type: 'weight', geometryId: 'spline', controlPointIndex: 1, value: 0.75 },
|
|
{ id: 'align-1', type: 'internalAlignment', geometryId: 'spline', internalGeometryIndex: 1, alignmentType: 'bspline-control-point' },
|
|
{ id: 'snell-1', type: 'snellsLaw', firstGeometryId: 'spline', secondGeometryId: 'spline', value: 1.2 },
|
|
])
|
|
const result = solveSketch(source)
|
|
assert.equal(result.status, 'invalid')
|
|
assert.deepEqual(result.diagnostics.filter((diagnostic) => diagnostic.code === 'UNSUPPORTED_CONSTRAINT').map((diagnostic) => diagnostic.constraintId), ['weight-1', 'align-1', 'snell-1'])
|
|
assert.equal(cloneSketch(source).constraints[0].type, 'weight')
|
|
})
|
|
|
|
test('sketch solver protocol exposes honest capabilities and deterministic replay', async () => {
|
|
const provider = new BasicSketchSolverProvider()
|
|
assert.deepEqual(provider.capabilities().engine, 'typescript-basic')
|
|
assert.equal(provider.capabilities().compatibility, 'experimental')
|
|
assert.ok(provider.capabilities().supportedConstraints.includes('pointOnObject'))
|
|
const fixture = createSketch('protocol-sketch', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 2 } }], [
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
])
|
|
const replay = await runSketchSolverReplay(provider, [{
|
|
id: 'basic-horizontal',
|
|
request: { requestId: 'replay-1', documentId: 'doc', documentVersion: 1, snapshot: fixture },
|
|
expected: { status: 'under-constrained', degreesOfFreedom: 3, maxResidual: 1e-7 },
|
|
}])
|
|
assert.deepEqual(replay, [{ id: 'basic-horizontal', passed: true, differences: [] }])
|
|
|
|
const unavailable = new UnavailablePlanegcsProvider()
|
|
assert.equal(unavailable.capabilities().availability, 'unavailable')
|
|
assert.equal(unavailable.capabilities().engine, 'planegcs-wasm')
|
|
await assert.rejects(() => unavailable.solve({} as SketchSolverRequest, new AbortController().signal), SketchSolverUnavailableError)
|
|
})
|
|
|
|
test('sketch solver coordinator rejects cancelled and stale generations', async () => {
|
|
let documentVersion = 1
|
|
let releaseFirst: (() => void) | undefined
|
|
const basic = new BasicSketchSolverProvider()
|
|
const delayed: SketchSolverProvider = {
|
|
capabilities: () => basic.capabilities(),
|
|
solve: (request, signal) => new Promise((resolve, reject) => {
|
|
const release = () => basic.solve(request, signal).then(resolve, reject)
|
|
if (request.requestId === 'first') releaseFirst = release
|
|
else release()
|
|
}),
|
|
}
|
|
const coordinator = new SketchSolverCoordinator(() => documentVersion)
|
|
const snapshot = createSketch('coordinator')
|
|
const first = coordinator.solve(delayed, { requestId: 'first', documentId: 'doc', documentVersion: 1, snapshot })
|
|
const second = coordinator.solve(delayed, { requestId: 'second', documentId: 'doc', documentVersion: 1, snapshot })
|
|
releaseFirst?.()
|
|
assert.equal((await first).status, 'cancelled')
|
|
assert.equal((await second).status, 'completed')
|
|
|
|
documentVersion = 2
|
|
const stale = await coordinator.solve(basic, { requestId: 'stale', documentId: 'doc', documentVersion: 1, snapshot })
|
|
assert.equal(stale.status, 'stale')
|
|
assert.equal(stale.generation, 3)
|
|
assert.equal(SKETCH_SOLVER_PROTOCOL_VERSION, 1)
|
|
})
|
|
|
|
test('planegcs adapter maps its declared native subset and releases Embind vectors', () => {
|
|
let deleted = false
|
|
const snapshot = createSketch('native-subset', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 3, y: 4 } }], [
|
|
{ id: 'horizontal', type: 'horizontal', geometryId: 'line' },
|
|
{ id: 'length', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
])
|
|
const result = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => ({ size: () => 6, get: (index) => [0, 0, 0, 5, 0, 0][index], delete: () => { deleted = true } }),
|
|
solveVerticalDistance: () => { throw new Error('must not run for horizontal fixture') },
|
|
solveDistanceX: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveDistanceY: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveAngle: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveCircleRadius: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveCircleDiameter: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveEqualLines: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for horizontal distance fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for horizontal fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for horizontal fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for horizontal fixture') },
|
|
}, snapshot)
|
|
assert.equal(deleted, true)
|
|
assert.equal(result.status, 'under-constrained')
|
|
assert.equal(result.degreesOfFreedom, 2)
|
|
assert.deepEqual(result.snapshot.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 0 } })
|
|
let verticalCalled = false
|
|
const vertical = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => { throw new Error('must not run for vertical fixture') },
|
|
solveVerticalDistance: () => ({ size: () => 6, get: (index) => [0, 0, 0, 0, 5, 0][index], delete: () => { verticalCalled = true } }),
|
|
solveDistanceX: () => { throw new Error('must not run for vertical fixture') },
|
|
solveDistanceY: () => ({ size: () => 6, get: (index) => [0, 0, 0, 0, 6, 0][index], delete: () => {} }),
|
|
solveAngle: () => { throw new Error('must not run for vertical fixture') },
|
|
solveCircleRadius: () => { throw new Error('must not run for vertical fixture') },
|
|
solveCircleDiameter: () => { throw new Error('must not run for vertical fixture') },
|
|
solveEqualLines: () => { throw new Error('must not run for vertical fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for vertical fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for vertical fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for vertical fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for vertical fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for vertical fixture') },
|
|
}, createSketch('native-vertical-subset', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 3, y: 4 } }], [
|
|
{ id: 'vertical', type: 'vertical', geometryId: 'line' },
|
|
{ id: 'length', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
]))
|
|
assert.equal(verticalCalled, true)
|
|
assert.deepEqual(vertical.snapshot.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 0, y: 5 } })
|
|
const distanceY = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveVerticalDistance: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveDistanceX: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveDistanceY: () => ({ size: () => 6, get: (index) => [0, 0, 0, 0, 6, 0][index], delete: () => {} }),
|
|
solveAngle: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveCircleRadius: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveCircleDiameter: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveEqualLines: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for distanceY fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for distanceY fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for distanceY fixture') },
|
|
}, createSketch('native-distance-y-subset', [{ id: 'line', type: 'line', start: { x: 1, y: 2 }, end: { x: 3, y: 4 } }], [
|
|
{ id: 'vertical', type: 'vertical', geometryId: 'line' },
|
|
{ id: 'distance-y', type: 'distanceY', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 6 },
|
|
]))
|
|
assert.deepEqual(distanceY.snapshot.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 0, y: 6 } })
|
|
const distanceX = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveVerticalDistance: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveDistanceX: () => ({ size: () => 6, get: (index) => [0, 0, 0, 6, 0, 0][index], delete: () => {} }),
|
|
solveDistanceY: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveAngle: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveCircleRadius: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveCircleDiameter: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveEqualLines: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for distanceX fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for distanceX fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for distanceX fixture') },
|
|
}, createSketch('native-distance-x-subset', [{ id: 'line', type: 'line', start: { x: 1, y: 2 }, end: { x: 3, y: 4 } }], [
|
|
{ id: 'horizontal', type: 'horizontal', geometryId: 'line' },
|
|
{ id: 'distance-x', type: 'distanceX', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 6 },
|
|
]))
|
|
assert.deepEqual(distanceX.snapshot.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 6, y: 0 } })
|
|
let angleDeleted = false
|
|
const angle = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => { throw new Error('must not run for angle fixture') },
|
|
solveVerticalDistance: () => { throw new Error('must not run for angle fixture') },
|
|
solveDistanceX: () => { throw new Error('must not run for angle fixture') },
|
|
solveDistanceY: () => { throw new Error('must not run for angle fixture') },
|
|
solveAngle: () => ({ size: () => 7, get: (index) => [0, 0, 0, 5 / Math.SQRT2, 5 / Math.SQRT2, 0, 0][index], delete: () => { angleDeleted = true } }),
|
|
solveCircleRadius: () => { throw new Error('must not run for angle fixture') },
|
|
solveCircleDiameter: () => { throw new Error('must not run for angle fixture') },
|
|
solveEqualLines: () => { throw new Error('must not run for angle fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for angle fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for angle fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for angle fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for angle fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for angle fixture') },
|
|
}, createSketch('native-angle-subset', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 3, y: 4 } }], [
|
|
{ id: 'angle', type: 'angle', geometryId: 'line', value: Math.PI / 4 },
|
|
{ id: 'length', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
]))
|
|
assert.equal(angleDeleted, true)
|
|
assert.equal(angle.degreesOfFreedom, 0)
|
|
assert.equal(angle.residual, 0)
|
|
assert.deepEqual(angle.snapshot.geometry[0], { id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 5 / Math.SQRT2, y: 5 / Math.SQRT2 } })
|
|
let radiusDeleted = false
|
|
const circle = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => { throw new Error('must not run for circle fixture') },
|
|
solveVerticalDistance: () => { throw new Error('must not run for circle fixture') },
|
|
solveDistanceX: () => { throw new Error('must not run for circle fixture') },
|
|
solveDistanceY: () => { throw new Error('must not run for circle fixture') },
|
|
solveAngle: () => { throw new Error('must not run for circle fixture') },
|
|
solveCircleRadius: () => ({ size: () => 5, get: (index) => [0, 2, 3, 4, 0][index], delete: () => { radiusDeleted = true } }),
|
|
solveCircleDiameter: () => { throw new Error('must not run for circle radius fixture') },
|
|
solveEqualLines: () => { throw new Error('must not run for circle radius fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for circle radius fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for circle radius fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for circle fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for circle fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for circle fixture') },
|
|
}, createSketch('native-circle-radius-subset', [{ id: 'circle', type: 'circle', center: { x: 2, y: 3 }, radius: 1 }], [
|
|
{ id: 'radius', type: 'radius', geometryId: 'circle', value: 4 },
|
|
]))
|
|
assert.equal(radiusDeleted, true)
|
|
assert.equal(circle.degreesOfFreedom, 0)
|
|
assert.equal(circle.residual, 0)
|
|
assert.deepEqual(circle.snapshot.geometry[0], { id: 'circle', type: 'circle', center: { x: 2, y: 3 }, radius: 4 })
|
|
let diameterDeleted = false
|
|
const diameter = solvePlanegcsSubset({
|
|
solveHorizontalDistance: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveVerticalDistance: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveDistanceX: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveDistanceY: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveAngle: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveCircleRadius: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveCircleDiameter: () => ({ size: () => 5, get: (index) => [0, 2, 3, 4, 0][index], delete: () => { diameterDeleted = true } }),
|
|
solveEqualLines: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveEqualCircles: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveTangentCircles: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveParallelLines: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solvePerpendicularLines: () => { throw new Error('must not run for circle diameter fixture') },
|
|
solveCoincidentLines: () => { throw new Error('must not run for circle diameter fixture') },
|
|
}, createSketch('native-circle-diameter-subset', [{ id: 'circle', type: 'circle', center: { x: 2, y: 3 }, radius: 1 }], [
|
|
{ id: 'diameter', type: 'diameter', geometryId: 'circle', value: 8 },
|
|
]))
|
|
assert.equal(diameterDeleted, true)
|
|
assert.equal(diameter.degreesOfFreedom, 0)
|
|
assert.equal(diameter.residual, 0)
|
|
assert.deepEqual(diameter.snapshot.geometry[0], { id: 'circle', type: 'circle', center: { x: 2, y: 3 }, radius: 4 })
|
|
const coincidentEndpointCalls: Array<{ firstEnd: boolean; secondEnd: boolean }> = []
|
|
const solveCoincidentLinePoints: NonNullable<PlanegcsWasmModule['solveCoincidentLinePoints']> = (...args) => {
|
|
const [firstStartX, firstStartY, firstEndX, firstEndY, , , , , , firstEnd, secondEnd] = args
|
|
coincidentEndpointCalls.push({ firstEnd, secondEnd })
|
|
const selectedFirstX = firstEnd ? firstEndX : firstStartX
|
|
const selectedFirstY = firstEnd ? firstEndY : firstStartY
|
|
const secondStartX = secondEnd ? selectedFirstX - 3 : selectedFirstX
|
|
const secondStartY = secondEnd ? selectedFirstY - 4 : selectedFirstY
|
|
const secondEndX = secondEnd ? selectedFirstX : selectedFirstX + 3
|
|
const secondEndY = secondEnd ? selectedFirstY : selectedFirstY + 4
|
|
const values = [0, firstStartX, firstStartY, firstEndX, firstEndY, secondStartX, secondStartY, secondEndX, secondEndY, 0]
|
|
return { size: () => values.length, get: (index: number) => values[index], delete: () => {} }
|
|
}
|
|
const ellipseAlignmentCalls: number[] = []
|
|
let ellipseAlignmentSetCalls = 0
|
|
const solveEllipseInternalAlignment: NonNullable<PlanegcsWasmModule['solveEllipseInternalAlignment']> = (centerX, centerY, majorRadius, minorRadius, rotation, alignmentType) => {
|
|
ellipseAlignmentCalls.push(alignmentType)
|
|
const focalDistance = Math.sqrt(majorRadius * majorRadius - minorRadius * minorRadius)
|
|
const focusX = centerX + focalDistance * Math.cos(rotation)
|
|
const focusY = centerY + focalDistance * Math.sin(rotation)
|
|
const values = [0, centerX, centerY, focusX, focusY, minorRadius, 0, 0, 0, 0, 0]
|
|
return { size: () => values.length, get: (index: number) => values[index], delete: () => {} }
|
|
}
|
|
const solveEllipseInternalAlignmentSet: NonNullable<PlanegcsWasmModule['solveEllipseInternalAlignmentSet']> = (centerX, centerY, majorRadius, minorRadius, rotation) => {
|
|
ellipseAlignmentSetCalls += 1
|
|
const focalDistance = Math.sqrt(majorRadius * majorRadius - minorRadius * minorRadius)
|
|
const values = [0, centerX, centerY, centerX + focalDistance * Math.cos(rotation), centerY + focalDistance * Math.sin(rotation), minorRadius, 0, 0, 0, 0]
|
|
return { size: () => values.length, get: (index: number) => values[index], delete: () => {} }
|
|
}
|
|
const solveCubicBsplineWeight: NonNullable<PlanegcsWasmModule['solveCubicBsplineWeight']> = (...args) => {
|
|
const [pole0X, pole0Y, pole1X, pole1Y, pole2X, pole2Y, pole3X, pole3Y, weight0, weight1, weight2, weight3, controlPointIndex, targetWeight] = args
|
|
const poles = [{ x: pole0X, y: pole0Y }, { x: pole1X, y: pole1Y }, { x: pole2X, y: pole2Y }, { x: pole3X, y: pole3Y }]
|
|
const solvedWeights = [weight0, weight1, weight2, weight3]
|
|
solvedWeights[controlPointIndex] = targetWeight
|
|
const helper = poles[controlPointIndex]
|
|
const values = [0, ...solvedWeights, helper.x, helper.y, targetWeight, 0, 0]
|
|
return { size: () => values.length, get: (index: number) => values[index], delete: () => {} }
|
|
}
|
|
const relationModule = {
|
|
solveHorizontalDistance: () => { throw new Error('must not run for relation fixture') },
|
|
solveVerticalDistance: () => { throw new Error('must not run for relation fixture') },
|
|
solveDistanceX: () => { throw new Error('must not run for relation fixture') },
|
|
solveDistanceY: () => { throw new Error('must not run for relation fixture') },
|
|
solveAngle: () => { throw new Error('must not run for relation fixture') },
|
|
solveCircleRadius: () => { throw new Error('must not run for relation fixture') },
|
|
solveCircleDiameter: () => { throw new Error('must not run for relation fixture') },
|
|
solveEqualLines: () => ({ size: () => 10, get: (index: number) => [0, 0, 0, 4, 0, 0, 2, 4, 2, 0][index], delete: () => {} }),
|
|
solveEqualCircles: () => ({ size: () => 8, get: (index: number) => [0, 2, 3, 4, 8, 9, 4, 0][index], delete: () => {} }),
|
|
solveTangentCircles: () => ({ size: () => 8, get: (index: number) => [0, 2, 3, 4, 10, 3, 4, 0][index], delete: () => {} }),
|
|
solvePointSymmetry: () => ({ size: () => 8, get: (index: number) => [0, 1, 2, 7, 10, 4, 6, 0][index], delete: () => {} }),
|
|
solvePointOnLine: () => ({ size: () => 8, get: (index: number) => [0, 2, 0, 0, 0, 4, 0, 0][index], delete: () => {} }),
|
|
solvePointOnCircle: () => ({ size: () => 7, get: (index: number) => [0, 4, 6.464101615137754, 2, 3, 4, 0][index], delete: () => {} }),
|
|
solvePointOnArc: () => ({ size: () => 9, get: (index: number) => [0, 2, 7, 2, 3, 4, 0, Math.PI, 0][index], delete: () => {} }),
|
|
solvePointOnEllipse: () => ({ size: () => 9, get: (index: number) => [0, 2, 6, 2, 3, 6, 3, 3, 0][index], delete: () => {} }),
|
|
solvePointOnCubicBspline: () => ({ size: () => 6, get: (index: number) => [0, 2, 1.5, 0.5, 0, 0][index], delete: () => {} }),
|
|
solveParallelLines: () => ({ size: () => 10, get: (index: number) => [0, 0, 0, 4, 0, 0, 2, 5, 2, 0][index], delete: () => {} }),
|
|
solvePerpendicularLines: () => ({ size: () => 10, get: (index: number) => [0, 0, 0, 4, 0, 0, 2, 0, 7, 0][index], delete: () => {} }),
|
|
solveCoincidentLines: () => ({ size: () => 10, get: (index: number) => [0, 0, 0, 4, 0, 4, 0, 5, 2, 0][index], delete: () => {} }),
|
|
solveCoincidentLinePoints,
|
|
solveSnellsLawLines: () => ({ size: () => 14, get: (index: number) => [0, 0, 0, -4, 4, 0, 0, 4, 3, -5, 0, 5, 0, 0][index], delete: () => {} }),
|
|
solveEllipseInternalAlignment,
|
|
solveEllipseInternalAlignmentSet,
|
|
solveCubicBsplineWeight,
|
|
}
|
|
const lines = [
|
|
{ id: 'first', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
|
{ id: 'second', type: 'line' as const, start: { x: 0, y: 2 }, end: { x: 3, y: 6 } },
|
|
]
|
|
const equalLines = solvePlanegcsSubset(relationModule, createSketch('native-equal-lines-subset', lines, [{ id: 'equal', type: 'equal', firstGeometryId: 'first', secondGeometryId: 'second' }]))
|
|
assert.equal(equalLines.degreesOfFreedom, 1)
|
|
assert.equal(equalLines.residual, 0)
|
|
assert.deepEqual(equalLines.snapshot.geometry[1], { id: 'second', type: 'line', start: { x: 0, y: 2 }, end: { x: 4, y: 2 } })
|
|
const equalCircles = solvePlanegcsSubset(relationModule, createSketch('native-equal-circles-subset', [
|
|
{ id: 'first', type: 'circle', center: { x: 2, y: 3 }, radius: 4 },
|
|
{ id: 'second', type: 'circle', center: { x: 8, y: 9 }, radius: 1 },
|
|
], [{ id: 'equal', type: 'equal', firstGeometryId: 'first', secondGeometryId: 'second' }]))
|
|
assert.equal(equalCircles.degreesOfFreedom, 0)
|
|
assert.equal(equalCircles.residual, 0)
|
|
assert.deepEqual(equalCircles.snapshot.geometry[1], { id: 'second', type: 'circle', center: { x: 8, y: 9 }, radius: 4 })
|
|
const tangentCircles = solvePlanegcsSubset(relationModule, createSketch('native-tangent-circles-subset', [
|
|
{ id: 'first', type: 'circle', center: { x: 2, y: 3 }, radius: 4 },
|
|
{ id: 'second', type: 'circle', center: { x: 10, y: 3 }, radius: 1 },
|
|
], [{ id: 'tangent', type: 'tangent', firstGeometryId: 'first', secondGeometryId: 'second' }]))
|
|
assert.equal(tangentCircles.degreesOfFreedom, 0)
|
|
assert.ok(Math.abs(tangentCircles.residual) <= 1e-12)
|
|
assert.deepEqual(tangentCircles.snapshot.geometry[1], { id: 'second', type: 'circle', center: { x: 10, y: 3 }, radius: 4 })
|
|
const symmetric = solvePlanegcsSubset(relationModule, createSketch('native-symmetric-subset', [
|
|
{ id: 'first', type: 'point', position: { x: 1, y: 2 } },
|
|
{ id: 'second', type: 'point', position: { x: 0, y: 0 } },
|
|
{ id: 'center', type: 'point', position: { x: 4, y: 6 } },
|
|
], [{ id: 'symmetric', type: 'symmetric', first: { geometryId: 'first', point: 'position' }, second: { geometryId: 'second', point: 'position' }, center: { geometryId: 'center', point: 'position' } }]))
|
|
assert.equal(symmetric.degreesOfFreedom, 0)
|
|
assert.equal(symmetric.residual, 0)
|
|
assert.deepEqual(symmetric.snapshot.geometry[1], { id: 'second', type: 'point', position: { x: 7, y: 10 } })
|
|
const pointOnObject = solvePlanegcsSubset(relationModule, createSketch('native-point-on-object-subset', [
|
|
{ id: 'point', type: 'point', position: { x: 2, y: 3 } },
|
|
{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
|
], [{ id: 'point-on-object', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'line' }]))
|
|
assert.equal(pointOnObject.degreesOfFreedom, 0)
|
|
assert.equal(pointOnObject.residual, 0)
|
|
assert.deepEqual(pointOnObject.snapshot.geometry[0], { id: 'point', type: 'point', position: { x: 2, y: 0 } })
|
|
const pointOnCircle = solvePlanegcsSubset(relationModule, createSketch('native-point-on-circle-subset', [
|
|
{ id: 'point', type: 'point', position: { x: 4, y: 6 } },
|
|
{ id: 'circle', type: 'circle', center: { x: 2, y: 3 }, radius: 4 },
|
|
], [{ id: 'point-on-circle', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'circle' }]))
|
|
assert.equal(pointOnCircle.degreesOfFreedom, 0)
|
|
assert.equal(pointOnCircle.residual, 0)
|
|
assert.deepEqual(pointOnCircle.snapshot.geometry[0], { id: 'point', type: 'point', position: { x: 4, y: 6.464101615137754 } })
|
|
const pointOnArc = solvePlanegcsSubset(relationModule, createSketch('native-point-on-arc-subset', [
|
|
{ id: 'point', type: 'point', position: { x: 2, y: 6 } },
|
|
{ id: 'arc', type: 'arc', center: { x: 2, y: 3 }, radius: 4, startAngle: 0, endAngle: Math.PI },
|
|
], [{ id: 'point-on-arc', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'arc' }]))
|
|
assert.equal(pointOnArc.degreesOfFreedom, 0)
|
|
assert.equal(pointOnArc.residual, 0)
|
|
assert.deepEqual(pointOnArc.snapshot.geometry[0], { id: 'point', type: 'point', position: { x: 2, y: 7 } })
|
|
assert.deepEqual(pointOnArc.snapshot.geometry[1], { id: 'arc', type: 'arc', center: { x: 2, y: 3 }, radius: 4, startAngle: 0, endAngle: Math.PI })
|
|
const pointOnEllipse = solvePlanegcsSubset(relationModule, createSketch('native-point-on-ellipse-subset', [
|
|
{ id: 'point', type: 'point', position: { x: 2, y: 7 } },
|
|
{ id: 'ellipse', type: 'ellipse', center: { x: 2, y: 3 }, majorRadius: 5, minorRadius: 3, rotation: 0 },
|
|
], [{ id: 'point-on-ellipse', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'ellipse' }]))
|
|
assert.equal(pointOnEllipse.degreesOfFreedom, 0)
|
|
assert.equal(pointOnEllipse.residual, 0)
|
|
assert.deepEqual(pointOnEllipse.snapshot.geometry[0], { id: 'point', type: 'point', position: { x: 2, y: 6 } })
|
|
assert.deepEqual(pointOnEllipse.snapshot.geometry[1], { id: 'ellipse', type: 'ellipse', center: { x: 2, y: 3 }, majorRadius: 5, minorRadius: 3, rotation: 0 })
|
|
const internalAlignment = solvePlanegcsSubset(relationModule, createSketch('native-ellipse-internal-alignment-subset', [
|
|
{ id: 'ellipse', type: 'ellipse', center: { x: 2, y: 3 }, majorRadius: 5, minorRadius: 3, rotation: 0.25 },
|
|
], [
|
|
{ id: 'major', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-major' },
|
|
{ id: 'minor', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-minor' },
|
|
{ id: 'focus-1', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-focus' },
|
|
{ id: 'focus-2', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 1, alignmentType: 'ellipse-focus' },
|
|
]))
|
|
assert.equal(internalAlignment.status, 'under-constrained')
|
|
assert.equal(internalAlignment.degreesOfFreedom, 5)
|
|
assert.ok(internalAlignment.residual <= 1e-12)
|
|
assert.equal(ellipseAlignmentSetCalls, 1)
|
|
assert.deepEqual(ellipseAlignmentCalls, [])
|
|
assert.deepEqual(internalAlignment.snapshot.geometry[0], { id: 'ellipse', type: 'ellipse', center: { x: 2, y: 3 }, majorRadius: 5, minorRadius: 3, rotation: 0.25 })
|
|
const pointOnBspline = solvePlanegcsSubset(relationModule, createSketch('native-point-on-B-spline-subset', [
|
|
{ id: 'point', type: 'point', position: { x: 2, y: 1 } },
|
|
{ id: 'bspline', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 2 }, { x: 4, y: 0 }], weights: [1, 1, 1, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1], periodic: false },
|
|
], [{ id: 'point-on-B-spline', type: 'pointOnObject', point: { geometryId: 'point', point: 'position' }, geometryId: 'bspline' }]))
|
|
assert.equal(pointOnBspline.degreesOfFreedom, 0)
|
|
assert.equal(pointOnBspline.residual, 0)
|
|
assert.deepEqual(pointOnBspline.snapshot.geometry[0], { id: 'point', type: 'point', position: { x: 2, y: 1.5 } })
|
|
const bsplineWeight = solvePlanegcsSubset(relationModule, createSketch('native-B-spline-weight-subset', [
|
|
{ id: 'spline', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 2 }, { x: 4, y: 0 }], weights: [1, 0.75, 1.25, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1], periodic: false },
|
|
], [
|
|
{ id: 'control-point-helper', type: 'internalAlignment', geometryId: 'spline', internalGeometryIndex: 1, alignmentType: 'bspline-control-point' },
|
|
{ id: 'weight', type: 'weight', geometryId: 'spline', controlPointIndex: 1, value: 1.5 },
|
|
]))
|
|
assert.equal(bsplineWeight.status, 'under-constrained')
|
|
assert.equal(bsplineWeight.degreesOfFreedom, 11)
|
|
assert.equal(bsplineWeight.residual, 0)
|
|
assert.deepEqual(bsplineWeight.snapshot.geometry[0], { id: 'spline', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 2 }, { x: 4, y: 0 }], weights: [1, 1.5, 1.25, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1], periodic: false })
|
|
const snellsLaw = solvePlanegcsSubset(relationModule, createSketch('native-snells-law-subset', [
|
|
{ id: 'incident', type: 'line', start: { x: 0, y: 0 }, end: { x: -4, y: 4 } },
|
|
{ id: 'refracted', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 3 } },
|
|
{ id: 'boundary', type: 'line', start: { x: -5, y: 0 }, end: { x: 5, y: 0 } },
|
|
], [
|
|
{ id: 'junction', type: 'coincident', first: { geometryId: 'incident', point: 'start' }, second: { geometryId: 'refracted', point: 'start' } },
|
|
{ id: 'boundary-point', type: 'pointOnObject', point: { geometryId: 'incident', point: 'start' }, geometryId: 'boundary' },
|
|
{ id: 'snells-law', type: 'snellsLaw', first: { geometryId: 'incident', point: 'start' }, second: { geometryId: 'refracted', point: 'start' }, boundaryGeometryId: 'boundary', value: 1.5 },
|
|
]))
|
|
assert.equal(snellsLaw.status, 'under-constrained')
|
|
assert.equal(snellsLaw.degreesOfFreedom, 8)
|
|
assert.equal(snellsLaw.residual, 0)
|
|
assert.deepEqual(snellsLaw.snapshot.geometry[1], { id: 'refracted', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 3 } })
|
|
const blocked = solvePlanegcsSubset(relationModule, createSketch('native-block-subset', [
|
|
{ id: 'blocked-line', type: 'line', start: { x: 1, y: 2 }, end: { x: 4, y: 6 } },
|
|
], [{ id: 'block', type: 'block', geometryId: 'blocked-line' }]))
|
|
assert.equal(blocked.status, 'solved')
|
|
assert.equal(blocked.degreesOfFreedom, 0)
|
|
assert.deepEqual(blocked.snapshot.geometry[0], { id: 'blocked-line', type: 'line', start: { x: 1, y: 2 }, end: { x: 4, y: 6 } })
|
|
const parallel = solvePlanegcsSubset(relationModule, createSketch('native-parallel-subset', lines, [{ id: 'parallel', type: 'parallel', firstGeometryId: 'first', secondGeometryId: 'second' }]))
|
|
assert.equal(parallel.degreesOfFreedom, 7)
|
|
assert.deepEqual(parallel.snapshot.geometry[1], { id: 'second', type: 'line', start: { x: 0, y: 2 }, end: { x: 5, y: 2 } })
|
|
const perpendicular = solvePlanegcsSubset(relationModule, createSketch('native-perpendicular-subset', lines, [{ id: 'perpendicular', type: 'perpendicular', firstGeometryId: 'first', secondGeometryId: 'second' }]))
|
|
assert.equal(perpendicular.degreesOfFreedom, 7)
|
|
assert.deepEqual(perpendicular.snapshot.geometry[1], { id: 'second', type: 'line', start: { x: 0, y: 2 }, end: { x: 0, y: 7 } })
|
|
for (const firstPoint of ['start', 'end'] as const) {
|
|
for (const secondPoint of ['start', 'end'] as const) {
|
|
const coincident = solvePlanegcsSubset(relationModule, createSketch(`native-coincident-${firstPoint}-${secondPoint}-subset`, lines, [{ id: 'coincident', type: 'coincident', first: { geometryId: 'first', point: firstPoint }, second: { geometryId: 'second', point: secondPoint } }]))
|
|
assert.equal(coincident.degreesOfFreedom, 1)
|
|
assert.equal(coincident.residual, 0)
|
|
const firstLine = coincident.snapshot.geometry[0]
|
|
const secondLine = coincident.snapshot.geometry[1]
|
|
if (firstLine.type !== 'line' || secondLine.type !== 'line') throw new Error('Coincident endpoint fixture must return two lines.')
|
|
assert.deepEqual(secondLine[secondPoint], firstLine[firstPoint])
|
|
assert.ok(Math.abs(Math.hypot(secondLine.end.x - secondLine.start.x, secondLine.end.y - secondLine.start.y) - 5) <= 1e-12)
|
|
}
|
|
}
|
|
assert.deepEqual(coincidentEndpointCalls, [
|
|
{ firstEnd: false, secondEnd: false },
|
|
{ firstEnd: false, secondEnd: true },
|
|
{ firstEnd: true, secondEnd: false },
|
|
{ firstEnd: true, secondEnd: true },
|
|
])
|
|
assert.throws(() => solvePlanegcsSubset({ ...relationModule }, createSketch('unsupported', [{ id: 'circle', type: 'circle', center: { x: 0, y: 0 }, radius: 1 }])), /positive Radius/)
|
|
})
|
|
|
|
test('planegcs Worker provider cancels by replacement and recovers after a crash', async () => {
|
|
type FakeWorker = {
|
|
postMessage(message: { type: string; request?: SketchSolverRequest }): void
|
|
terminate(): void
|
|
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void): void
|
|
removeEventListener(type: 'message' | 'error', listener: (event: MessageEvent | ErrorEvent) => void): void
|
|
emitError(message: string): void
|
|
terminated: boolean
|
|
}
|
|
const workers: FakeWorker[] = []
|
|
const makeWorker = (): FakeWorker => {
|
|
const messageListeners = new Set<(event: MessageEvent) => void>()
|
|
const errorListeners = new Set<(event: ErrorEvent) => void>()
|
|
const worker: FakeWorker = {
|
|
terminated: false,
|
|
postMessage(message) {
|
|
if (message.type === 'initialize') queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'ready', capabilities: { providerId: 'freecad.planegcs-wasm', providerVersion: '1.1.1-embind-subset.12', engine: 'planegcs-wasm', availability: 'available', compatibility: 'experimental', supportedGeometry: ['line', 'circle'], supportedConstraints: ['horizontal', 'vertical', 'parallel', 'perpendicular', 'distance', 'distanceX', 'distanceY', 'angle', 'radius', 'diameter', 'equal', 'tangent', 'coincident'] } } } as MessageEvent)))
|
|
if (message.type === 'solve' && message.request && message.request.requestId !== 'cancel-me') {
|
|
const request = message.request
|
|
const result = solvePlanegcsSubset({ solveHorizontalDistance: () => ({ size: () => 6, get: (index) => [0, 0, 0, 5, 0, 0][index], delete: () => {} }), solveVerticalDistance: () => ({ size: () => 6, get: (index) => [0, 0, 0, 0, 5, 0][index], delete: () => {} }), solveDistanceX: () => ({ size: () => 6, get: (index) => [0, 0, 0, 5, 0, 0][index], delete: () => {} }), solveDistanceY: () => ({ size: () => 6, get: (index) => [0, 0, 0, 0, 5, 0][index], delete: () => {} }), solveAngle: () => ({ size: () => 7, get: (index) => [0, 0, 0, 5 / Math.SQRT2, 5 / Math.SQRT2, 0, 0][index], delete: () => {} }), solveCircleRadius: () => ({ size: () => 5, get: (index) => [0, 2, 3, 4, 0][index], delete: () => {} }), solveCircleDiameter: () => ({ size: () => 5, get: (index) => [0, 2, 3, 4, 0][index], delete: () => {} }), solveEqualLines: () => ({ size: () => 10, get: (index) => [0, 0, 0, 4, 0, 0, 2, 4, 2, 0][index], delete: () => {} }), solveEqualCircles: () => ({ size: () => 8, get: (index) => [0, 2, 3, 4, 8, 9, 4, 0][index], delete: () => {} }), solveTangentCircles: () => ({ size: () => 8, get: (index) => [0, 2, 3, 4, 10, 3, 4, 0][index], delete: () => {} }), solveParallelLines: () => ({ size: () => 10, get: (index) => [0, 0, 0, 4, 0, 0, 2, 5, 2, 0][index], delete: () => {} }), solvePerpendicularLines: () => ({ size: () => 10, get: (index) => [0, 0, 0, 4, 0, 0, 2, 0, 7, 0][index], delete: () => {} }), solveCoincidentLines: () => ({ size: () => 10, get: (index) => [0, 0, 0, 4, 0, 4, 0, 5, 2, 0][index], delete: () => {} }) }, request.snapshot)
|
|
queueMicrotask(() => messageListeners.forEach((listener) => listener({ data: { type: 'response', response: { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, generation: request.generation, provider: { providerId: 'freecad.planegcs-wasm', providerVersion: '1.1.1-embind-subset.12', engine: 'planegcs-wasm', availability: 'available', compatibility: 'experimental', supportedGeometry: ['line', 'circle'], supportedConstraints: ['horizontal', 'vertical', 'parallel', 'perpendicular', 'distance', 'distanceX', 'distanceY', 'angle', 'radius', 'diameter', 'equal', 'tangent', 'coincident'] }, result } } } as MessageEvent)))
|
|
}
|
|
},
|
|
terminate() { worker.terminated = true },
|
|
addEventListener(type, listener) { (type === 'message' ? messageListeners : errorListeners).add(listener as never) },
|
|
removeEventListener(type, listener) { (type === 'message' ? messageListeners : errorListeners).delete(listener as never) },
|
|
emitError(message) { errorListeners.forEach((listener) => listener({ message } as ErrorEvent)) },
|
|
}
|
|
workers.push(worker)
|
|
return worker
|
|
}
|
|
const provider = new PlanegcsWorkerProvider({ workerFactory: makeWorker, initializationTimeoutMs: 100 })
|
|
const snapshot = createSketch('worker-native-subset', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 3, y: 4 } }], [
|
|
{ id: 'horizontal', type: 'horizontal', geometryId: 'line' },
|
|
{ id: 'length', type: 'distance', first: { geometryId: 'line', point: 'start' }, second: { geometryId: 'line', point: 'end' }, value: 5 },
|
|
])
|
|
const cancelledRequest = { protocolVersion: SKETCH_SOLVER_PROTOCOL_VERSION, requestId: 'cancel-me', documentId: 'doc', documentVersion: 2, generation: 1, snapshot }
|
|
const controller = new AbortController()
|
|
const cancelled = provider.solve(cancelledRequest, controller.signal)
|
|
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
controller.abort()
|
|
await assert.rejects(cancelled, (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
|
|
assert.equal(workers[0].terminated, true)
|
|
assert.equal(workers.length, 2)
|
|
|
|
const request = { ...cancelledRequest, requestId: 'solve-after-cancel', generation: 2 }
|
|
const response = await provider.solve(request, new AbortController().signal)
|
|
assert.equal(response.result.status, 'under-constrained')
|
|
workers[1].emitError('synthetic planegcs crash')
|
|
assert.equal(provider.capabilities().availability, 'unavailable')
|
|
assert.equal(workers.length, 3)
|
|
assert.equal((await provider.initialize()).availability, 'available')
|
|
provider.dispose()
|
|
})
|
|
|
|
test('sketcher operations are facade transactions and survive the project fallback store', async () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.app.document.getActive().version
|
|
const added = facade.app.sketcher.addGeometry('sketch', { id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } })
|
|
assert.equal(added.geometry.length, 1)
|
|
const projected = facade.app.sketcher.projectGeometry('sketch', 'line-1', 'projection-line')
|
|
assert.equal(projected.geometry.find((geometry) => geometry.id === 'projection-line')?.construction, true)
|
|
const copied = facade.app.sketcher.carbonCopy('sketch', ['line-1', 'projection-line'], 'cc')
|
|
assert.deepEqual(copied.geometry.map((geometry) => geometry.id), ['line-1', 'projection-line', 'cc-line-1', 'cc-projection-line'])
|
|
assert.throws(() => facade.app.sketcher.projectGeometry('sketch', 'missing'), /does not exist/)
|
|
assert.throws(() => facade.app.sketcher.carbonCopy('sketch', ['line-1'], 'cc'), /duplicate/)
|
|
facade.app.sketcher.addConstraint('sketch', { id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' })
|
|
const solved = facade.app.sketcher.solve('sketch')
|
|
assert.equal(solved.status, 'under-constrained')
|
|
assert.ok(facade.app.document.getActive().version >= before + 3)
|
|
assert.match(String(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'ConstraintStatus')?.value), /Under-constrained/)
|
|
await facade.project.save()
|
|
const loaded = await facade.project.load('doc-pump-housing')
|
|
assert.equal(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.constraints[0].id, 'horizontal-1')
|
|
})
|
|
|
|
test('external sketch geometry persists stable topology references and dependency edges', async () => {
|
|
const facade = createMockFacade()
|
|
const reference = createPersistedTopoRef('pad', { shapeId: 'pad', kind: 'edge', persistentId: 'edge:profile:1', topologyVersion: 2, status: 'stable', signature: 'line:10' }, 4)
|
|
facade.app.sketcher.addExternalGeometry('sketch', {
|
|
id: 'external-1',
|
|
source: reference,
|
|
projection: { id: 'projected-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } },
|
|
construction: true,
|
|
})
|
|
const sketch = facade.app.sketcher.get('sketch')
|
|
assert.equal(sketch?.externalGeometry.length, 1)
|
|
assert.deepEqual(facade.app.document.getDependencies().find((edge) => edge.propertyName === 'ExternalGeometry:external-1'), {
|
|
sourceId: 'sketch', targetId: 'pad', relation: 'topo-ref', propertyName: 'ExternalGeometry:external-1', reference: 'edge:profile:1',
|
|
})
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(facade.app.document.getActive())
|
|
const loaded = await persistence.load('doc-pump-housing')
|
|
assert.deepEqual(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.externalGeometry, sketch?.externalGeometry)
|
|
assert.throws(() => facade.app.sketcher.addExternalGeometry('sketch', { id: 'self', source: { ...reference, objectId: 'sketch' }, projection: { id: 'self-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true }), /cannot import external geometry from itself/)
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('Sketcher solve command is selection-aware and emits the standard command lifecycle', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
facade.selection.select('sketch')
|
|
const events: string[] = []
|
|
facade.subscribe((event) => { if (event.type.startsWith('command.')) events.push(event.type) })
|
|
facade.gui.command.execute({ commandId: 'solve-sketch' })
|
|
assert.deepEqual(events, ['command.started', 'command.completed'])
|
|
assert.equal(facade.app.document.getObject('sketch')?.sketch?.solver.status, 'solved')
|
|
facade.selection.select('pad')
|
|
assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('solve-sketch').reason || '', /Sketcher sketch/)
|
|
facade.selection.clear()
|
|
assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled')
|
|
})
|
|
|
|
test('dependency graph propagates dirty state and orders dependencies', () => {
|
|
const graph = new DependencyGraph([
|
|
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
|
|
{ sourceId: 'fillet', targetId: 'pocket', relation: 'link' },
|
|
{ sourceId: 'body', targetId: 'fillet', relation: 'link' },
|
|
], ['pad', 'pocket', 'fillet', 'body'])
|
|
const plan = graph.plan(['pad'])
|
|
assert.deepEqual(plan.cycles, [])
|
|
assert.deepEqual(plan.order, ['pad', 'pocket', 'fillet', 'body'])
|
|
assert.deepEqual(plan.levels, [['pad'], ['pocket'], ['fillet'], ['body']])
|
|
assert.deepEqual(plan.affected, ['pad', 'pocket', 'fillet', 'body'])
|
|
graph.addEdge({ sourceId: 'pad', targetId: 'body', relation: 'expression' })
|
|
assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']])
|
|
const branched = new DependencyGraph([{ sourceId: 'left', targetId: 'root', relation: 'link' }, { sourceId: 'right', targetId: 'root', relation: 'link' }], ['root', 'left', 'right'])
|
|
assert.deepEqual(branched.plan(['root']).levels, [['root'], ['left', 'right']])
|
|
})
|
|
|
|
test('generation-aware recompute cancels an older run before accepting its result', async () => {
|
|
let version = 1
|
|
const coordinator = new RecomputeCoordinator(async (object, _document, context) => {
|
|
if (context.generation === 1) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(resolve, 30)
|
|
context.signal.addEventListener('abort', () => {
|
|
clearTimeout(timer)
|
|
const error = new Error('cancelled')
|
|
error.name = 'AbortError'
|
|
reject(error)
|
|
}, { once: true })
|
|
})
|
|
}
|
|
return { status: 'success', updatedObject: object }
|
|
}, () => version)
|
|
const document = recomputeDocumentFixture()
|
|
const first = coordinator.run(document)
|
|
const second = coordinator.run(document)
|
|
assert.equal((await first).status, 'cancelled')
|
|
const accepted = await second
|
|
assert.equal(accepted.status, 'completed')
|
|
assert.equal(accepted.generation, 2)
|
|
assert.deepEqual(accepted.completed, ['root'])
|
|
version = 2
|
|
})
|
|
|
|
test('recompute executes independent dependency levels concurrently and merges in stable order', async () => {
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(),
|
|
tree: [
|
|
{ id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
|
|
{ id: 'left', label: 'Left', type: 'feature', state: 'dirty' },
|
|
{ id: 'right', label: 'Right', type: 'feature', state: 'dirty' },
|
|
],
|
|
objects: [
|
|
{ id: 'root', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'left', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'right', typeId: 'Part::Feature', properties: [] },
|
|
],
|
|
dependencies: [
|
|
{ sourceId: 'left', targetId: 'root', relation: 'link' },
|
|
{ sourceId: 'right', targetId: 'root', relation: 'link' },
|
|
],
|
|
recompute: {
|
|
generation: 0,
|
|
status: 'idle',
|
|
objectStates: { root: 'touched', left: 'touched', right: 'touched' },
|
|
dirtyObjects: ['root'],
|
|
order: [],
|
|
errors: [],
|
|
},
|
|
}
|
|
let active = 0
|
|
let maximumActive = 0
|
|
const coordinator = new RecomputeCoordinator(async (object) => {
|
|
active += 1
|
|
maximumActive = Math.max(maximumActive, active)
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
active -= 1
|
|
return { status: 'success', updatedObject: object }
|
|
}, () => 1)
|
|
|
|
const result = await coordinator.run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.equal(maximumActive, 2)
|
|
assert.deepEqual(result.order, ['root', 'left', 'right'])
|
|
assert.deepEqual(result.completed, ['root', 'left', 'right'])
|
|
assert.deepEqual(result.levels, [['root'], ['left', 'right']])
|
|
})
|
|
|
|
test('recompute rejects results from an obsolete document version', async () => {
|
|
let version = 1
|
|
const coordinator = new RecomputeCoordinator(async () => {
|
|
version = 2
|
|
return { status: 'success' }
|
|
}, () => version)
|
|
const result = await coordinator.run(recomputeDocumentFixture())
|
|
assert.equal(result.status, 'stale')
|
|
assert.deepEqual(result.dirtyObjects, ['root'])
|
|
})
|
|
|
|
test('recompute failure marks dependent objects as upstream-failed', async () => {
|
|
const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
|
|
const coordinator = new RecomputeCoordinator(async (object) => object.id === 'root'
|
|
? { status: 'failed', errors: [{ objectId: object.id, code: 'TEST_FAILURE', message: 'expected failure' }] }
|
|
: { status: 'success' }, () => 1)
|
|
const result = await coordinator.run(document)
|
|
assert.equal(result.status, 'failed')
|
|
assert.deepEqual(result.failed, ['root'])
|
|
assert.deepEqual(result.skipped, ['child'])
|
|
assert.equal(result.objectStates.child, 'upstream-failed')
|
|
assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED'))
|
|
})
|
|
|
|
test('recompute suppression skips dependent nodes without reporting a failure', async () => {
|
|
const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
|
|
document.objects[0].properties.push({ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true })
|
|
const executed: string[] = []
|
|
const coordinator = new RecomputeCoordinator(async (object, source, context) => {
|
|
executed.push(object.id)
|
|
return executeFacadeRecomputeNode(object, source, context)
|
|
}, () => 1)
|
|
const result = await coordinator.run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.deepEqual(executed, ['root'])
|
|
assert.deepEqual(result.suppressed, ['root'])
|
|
assert.deepEqual(result.skipped, ['root', 'child'])
|
|
assert.equal(result.objectStates.root, 'suppressed')
|
|
assert.equal(result.objectStates.child, 'upstream-suppressed')
|
|
assert.deepEqual(result.dirtyObjects, [])
|
|
assert.deepEqual(result.errors, [])
|
|
})
|
|
|
|
test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains the last valid Shape', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-feature', documentVersion: 2 })
|
|
const calls: string[] = []
|
|
const padHoleCounts: number[] = []
|
|
const padRegionPointCounts: number[][] = []
|
|
const taperAngles: Array<number | undefined> = []
|
|
const revolutionProfileRotations: Array<number | undefined> = []
|
|
const grooveProfileRotations: Array<number | undefined> = []
|
|
let padShouldFail = false
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => shape('box-shape'),
|
|
createCylinder: async () => shape('cylinder-shape'),
|
|
createSphere: async (input) => { calls.push(`sphere:${input.center?.join(',')}`); return shape('sphere-shape') },
|
|
createCone: async () => shape('cone-shape'),
|
|
applyPlacement: async (input) => { calls.push(`placement:${input.placement.translation.join(',')}:${input.placement.rotationAxis.join(',')}:${input.placement.rotationAngle}`); return shape(`pattern-copy-${calls.filter((call) => call.startsWith('placement:')).length}`) },
|
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('union-shape') },
|
|
cut: async (input) => { calls.push(`cut:${input.tools.map((entry) => entry.id).join(',')}`); return shape('cut-shape') },
|
|
intersection: async () => shape('intersection-shape'),
|
|
pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); taperAngles.push(input.taperAngle); padHoleCounts.push(input.profile.holes?.length ?? 0); padRegionPointCounts.push([input.profile.outer.length, ...(input.profile.additionalRegions ?? []).map((region) => region.outer.length)]); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') },
|
|
extrude: async (input) => { calls.push(`extrude:${input.length}:${input.direction?.join(',')}:${String(input.symmetricToPlane)}`); return shape('extrude-shape') },
|
|
pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}:${input.profile.outer.length}:${input.length}`); taperAngles.push(input.taperAngle); return shape('pocket-shape') },
|
|
revolution: async (input) => { calls.push(`revolution:${input.angle}:${input.axisDirection?.[1]}`); revolutionProfileRotations.push(input.profileRotationAngle); return shape('revolution-shape') },
|
|
groove: async (input) => { calls.push(`groove:${input.base.id}:${input.angle}:${input.axisDirection?.[1]}`); grooveProfileRotations.push(input.profileRotationAngle); return shape('groove-shape') },
|
|
fillet: async (input) => { calls.push(`fillet:${input.radius}:${input.indexes?.join(',') ?? 'all'}`); return shape('fillet-shape') },
|
|
chamfer: async (input) => { calls.push(`chamfer:${input.distance}:${input.indexes?.join(',') ?? 'all'}`); return shape('chamfer-shape') },
|
|
modeledThread: async (input) => { calls.push(`modeled-thread:${input.minorDiameter}:${input.majorDiameter}:${input.pitch}:${input.depth}:${String(input.leftHanded)}`); return shape('threaded-hole-shape') },
|
|
release: async (released) => { calls.push(`release:${released.id}`) },
|
|
}
|
|
const sketch = { id: 'sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('sketch', [
|
|
{ id: 'a', type: 'line' as const, start: { x: -1, y: -1 }, end: { x: 1, y: -1 } },
|
|
{ id: 'b', type: 'line' as const, start: { x: 1, y: -1 }, end: { x: 1, y: 1 } },
|
|
{ id: 'c', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: -1, y: 1 } },
|
|
{ id: 'd', type: 'line' as const, start: { x: -1, y: 1 }, end: { x: -1, y: -1 } },
|
|
]) }
|
|
const pad = { id: 'pad', typeId: 'PartDesign::Pad', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 10, unit: 'mm' },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
] }
|
|
const pocket = { id: 'pocket', typeId: 'PartDesign::Pocket', properties: [
|
|
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'] },
|
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5, unit: 'mm' },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), version: 2, objects: [sketch, pad, pocket], dependencies: [
|
|
{ sourceId: 'pad', targetId: 'sketch', relation: 'link' },
|
|
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
|
|
{ sourceId: 'pocket', targetId: 'sketch', relation: 'link' },
|
|
], recompute: { generation: 0, status: 'idle', objectStates: { sketch: 'touched', pad: 'touched', pocket: 'touched' }, dirtyObjects: ['sketch'], order: [], errors: [] } }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
|
const result = await new RecomputeCoordinator(executor, () => 2).run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.deepEqual(calls.slice(0, 2), ['pad:4', 'pocket:pad-shape:true:4:5'])
|
|
assert.equal(shapes.get('pad')?.id, 'pad-shape')
|
|
assert.equal(shapes.get('pocket')?.id, 'pocket-shape')
|
|
|
|
const profileSketch = (id: string, loops: Array<Array<[number, number]>>) => ({ id, typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch(id, loops.flatMap((loop, loopIndex) => loop.map((start, pointIndex) => {
|
|
const end = loop[(pointIndex + 1) % loop.length]
|
|
return { id: `${loopIndex}-${pointIndex}`, type: 'line' as const, start: { x: start[0], y: start[1] }, end: { x: end[0], y: end[1] } }
|
|
}))) })
|
|
const outer: Array<[number, number]> = [[0, 0], [6, 0], [6, 6], [0, 6]]
|
|
const profileHole: Array<[number, number]> = [[2, 2], [2, 4], [4, 4], [4, 2]]
|
|
const multiRingSketch = profileSketch('sketch-multi-ring', [profileHole, outer])
|
|
const multiRingPad = { ...pad, id: 'pad-multi-ring', properties: pad.properties.map((property) => property.name === 'Profile' ? { ...property, value: multiRingSketch.id } : property) }
|
|
const multiRingResult = await executor(multiRingPad, { ...document, objects: [multiRingSketch, multiRingPad] }, context)
|
|
assert.equal(multiRingResult.status, 'success')
|
|
assert.equal(padHoleCounts.at(-1), 1)
|
|
|
|
const selfIntersectingSketch = profileSketch('sketch-self-intersecting', [[[0, 0], [4, 4], [0, 4], [4, 0]]])
|
|
const selfIntersectingPad = { ...pad, id: 'pad-self-intersecting', properties: pad.properties.map((property) => property.name === 'Profile' ? { ...property, value: selfIntersectingSketch.id } : property) }
|
|
const selfIntersectingResult = await executor(selfIntersectingPad, { ...document, objects: [selfIntersectingSketch, selfIntersectingPad] }, context)
|
|
assert.equal(selfIntersectingResult.status, 'success')
|
|
assert.deepEqual(padRegionPointCounts.at(-1), [3, 3])
|
|
|
|
const disjointSketch = profileSketch('sketch-disjoint', [outer, [[8, 8], [9, 8], [9, 9], [8, 9]]])
|
|
const disjointPad = { ...pad, id: 'pad-disjoint', properties: pad.properties.map((property) => property.name === 'Profile' ? { ...property, value: disjointSketch.id } : property) }
|
|
const disjointResult = await executor(disjointPad, { ...document, objects: [disjointSketch, disjointPad] }, context)
|
|
assert.equal(disjointResult.status, 'success')
|
|
assert.deepEqual(padRegionPointCounts.at(-1), [4, 4])
|
|
|
|
const openSketch = { id: 'sketch-open', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('sketch-open', [
|
|
{ id: 'open-0', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
|
{ id: 'open-1', type: 'line' as const, start: { x: 4, y: 0 }, end: { x: 4, y: 4 } },
|
|
{ id: 'open-2', type: 'line' as const, start: { x: 4, y: 4 }, end: { x: 0, y: 4 } },
|
|
]) }
|
|
const openPad = { ...pad, id: 'pad-open', properties: pad.properties.map((property) => property.name === 'Profile' ? { ...property, value: openSketch.id } : property) }
|
|
const padCallsBeforeOpen = padRegionPointCounts.length
|
|
const openResult = await executor(openPad, { ...document, objects: [openSketch, openPad] }, context)
|
|
assert.equal(openResult.errors?.[0].code, 'PROFILE_OPEN')
|
|
assert.equal(padRegionPointCounts.length, padCallsBeforeOpen)
|
|
|
|
const unsupportedPadMode = { ...pad, id: 'pad-unsupported-mode', properties: [...pad.properties, { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'] }] }
|
|
const unsupportedPadModeResult = await executor(unsupportedPadMode, document, context)
|
|
assert.equal(unsupportedPadModeResult.errors?.[0].code, 'PAD_TYPE_UNSUPPORTED')
|
|
const taperedPad = { ...pad, id: 'pad-tapered', properties: [...pad.properties, { name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 2 }] }
|
|
const taperedPadResult = await executor(taperedPad, document, context)
|
|
assert.equal(taperedPadResult.status, 'success')
|
|
assert.equal(taperAngles.at(-1), 2)
|
|
const twoSidedPad = { ...pad, id: 'pad-two-sided', properties: [...pad.properties,
|
|
{ name: 'SideType', label: 'Side type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Two sides', options: ['One side', 'Two sides', 'Symmetric'] },
|
|
{ name: 'Length2', label: 'Length 2', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
|
|
] }
|
|
assert.equal((await executor(twoSidedPad, document, context)).status, 'success')
|
|
assert.ok(calls.includes('union:pad-shape,pad-shape'))
|
|
|
|
padShouldFail = true
|
|
const failed = await executor(pad, document, context)
|
|
assert.equal(failed.status, 'failed')
|
|
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.equal(shapes.get('pad')?.id, 'pad-shape')
|
|
padShouldFail = false
|
|
const targetRef = { schemaVersion: 1 as const, objectId: 'pad', kind: 'face' as const, persistentId: 'pad-top', topologyVersion: 2, generation: 1, status: 'stable' as const }
|
|
const padWithTarget = { ...pad, topology: {
|
|
shapeId: 'pad-shape', documentVersion: 2, generation: 1,
|
|
entries: [{ ref: { shapeId: 'pad-shape', kind: 'face' as const, persistentId: 'pad-top', topologyVersion: 2, status: 'stable' as const }, signature: { kind: 'face' as const, canonical: 'pad-top', hash: 'pad-top', centroid: [0, 0, 8] as [number, number, number], bounds: { min: [-5, -5, 8] as [number, number, number], max: [5, 5, 8] as [number, number, number] }, area: 100, normal: [0, 0, 1] as [number, number, number], analytic: { type: 'Plane', tolerance: 1e-7, adjacencyDegree: 4, incidentCount: 4 } } }],
|
|
migration: { previousGeneration: null, matches: [] },
|
|
history: { operationId: 'pad:generation:1', provider: 'signature-fallback' as const, relations: [], counts: { preserved: 0, modified: 0, generated: 1, deleted: 0, ambiguous: 0 } },
|
|
} }
|
|
const upToFace = { ...pocket, properties: [...pocket.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Up to face' } : property), { name: 'UpToFace', label: 'Up to face', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLinkSub' as const, value: targetRef }] }
|
|
const upToFaceResult = await executor(upToFace, { ...document, objects: [sketch, padWithTarget, upToFace] }, context)
|
|
assert.equal(upToFaceResult.status, 'success')
|
|
assert.ok(calls.includes('pocket:pad-shape:false:4:8'))
|
|
const taperedPocket = { ...pocket, id: 'pocket-tapered', properties: [...pocket.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Dimension' } : property), { name: 'TaperAngle', label: 'Taper angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 2 }] }
|
|
const taperedPocketResult = await executor(taperedPocket, document, context)
|
|
assert.equal(taperedPocketResult.status, 'success')
|
|
assert.equal(taperAngles.at(-1), 2)
|
|
const twoSidedPocket = { ...pocket, id: 'pocket-two-sided', properties: [...pocket.properties,
|
|
{ name: 'SideType', label: 'Side type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Two sides', options: ['One side', 'Two sides', 'Symmetric'] },
|
|
{ name: 'Length2', label: 'Length 2', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 3 },
|
|
] }
|
|
assert.equal((await executor(twoSidedPocket, document, context)).status, 'success')
|
|
assert.ok(calls.includes('union:pad-shape,pad-shape'))
|
|
assert.ok(calls.includes('cut:union-shape'))
|
|
|
|
const revolution = { id: 'revolution', typeId: 'PartDesign::Revolution', properties: [
|
|
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 270, unit: 'deg' },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
const revolutionResult = await executor(revolution, document, context)
|
|
assert.equal(revolutionResult.status, 'success')
|
|
assert.equal(shapes.get('revolution')?.id, 'revolution-shape')
|
|
assert.ok(calls.includes('revolution:270:-1'))
|
|
const symmetricRevolution = { ...revolution, id: 'revolution-symmetric', properties: [...revolution.properties, { name: 'Midplane', label: 'Symmetric', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
const symmetricRevolutionResult = await executor(symmetricRevolution, document, context)
|
|
assert.equal(symmetricRevolutionResult.status, 'success')
|
|
assert.ok(calls.includes('revolution:270:-1'))
|
|
assert.equal(revolutionProfileRotations.at(-1), -135)
|
|
const twoAngleRevolution = { ...revolution, id: 'revolution-two-angles', properties: [...revolution.properties,
|
|
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Two angles', options: ['Angle', 'Two angles'] },
|
|
{ name: 'Angle2', label: 'Reverse angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 45 },
|
|
] }
|
|
assert.equal((await executor(twoAngleRevolution, document, context)).status, 'success')
|
|
assert.ok(calls.includes('revolution:315:-1'))
|
|
assert.equal(revolutionProfileRotations.at(-1), -45)
|
|
const numericTwoAngleRevolution = { ...twoAngleRevolution, id: 'revolution-numeric-two-angles', properties: twoAngleRevolution.properties.map((property) => property.name === 'Type' ? { ...property, value: 4 } : property) }
|
|
assert.equal((await executor(numericTwoAngleRevolution, document, context)).status, 'success')
|
|
const numericSupportRevolution = { ...twoAngleRevolution, id: 'revolution-numeric-support', properties: twoAngleRevolution.properties.map((property) => property.name === 'Type' ? { ...property, value: 1 } : property) }
|
|
assert.equal((await executor(numericSupportRevolution, document, context)).errors?.[0].code, 'REVOLUTION_SUPPORT_MODE_UNSUPPORTED')
|
|
const invalidAngle2 = { ...twoAngleRevolution, id: 'revolution-invalid-angle2', properties: twoAngleRevolution.properties.map((property) => property.name === 'Angle2' ? { ...property, value: 361 } : property) }
|
|
const invalidAngle2Result = await executor(invalidAngle2, document, context)
|
|
assert.equal(invalidAngle2Result.errors?.[0].code, 'REVOLUTION_ANGLE2_INVALID')
|
|
const groove = { id: 'groove-two-angles', typeId: 'PartDesign::Groove', properties: [
|
|
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 120 },
|
|
{ name: 'Angle2', label: 'Reverse angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 45 },
|
|
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Two angles', options: ['Angle', 'Two angles'] },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
] }
|
|
assert.equal((await executor(groove, document, context)).status, 'success')
|
|
assert.ok(calls.includes('groove:pad-shape:165:1'))
|
|
assert.equal(grooveProfileRotations.at(-1), -45)
|
|
const grooveMidplane = { ...groove, id: 'groove-midplane', properties: groove.properties.filter((property) => property.name !== 'Angle2' && property.name !== 'Type').concat({ name: 'Midplane', label: 'Symmetric', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }) }
|
|
assert.equal((await executor(grooveMidplane, document, context)).status, 'success')
|
|
assert.ok(calls.includes('groove:pad-shape:120:1'))
|
|
assert.equal(grooveProfileRotations.at(-1), -60)
|
|
const unsupportedGroove = { ...groove, id: 'groove-up-to-face', properties: groove.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Up to face' } : property) }
|
|
assert.equal((await executor(unsupportedGroove, document, context)).errors?.[0].code, 'GROOVE_SUPPORT_MODE_UNSUPPORTED')
|
|
|
|
const partExtrude = { id: 'part-extrude', typeId: 'Part::Extrusion', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Extrude', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Dir', label: 'Dir', group: 'Extrude', scope: 'data' as const, type: 'App::PropertyVector' as const, value: { x: 0, y: 0, z: 2 } },
|
|
{ name: 'LengthFwd', label: 'LengthFwd', group: 'Extrude', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 8, unit: 'mm' },
|
|
{ name: 'LengthRev', label: 'LengthRev', group: 'Extrude', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 0, unit: 'mm' },
|
|
{ name: 'Solid', label: 'Solid', group: 'Extrude', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
{ name: 'Symmetric', label: 'Symmetric', group: 'Extrude', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
const partExtrudeResult = await executor(partExtrude, document, context)
|
|
assert.equal(partExtrudeResult.status, 'success')
|
|
assert.ok(calls.includes('extrude:8:0,0,2:true'))
|
|
assert.equal((await executor({ ...partExtrude, id: 'part-extrude-legacy', typeId: 'Part::Extrude' }, document, context)).status, 'success')
|
|
const invalidPartExtrude = { ...partExtrude, id: 'part-extrude-invalid', properties: partExtrude.properties.map((property) => property.name === 'LengthRev' ? { ...property, value: 2 } : property) }
|
|
const invalidPartExtrudeResult = await executor(invalidPartExtrude, document, context)
|
|
assert.equal(invalidPartExtrudeResult.status, 'failed')
|
|
assert.equal(invalidPartExtrudeResult.errors?.[0].code, 'EXTRUDE_SIDE_MODE_INVALID')
|
|
const twoSidedExtrude = { ...partExtrude, id: 'part-extrude-two-sided', properties: partExtrude.properties.map((property) => property.name === 'LengthRev' ? { ...property, value: 2 } : property).map((property) => property.name === 'Symmetric' ? { ...property, value: false } : property) }
|
|
assert.equal((await executor(twoSidedExtrude, document, context)).status, 'success')
|
|
assert.ok(calls.includes('union:extrude-shape,extrude-shape'))
|
|
const partRevolution = { id: 'part-revolution', typeId: 'Part::Revolution', properties: [
|
|
{ name: 'Source', label: 'Source', group: 'Revolve', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Base', label: 'Base', group: 'Revolve', scope: 'data' as const, type: 'App::PropertyVector' as const, value: { x: 1, y: 2, z: 3 } },
|
|
{ name: 'Axis', label: 'Axis', group: 'Revolve', scope: 'data' as const, type: 'App::PropertyVector' as const, value: { x: 0, y: 0, z: 1 } },
|
|
{ name: 'Angle', label: 'Angle', group: 'Revolve', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 180, unit: 'deg' },
|
|
{ name: 'Solid', label: 'Solid', group: 'Revolve', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
const partRevolutionResult = await executor(partRevolution, document, context)
|
|
assert.equal(partRevolutionResult.status, 'success')
|
|
assert.ok(calls.includes('revolution:180:0'))
|
|
const symmetricPartRevolution = { ...partRevolution, id: 'part-revolution-symmetric', properties: [...partRevolution.properties, { name: 'Symmetric', label: 'Symmetric', group: 'Revolve', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
assert.equal((await executor(symmetricPartRevolution, document, context)).status, 'success')
|
|
assert.ok(calls.includes('revolution:180:0'))
|
|
assert.equal(revolutionProfileRotations.at(-1), -90)
|
|
|
|
const edgeSource = createEdgeSubshapeRefs('pad-shape', 2, [{ vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }])
|
|
const edgeEntries = edgeSource.refs.map((ref, index) => ({ ref, signature: edgeSource.signatures[index] }))
|
|
const padWithTopology: DocumentObjectSnapshot = { ...pad, topology: {
|
|
shapeId: 'pad-shape', documentVersion: 2, generation: 1, entries: edgeEntries,
|
|
migration: { previousGeneration: null, matches: edgeSource.refs.map((ref) => ({ current: ref, score: 1, status: 'new' as const })) },
|
|
history: captureSignatureTopologyHistory('pad:1', [], edgeEntries),
|
|
} }
|
|
const dressDocument: DocumentSnapshot = { ...document, objects: [sketch, padWithTopology, pocket] }
|
|
const selectedEdge = { schemaVersion: 1 as const, objectId: 'pad', kind: 'edge' as const, persistentId: edgeSource.refs[1].persistentId, topologyVersion: 2, generation: 1, status: 'stable' as const }
|
|
const selectedFillet: DocumentObjectSnapshot = { id: 'selected-fillet', typeId: 'PartDesign::Fillet', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Fillet', scope: 'data', type: 'App::PropertyLinkSub', value: selectedEdge },
|
|
{ name: 'Radius', label: 'Radius', group: 'Fillet', scope: 'data', type: 'App::PropertyLength', value: 0.5, unit: 'mm' },
|
|
] }
|
|
assert.equal((await executor(selectedFillet, dressDocument, context)).status, 'success')
|
|
assert.ok(calls.includes('fillet:0.5:1'))
|
|
const partSelectedFillet: DocumentObjectSnapshot = { ...selectedFillet, id: 'part-selected-fillet', typeId: 'Part::Fillet' }
|
|
assert.equal((await executor(partSelectedFillet, dressDocument, context)).status, 'success')
|
|
assert.equal(calls.filter((entry) => entry === 'fillet:0.5:1').length, 2)
|
|
const multiEdgeFillet: DocumentObjectSnapshot = { ...selectedFillet, id: 'multi-edge-fillet', properties: selectedFillet.properties.map((property) => property.name === 'Base' ? { ...property, value: { schemaVersion: 1 as const, objectId: 'pad', subElements: [
|
|
{ ...selectedEdge, persistentId: edgeSource.refs[0].persistentId },
|
|
{ ...selectedEdge, persistentId: edgeSource.refs[1].persistentId },
|
|
] } } : property.name === 'Radius' ? { ...property, value: 0.4 } : property) }
|
|
assert.equal((await executor(multiEdgeFillet, dressDocument, context)).status, 'success')
|
|
assert.ok(calls.includes('fillet:0.4:0,1'))
|
|
const allEdgesFillet: DocumentObjectSnapshot = { ...selectedFillet, id: 'all-edges-fillet', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Fillet', scope: 'data', type: 'App::PropertyLink', value: 'pad' },
|
|
{ name: 'Radius', label: 'Radius', group: 'Fillet', scope: 'data', type: 'App::PropertyLength', value: 0.25, unit: 'mm' },
|
|
{ name: 'UseAllEdges', label: 'Use all edges', group: 'Fillet', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] }
|
|
assert.equal((await executor(allEdgesFillet, dressDocument, context)).status, 'success')
|
|
assert.ok(calls.includes('fillet:0.25:all'))
|
|
const ambiguousFillet: DocumentObjectSnapshot = { ...selectedFillet, id: 'ambiguous-fillet', properties: selectedFillet.properties.map((property) => property.name === 'Base' ? { ...property, value: { ...selectedEdge, status: 'ambiguous' as const, candidates: edgeSource.refs.map((ref) => ref.persistentId) } } : property) }
|
|
const ambiguousFilletResult = await executor(ambiguousFillet, dressDocument, context)
|
|
assert.equal(ambiguousFilletResult.status, 'failed')
|
|
assert.equal(ambiguousFilletResult.errors?.[0].code, 'DRESSUP_EDGE_REFERENCE_UNRESOLVED')
|
|
const selectedChamfer: DocumentObjectSnapshot = { id: 'selected-chamfer', typeId: 'PartDesign::Chamfer', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Chamfer', scope: 'data', type: 'App::PropertyLinkSub', value: selectedEdge },
|
|
{ name: 'Size', label: 'Size', group: 'Chamfer', scope: 'data', type: 'App::PropertyLength', value: 0.75, unit: 'mm' },
|
|
{ name: 'ChamferType', label: 'Chamfer type', group: 'Chamfer', scope: 'data', type: 'App::PropertyEnumeration', value: 'Equal distance', options: ['Equal distance', 'Two distances', 'Distance and Angle'] },
|
|
] }
|
|
assert.equal((await executor(selectedChamfer, dressDocument, context)).status, 'success')
|
|
assert.ok(calls.includes('chamfer:0.75:1'))
|
|
const partSelectedChamfer: DocumentObjectSnapshot = { ...selectedChamfer, id: 'part-selected-chamfer', typeId: 'Part::Chamfer' }
|
|
assert.equal((await executor(partSelectedChamfer, dressDocument, context)).status, 'success')
|
|
assert.equal(calls.filter((entry) => entry === 'chamfer:0.75:1').length, 2)
|
|
const unsupportedChamfer: DocumentObjectSnapshot = { ...selectedChamfer, id: 'unsupported-chamfer', properties: selectedChamfer.properties.map((property) => property.name === 'ChamferType' ? { ...property, value: 'Two distances' } : property) }
|
|
const unsupportedChamferResult = await executor(unsupportedChamfer, dressDocument, context)
|
|
assert.equal(unsupportedChamferResult.status, 'failed')
|
|
assert.equal(unsupportedChamferResult.errors?.[0].code, 'CHAMFER_MODE_UNSUPPORTED')
|
|
|
|
const linearPattern = { id: 'linear-pattern', typeId: 'PartDesign::LinearPattern', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
{ name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyInteger' as const, value: 3 },
|
|
{ name: 'Length', label: 'Length', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 20, unit: 'mm' },
|
|
{ name: 'Direction', label: 'Direction', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Vertical', options: ['Horizontal', 'Vertical', 'Normal'] },
|
|
] }
|
|
const patternResult = await executor(linearPattern, document, context)
|
|
assert.equal(patternResult.status, 'success')
|
|
assert.equal(shapes.get('linear-pattern')?.id, 'union-shape')
|
|
assert.ok(calls.includes('placement:0,10,0:0,0,1:0'))
|
|
assert.ok(calls.includes('placement:0,20,0:0,0,1:0'))
|
|
assert.ok(calls.includes('union:pad-shape,pattern-copy-1,pattern-copy-2'))
|
|
assert.ok(calls.includes('release:pattern-copy-1'))
|
|
assert.ok(calls.includes('release:pattern-copy-2'))
|
|
|
|
const invalidPattern = { ...linearPattern, properties: linearPattern.properties.map((property) => property.name === 'Occurrences' ? { ...property, value: 1 } : property) }
|
|
const invalidPatternResult = await executor(invalidPattern, document, context)
|
|
assert.equal(invalidPatternResult.status, 'failed')
|
|
assert.equal(invalidPatternResult.errors?.[0].code, 'PATTERN_OCCURRENCES_INVALID')
|
|
const spacingPattern = { ...linearPattern, id: 'linear-pattern-spacing', properties: [...linearPattern.properties,
|
|
{ name: 'Mode', label: 'Mode', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Spacing', options: ['Extent', 'Spacing'] },
|
|
{ name: 'Offset', label: 'Spacing', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 7 },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
assert.equal((await executor(spacingPattern, document, context)).status, 'success')
|
|
assert.ok(calls.some((call) => call.includes('placement:0,-7,0')))
|
|
assert.ok(calls.some((call) => call.includes('placement:0,-14,0')))
|
|
const customSpacingPattern = { ...spacingPattern, id: 'linear-pattern-custom-spacing', properties: [...spacingPattern.properties,
|
|
{ name: 'Spacings', label: 'Custom spacings', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyFloatList' as const, value: [3, -1] },
|
|
{ name: 'SpacingPattern', label: 'Spacing pattern', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyFloatList' as const, value: [5, 9] },
|
|
] }
|
|
assert.equal((await executor(customSpacingPattern, document, context)).status, 'success')
|
|
assert.ok(calls.some((call) => call.includes('placement:0,-3,0')))
|
|
assert.ok(calls.some((call) => call.includes('placement:0,-12,0')))
|
|
const invalidCustomSpacing = { ...customSpacingPattern, id: 'linear-pattern-invalid-spacing', properties: customSpacingPattern.properties.map((property) => property.name === 'Spacings' ? { ...property, value: [3, 0] } : property) }
|
|
const invalidCustomSpacingResult = await executor(invalidCustomSpacing, document, context)
|
|
assert.equal(invalidCustomSpacingResult.status, 'failed')
|
|
assert.equal(invalidCustomSpacingResult.errors?.[0].code, 'PATTERN_SPACING_INVALID')
|
|
const featureModePattern = { ...linearPattern, id: 'linear-pattern-feature-mode', properties: [...linearPattern.properties,
|
|
{ name: 'TransformMode', label: 'Transform mode', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Features', options: ['Features', 'Whole shape'] },
|
|
{ name: 'Originals', label: 'Original features', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLinkList' as const, value: ['pad'] },
|
|
] }
|
|
const featureModeResult = await executor(featureModePattern, document, context)
|
|
assert.equal(featureModeResult.status, 'success')
|
|
assert.ok(calls.some((call) => call === 'union:pad-shape,pattern-copy-7,pattern-copy-8'))
|
|
const multiOriginalDocument: DocumentSnapshot = { ...document, tree: [...document.tree, { id: 'pad-secondary', label: 'Pad secondary', type: 'feature' }], objects: [...document.objects, { ...pad, id: 'pad-secondary' }] }
|
|
shapes.set('pad-secondary', shape('pad-secondary-shape'))
|
|
const multiOriginalPattern = { ...linearPattern, id: 'linear-pattern-originals', properties: [...linearPattern.properties, { name: 'Originals', label: 'Original features', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLinkList' as const, value: ['pad', 'pad-secondary'] }] }
|
|
assert.equal((await executor(multiOriginalPattern, multiOriginalDocument, context)).status, 'success')
|
|
assert.ok(calls.some((call) => call === 'union:pad-shape,pattern-copy-9,pattern-copy-10'))
|
|
const twoDirectionPattern = { ...linearPattern, id: 'linear-pattern-two-direction', properties: [...linearPattern.properties,
|
|
{ name: 'Direction2', label: 'Direction 2', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Horizontal', options: ['None', 'Horizontal', 'Vertical', 'Normal'] },
|
|
{ name: 'Occurrences2', label: 'Occurrences 2', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyInteger' as const, value: 2 },
|
|
{ name: 'Length2', label: 'Length 2', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 10 },
|
|
{ name: 'Mode2', label: 'Mode 2', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Extent', options: ['Extent', 'Spacing'] },
|
|
{ name: 'Reversed2', label: 'Reversed 2', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
assert.equal((await executor(twoDirectionPattern, document, context)).status, 'success')
|
|
assert.ok(calls.filter((call) => call.includes('placement:-10,0,0')).length >= 3)
|
|
|
|
const polarPattern = { id: 'polar-pattern', typeId: 'PartDesign::PolarPattern', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
{ name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyInteger' as const, value: 3 },
|
|
{ name: 'Angle', label: 'Angle', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 360, unit: 'deg' },
|
|
{ name: 'Axis', label: 'Axis', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Normal', options: ['Normal', 'Horizontal', 'Vertical'] },
|
|
] }
|
|
const polarResult = await executor(polarPattern, document, context)
|
|
assert.equal(polarResult.status, 'success')
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:120'))
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:240'))
|
|
assert.ok(calls.includes('union:pad-shape,pattern-copy-3,pattern-copy-4'))
|
|
|
|
const partialPolar = { ...polarPattern, id: 'polar-pattern-partial', properties: polarPattern.properties.map((property) => property.name === 'Angle' ? { ...property, value: 180 } : property) }
|
|
const partialPolarResult = await executor(partialPolar, document, context)
|
|
assert.equal(partialPolarResult.status, 'success')
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:90'))
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:180'))
|
|
const spacingPolar = { ...polarPattern, id: 'polar-pattern-spacing', properties: [...polarPattern.properties,
|
|
{ name: 'Mode', label: 'Mode', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Spacing', options: ['Extent', 'Spacing'] },
|
|
{ name: 'Offset', label: 'Spacing', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 30 },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
assert.equal((await executor(spacingPolar, document, context)).status, 'success')
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,-1:30'))
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,-1:60'))
|
|
const customPolarSpacing = { ...spacingPolar, id: 'polar-pattern-custom-spacing', properties: [...spacingPolar.properties,
|
|
{ name: 'Spacings', label: 'Custom spacings', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyFloatList' as const, value: [15, -1] },
|
|
{ name: 'SpacingPattern', label: 'Spacing pattern', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyFloatList' as const, value: [20, 40] },
|
|
] }
|
|
assert.equal((await executor(customPolarSpacing, document, context)).status, 'success')
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,-1:15'))
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,-1:55'))
|
|
const invalidPolarSpacing = { ...customPolarSpacing, id: 'polar-pattern-invalid-spacing', properties: customPolarSpacing.properties.map((property) => property.name === 'Spacings' ? { ...property, value: [15, 0] } : property) }
|
|
const invalidPolarSpacingResult = await executor(invalidPolarSpacing, document, context)
|
|
assert.equal(invalidPolarSpacingResult.status, 'failed')
|
|
assert.equal(invalidPolarSpacingResult.errors?.[0].code, 'PATTERN_SPACING_INVALID')
|
|
|
|
const hole = { id: 'hole', typeId: 'PartDesign::Hole', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
{ name: 'Diameter', label: 'Diameter', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 6, unit: 'mm' },
|
|
{ name: 'Depth', label: 'Depth', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 8, unit: 'mm' },
|
|
{ name: 'Type', label: 'Type', group: 'Hole', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Dimension', options: ['Dimension', 'Through all'] },
|
|
] }
|
|
const holeResult = await executor(hole, document, context)
|
|
assert.equal(holeResult.status, 'success')
|
|
assert.equal(shapes.get('hole')?.id, 'pocket-shape')
|
|
assert.ok(calls.includes('pocket:pad-shape:false:32:8'))
|
|
const invalidHole = { ...hole, properties: hole.properties.map((property) => property.name === 'Diameter' ? { ...property, value: 0 } : property) }
|
|
const invalidHoleResult = await executor(invalidHole, document, context)
|
|
assert.equal(invalidHoleResult.status, 'failed')
|
|
assert.equal(invalidHoleResult.errors?.[0].code, 'HOLE_DIAMETER_INVALID')
|
|
const counterbore = { ...hole, id: 'counterbore-hole', properties: [...hole.properties,
|
|
{ name: 'HoleCutType', label: 'Cut type', group: 'Hole', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Counterbore', options: ['None', 'Counterbore', 'Countersink'] },
|
|
{ name: 'HoleCutDiameter', label: 'Cut diameter', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 10 },
|
|
{ name: 'HoleCutDepth', label: 'Cut depth', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
|
|
{ name: 'Position', label: 'Position', group: 'Hole', scope: 'data' as const, type: 'App::PropertyVector' as const, value: { x: 2, y: 3, z: 1 } },
|
|
{ name: 'Direction', label: 'Direction', group: 'Hole', scope: 'data' as const, type: 'App::PropertyVector' as const, value: { x: 0, y: 0, z: -1 } },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Hole', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
assert.equal((await executor(counterbore, document, context)).status, 'success')
|
|
assert.ok(calls.some((call) => call.startsWith('release:')))
|
|
const counterdrill = { ...counterbore, id: 'counterdrill-hole', properties: counterbore.properties.map((property) => property.name === 'HoleCutType' ? { ...property, value: 'Counterdrill' } : property) }
|
|
assert.equal((await executor(counterdrill, document, context)).status, 'success')
|
|
assert.ok(calls.includes('cut:cylinder-shape,cone-shape'))
|
|
const angled = { ...hole, id: 'angled-hole', properties: [...hole.properties,
|
|
{ name: 'DrillPoint', label: 'Drill point', group: 'Hole', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Angled', options: ['Flat', 'Angled'] },
|
|
{ name: 'DrillPointAngle', label: 'Drill point angle', group: 'Hole', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 118 },
|
|
] }
|
|
assert.equal((await executor(angled, document, context)).status, 'success')
|
|
const tapered = { ...hole, id: 'tapered-hole', properties: [...hole.properties,
|
|
{ name: 'Tapered', label: 'Tapered', group: 'Hole', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
{ name: 'TaperedAngle', label: 'Taper angle', group: 'Hole', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 95 },
|
|
] }
|
|
assert.equal((await executor(tapered, document, context)).status, 'success')
|
|
const taperedThroughAll = { ...tapered, id: 'tapered-through-all', properties: tapered.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Through all' } : property) }
|
|
const taperedThroughAllResult = await executor(taperedThroughAll, document, context)
|
|
assert.equal(taperedThroughAllResult.errors?.[0].code, 'HOLE_TAPERED_THROUGH_ALL_UNSUPPORTED')
|
|
const invalidTaper = { ...tapered, id: 'invalid-tapered-hole', properties: tapered.properties.map((property) => property.name === 'TaperedAngle' ? { ...property, value: 0 } : property) }
|
|
const invalidTaperResult = await executor(invalidTaper, document, context)
|
|
assert.equal(invalidTaperResult.errors?.[0].code, 'HOLE_TAPERED_ANGLE_INVALID')
|
|
const drillForDepth = { ...angled, id: 'drill-for-depth-hole', properties: [...angled.properties, { name: 'DrillForDepth', label: 'Drill for depth', group: 'Hole', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
const drillForDepthResult = await executor(drillForDepth, document, context)
|
|
assert.equal(drillForDepthResult.status, 'success')
|
|
const shortenedPocket = calls.filter((call) => call.startsWith('pocket:pad-shape:false:32:')).at(-1)
|
|
assert.ok(shortenedPocket && Number(shortenedPocket.split(':').at(-1)) < 8)
|
|
const shortDrillForDepth = { ...drillForDepth, id: 'short-drill-for-depth-hole', properties: drillForDepth.properties.map((property) => property.name === 'Depth' ? { ...property, value: 0.1 } : property) }
|
|
const shortDrillForDepthResult = await executor(shortDrillForDepth, document, context)
|
|
assert.equal(shortDrillForDepthResult.errors?.[0].code, 'HOLE_DRILL_DEPTH_INVALID')
|
|
const threaded = { ...hole, id: 'threaded-hole', properties: [...hole.properties, { name: 'Threaded', label: 'Threaded', group: 'Hole', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
const threadedResult = await executor(threaded, document, context)
|
|
assert.equal(threadedResult.status, 'failed')
|
|
assert.equal(threadedResult.errors?.[0].code, 'HOLE_THREAD_STANDARD_INVALID')
|
|
const isoThread = { ...hole, id: 'iso-threaded-hole', properties: [
|
|
...hole.properties.map((property) => property.name === 'Diameter' ? { ...property, value: 5 } : property),
|
|
{ name: 'Threaded', label: 'Threaded', group: 'Thread', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
{ name: 'ThreadType', label: 'Thread type', group: 'Thread', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'ISOMetricProfile', options: ['None', 'ISOMetricProfile'] },
|
|
{ name: 'ThreadSize', label: 'Thread size', group: 'Thread', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'M6x1.0', options: ['M6x1.0'] },
|
|
{ name: 'ThreadDiameter', label: 'Thread diameter', group: 'Thread', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 6 },
|
|
{ name: 'ThreadPitch', label: 'Thread pitch', group: 'Thread', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 1 },
|
|
] }
|
|
assert.equal((await executor(isoThread, document, context)).status, 'success')
|
|
const modeledIsoThread = { ...isoThread, id: 'modeled-iso-threaded-hole', properties: [
|
|
...isoThread.properties,
|
|
{ name: 'ModelThread', label: 'Model thread', group: 'Thread', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
{ name: 'ThreadDirection', label: 'Thread direction', group: 'Thread', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Left', options: ['Right', 'Left'] },
|
|
{ name: 'ThreadDepthType', label: 'Thread depth type', group: 'Thread', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Dimension', options: ['Hole Depth', 'Dimension', 'Tapped (DIN76)'] },
|
|
{ name: 'ThreadDepth', label: 'Thread depth', group: 'Thread', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 6 },
|
|
] }
|
|
assert.equal((await executor(modeledIsoThread, document, context)).status, 'success')
|
|
assert.ok(calls.includes('modeled-thread:5:6:1:6:true'))
|
|
const invalidDirection = { ...hole, id: 'invalid-hole-direction', properties: [...hole.properties, { name: 'Direction', label: 'Direction', group: 'Hole', scope: 'data' as const, type: 'App::PropertyVector' as const, value: { x: 0, y: 0, z: 0 } }] }
|
|
const invalidDirectionResult = await executor(invalidDirection, document, context)
|
|
assert.equal(invalidDirectionResult.status, 'failed')
|
|
assert.equal(invalidDirectionResult.errors?.[0].code, 'HOLE_DIRECTION_INVALID')
|
|
|
|
const suppressedPad = { ...pad, properties: [...pad.properties, { name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
const suppressedResult = await executor(suppressedPad, document, context)
|
|
assert.equal(suppressedResult.status, 'suppressed')
|
|
assert.equal(shapes.has('pad'), false)
|
|
assert.ok(calls.includes('release:pad-shape'))
|
|
})
|
|
|
|
test('OCCT feature executor routes ordinary cross-feature operations to native topology history', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-native-routing', documentVersion: 1 })
|
|
const faceTopology = (shapeId: string): SubshapeTopology => {
|
|
const created = createSubshapeRefs(shapeId, 1, [{ vertexCoord: [0, 0, 0, 1, 0, 0, 0, 1, 0], normalCoord: [], triIndexes: [0, 1, 2] }])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
}
|
|
const operations: string[] = []
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
pad: async () => shape('pad-native'),
|
|
pocket: async () => shape('pocket-native'),
|
|
revolution: async () => shape('revolution-native'),
|
|
groove: async () => shape('groove-native'),
|
|
topology: async (handle) => faceTopology(handle.id),
|
|
topologyHistory: async (input) => {
|
|
operations.push(input.operation || '')
|
|
return input.inputs.map((source) => ({ sourceObjectId: source.objectId, sourceKind: 'face' as const, sourceIndex: 0, relation: 'modified' as const, resultKind: 'face' as const, resultIndexes: [0] }))
|
|
},
|
|
release: async () => {},
|
|
}
|
|
const sketch = { id: 'profile', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('profile', [
|
|
{ id: 'a', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
|
|
{ id: 'b', type: 'line' as const, start: { x: 1, y: 0 }, end: { x: 1, y: 1 } },
|
|
{ id: 'c', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: 0, y: 1 } },
|
|
{ id: 'd', type: 'line' as const, start: { x: 0, y: 1 }, end: { x: 0, y: 0 } },
|
|
]), topology: faceTopology('profile') }
|
|
const base = { id: 'base', typeId: 'Part::Box', properties: [], topology: faceTopology('base') }
|
|
const targets: DocumentObjectSnapshot[] = [
|
|
{ id: 'pad-native-feature', typeId: 'PartDesign::Pad', properties: [{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 5 }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' }] },
|
|
{ id: 'pocket-native-feature', typeId: 'PartDesign::Pocket', properties: [{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension' }, { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2 }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' }, { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'base' }] },
|
|
{ id: 'revolution-native-feature', typeId: 'PartDesign::Revolution', properties: [{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 180 }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' }] },
|
|
{ id: 'groove-native-feature', typeId: 'PartDesign::Groove', properties: [{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 180 }, { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' }, { name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'base' }] },
|
|
]
|
|
const shapes = new Map<string, ShapeHandle>([['profile', shape('profile-shape')], ['base', shape('base-shape')]])
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-native-routing', version: 1, objects: [sketch, base, ...targets], dependencies: [] }
|
|
for (const target of targets) {
|
|
const result = await executor(target, document, { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal })
|
|
assert.equal(result.status, 'success')
|
|
}
|
|
assert.deepEqual(operations, ['pad', 'pocket', 'revolution', 'groove'])
|
|
})
|
|
|
|
test('OCCT feature executor sends effective Up-to-face, two-sided, midplane and two-angle history sides', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-native-modes', documentVersion: 1 })
|
|
const faceTopology = (shapeId: string, z = 0): SubshapeTopology => {
|
|
const created = createSubshapeRefs(shapeId, 1, [{ vertexCoord: [0, 0, z, 1, 0, z, 0, 1, z], normalCoord: [0, 0, 1, 0, 0, 1, 0, 0, 1], triIndexes: [0, 1, 2] }])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
}
|
|
const historyInputs: Array<{ id: string; sides: NativeTopologyHistoryInput['featureSides'] }> = []
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
pad: async () => shape('pad-native'), pocket: async () => shape('pocket-native'), revolution: async () => shape('revolution-native'), groove: async () => shape('groove-native'),
|
|
union: async () => shape('union-native'), cut: async () => shape('cut-native'),
|
|
topology: async (handle) => faceTopology(handle.id),
|
|
topologyHistory: async (input) => {
|
|
historyInputs.push({ id: input.operationId.split(':generation:')[0], sides: input.featureSides })
|
|
return input.inputs.map((source) => ({ sourceObjectId: source.objectId, sourceKind: 'face' as const, sourceIndex: 0, relation: 'modified' as const, resultKind: 'face' as const, resultIndexes: [0] }))
|
|
},
|
|
release: async () => {},
|
|
}
|
|
const sketch = { id: 'profile', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('profile', [
|
|
{ id: 'a', type: 'line' as const, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
|
|
{ id: 'b', type: 'line' as const, start: { x: 1, y: 0 }, end: { x: 1, y: 1 } },
|
|
{ id: 'c', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: 0, y: 1 } },
|
|
{ id: 'd', type: 'line' as const, start: { x: 0, y: 1 }, end: { x: 0, y: 0 } },
|
|
]), topology: faceTopology('profile') }
|
|
const base = { id: 'base', typeId: 'Part::Box', properties: [], topology: faceTopology('base') }
|
|
const targetTopology = faceTopology('target-face', 7)
|
|
const target = { id: 'target', typeId: 'Part::Feature', properties: [], topology: targetTopology }
|
|
const upToFace = { schemaVersion: 1, objectId: 'target', subElements: [{ objectId: 'target', kind: 'face', persistentId: targetTopology.faces[0].persistentId, status: 'stable' }] }
|
|
const targets: DocumentObjectSnapshot[] = [
|
|
{ id: 'pad-two-sided', typeId: 'PartDesign::Pad', properties: [
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' },
|
|
{ name: 'SideType', label: 'Side type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Two sides' },
|
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 6 },
|
|
{ name: 'Length2', label: 'Length 2', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
] },
|
|
{ id: 'pocket-up-to-face', typeId: 'PartDesign::Pocket', properties: [
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' },
|
|
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'base' },
|
|
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Up to face' },
|
|
{ name: 'UpToFace', label: 'Face', group: 'Parameters', scope: 'data', type: 'App::PropertyLinkSub', value: upToFace },
|
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
] },
|
|
{ id: 'revolution-midplane', typeId: 'PartDesign::Revolution', properties: [
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' },
|
|
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 180 },
|
|
{ name: 'Midplane', label: 'Midplane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] },
|
|
{ id: 'groove-two-angles', typeId: 'PartDesign::Groove', properties: [
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'profile' },
|
|
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'base' },
|
|
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Two angles' },
|
|
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 120 },
|
|
{ name: 'Angle2', label: 'Angle 2', group: 'Parameters', scope: 'data', type: 'App::PropertyAngle', value: 60 },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] },
|
|
]
|
|
const shapes = new Map<string, ShapeHandle>([['profile', shape('profile-shape')], ['base', shape('base-shape')], ['target', shape('target-shape')]])
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-native-modes', version: 1, objects: [sketch, base, target, ...targets], dependencies: [] }
|
|
for (const feature of targets) assert.equal((await executor(feature, document, { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal })).status, 'success')
|
|
assert.deepEqual(historyInputs, [
|
|
{ id: 'pad-two-sided', sides: [{ direction: [0, 0, 6] }, { direction: [0, 0, -2] }] },
|
|
{ id: 'pocket-up-to-face', sides: [{ direction: [0, 0, 7] }] },
|
|
{ id: 'revolution-midplane', sides: [{ direction: [0, 1, 0], angle: 90 }, { direction: [0, -1, 0], angle: 90 }] },
|
|
{ id: 'groove-two-angles', sides: [{ direction: [0, -1, 0], angle: 120 }, { direction: [0, 1, 0], angle: 60 }] },
|
|
])
|
|
})
|
|
|
|
test('Loft recompute maps section placement, advanced options, modes, diagnostics, and last-valid Shape recovery', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-loft', documentVersion: 3 })
|
|
const calls: Array<{ mode: string; sections: PlanarProfile[]; base?: string; ruled?: boolean; closed?: boolean }> = []
|
|
let loftShouldFail = false
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => shape('box'),
|
|
createCylinder: async () => shape('cylinder'),
|
|
createSphere: async () => shape('sphere'),
|
|
createCone: async () => shape('cone'),
|
|
applyPlacement: async (input) => input.shape,
|
|
union: async () => shape('union'),
|
|
cut: async () => shape('cut'),
|
|
intersection: async () => shape('common'),
|
|
pad: async () => shape('pad'),
|
|
pocket: async () => shape('pocket'),
|
|
revolution: async () => shape('revolution'),
|
|
loft: async (input) => {
|
|
calls.push({ mode: input.mode ?? 'standalone', sections: input.sections, base: input.base?.id, ruled: input.ruled, closed: input.closed })
|
|
if (loftShouldFail) throw new Error('Bitbybit loft failed')
|
|
return shape(`${input.mode ?? 'standalone'}-loft-${calls.length}`)
|
|
},
|
|
fillet: async () => shape('fillet'),
|
|
chamfer: async () => shape('chamfer'),
|
|
release: async () => undefined,
|
|
}
|
|
const makeSection = (id: string, z: number): DocumentObjectSnapshot => ({
|
|
id,
|
|
typeId: 'Sketcher::SketchObject',
|
|
properties: z === 0 ? [] : [{
|
|
name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data', type: 'App::PropertyPlacement',
|
|
value: { position: { x: 0, y: 0, z }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } },
|
|
}],
|
|
sketch: createSketch(id, [
|
|
{ id: `${id}-a`, type: 'line', start: { x: -1, y: -1 }, end: { x: 1, y: -1 } },
|
|
{ id: `${id}-b`, type: 'line', start: { x: 1, y: -1 }, end: { x: 1, y: 1 } },
|
|
{ id: `${id}-c`, type: 'line', start: { x: 1, y: 1 }, end: { x: -1, y: 1 } },
|
|
{ id: `${id}-d`, type: 'line', start: { x: -1, y: 1 }, end: { x: -1, y: -1 } },
|
|
]),
|
|
})
|
|
const sectionA = makeSection('section-a', 0)
|
|
const sectionB = makeSection('section-b', 12)
|
|
const sectionC = makeSection('section-c', 24)
|
|
const baseObject: DocumentObjectSnapshot = { id: 'base', typeId: 'Part::Box', properties: [] }
|
|
const partLoft: DocumentObjectSnapshot = { id: 'part-loft', typeId: 'Part::Loft', properties: [
|
|
{ name: 'Sections', label: 'Sections', group: 'Loft', scope: 'data', type: 'App::PropertyLinkList', value: ['section-a', 'section-b'] },
|
|
] }
|
|
const additive: DocumentObjectSnapshot = { id: 'additive-loft', typeId: 'PartDesign::AdditiveLoft', properties: [
|
|
{ name: 'Profile', label: 'Profile', group: 'Loft', scope: 'data', type: 'App::PropertyLink', value: 'section-a' },
|
|
{ name: 'Sections', label: 'Sections', group: 'Loft', scope: 'data', type: 'App::PropertyLinkList', value: ['section-b'] },
|
|
{ name: 'Base', label: 'Base', group: 'Loft', scope: 'data', type: 'App::PropertyLink', value: 'base' },
|
|
] }
|
|
const subtractive: DocumentObjectSnapshot = { ...additive, id: 'subtractive-loft', typeId: 'PartDesign::SubtractiveLoft' }
|
|
const advancedLoft: DocumentObjectSnapshot = { id: 'advanced-loft', typeId: 'Part::Loft', properties: [
|
|
{ name: 'Sections', label: 'Sections', group: 'Loft', scope: 'data', type: 'App::PropertyLinkList', value: ['section-a', 'section-b', 'section-c'] },
|
|
{ name: 'Ruled', label: 'Ruled', group: 'Loft', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Closed', label: 'Closed', group: 'Loft', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-loft', version: 3, objects: [baseObject, sectionA, sectionB, sectionC, partLoft, additive, subtractive, advancedLoft] }
|
|
const shapes = new Map<string, ShapeHandle>([['base', shape('base-shape')]])
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
|
|
|
assert.equal((await executor(partLoft, document, context)).status, 'success')
|
|
assert.equal(calls[0].mode, 'standalone')
|
|
assert.equal(calls[0].sections[0].outer.every((point) => point[2] === 0), true)
|
|
assert.equal(calls[0].sections[1].outer.every((point) => point[2] === 12), true)
|
|
assert.equal((await executor(additive, document, context)).status, 'success')
|
|
assert.deepEqual({ mode: calls[1].mode, base: calls[1].base }, { mode: 'additive', base: 'base-shape' })
|
|
assert.equal((await executor(subtractive, document, context)).status, 'success')
|
|
assert.equal(calls[2].mode, 'subtractive')
|
|
assert.equal((await executor(advancedLoft, document, context)).status, 'success')
|
|
assert.deepEqual({ ruled: calls[3].ruled, closed: calls[3].closed, sections: calls[3].sections.length }, { ruled: true, closed: true, sections: 3 })
|
|
|
|
const lastValid = shapes.get('additive-loft')
|
|
loftShouldFail = true
|
|
const failed = await executor(additive, document, context)
|
|
assert.equal(failed.status, 'failed')
|
|
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.equal(shapes.get('additive-loft')?.id, lastValid?.id)
|
|
|
|
const duplicate = { ...partLoft, properties: [{ ...partLoft.properties[0], value: ['section-a', 'section-a'] }] }
|
|
const duplicateResult = await executor(duplicate, document, context)
|
|
assert.equal(duplicateResult.status, 'failed')
|
|
assert.equal(duplicateResult.errors?.[0].code, 'LOFT_SECTIONS_DUPLICATE')
|
|
const missingBase = { ...additive, properties: additive.properties.filter((property) => property.name !== 'Base') }
|
|
const missingBaseResult = await executor(missingBase, document, context)
|
|
assert.equal(missingBaseResult.status, 'failed')
|
|
assert.equal(missingBaseResult.errors?.[0].code, 'BASE_SHAPE_MISSING')
|
|
const nonSolid = { ...partLoft, properties: [...partLoft.properties, { name: 'Solid', label: 'Solid', group: 'Loft', scope: 'data' as const, type: 'App::PropertyBool' as const, value: false }] }
|
|
assert.equal((await executor(nonSolid, document, context)).errors?.[0].code, 'LOFT_SHELL_UNSUPPORTED')
|
|
const linearized = { ...partLoft, properties: [...partLoft.properties, { name: 'Linearize', label: 'Linearize', group: 'Loft', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
assert.equal((await executor(linearized, document, context)).errors?.[0].code, 'LOFT_LINEARIZE_UNSUPPORTED')
|
|
const nonDefaultDegree = { ...partLoft, properties: [...partLoft.properties, { name: 'MaxDegree', label: 'Maximum degree', group: 'Loft', scope: 'data' as const, type: 'App::PropertyIntegerConstraint' as const, value: 4 }] }
|
|
assert.equal((await executor(nonDefaultDegree, document, context)).errors?.[0].code, 'LOFT_MAX_DEGREE_UNSUPPORTED')
|
|
})
|
|
|
|
test('Pipe recompute maps open Sketch paths, three modes, branches, and last-valid recovery', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-pipe', documentVersion: 4 })
|
|
const calls: Array<{ mode: string; path: [number, number, number][]; base?: string }> = []
|
|
let pipeShouldFail = false
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => shape('box'), createCylinder: async () => shape('cylinder'), createSphere: async () => shape('sphere'), createCone: async () => shape('cone'),
|
|
applyPlacement: async (input) => input.shape, union: async () => shape('union'), cut: async () => shape('cut'), intersection: async () => shape('common'),
|
|
pad: async () => shape('pad'), pocket: async () => shape('pocket'), revolution: async () => shape('revolution'),
|
|
pipe: async (input) => {
|
|
calls.push({ mode: input.mode ?? 'standalone', path: input.path, base: input.base?.id })
|
|
if (pipeShouldFail) throw new Error('Bitbybit pipe failed')
|
|
return shape(`${input.mode ?? 'standalone'}-pipe-${calls.length}`)
|
|
},
|
|
fillet: async () => shape('fillet'), chamfer: async () => shape('chamfer'), release: async () => undefined,
|
|
}
|
|
const profile: DocumentObjectSnapshot = {
|
|
id: 'pipe-profile', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('pipe-profile', [
|
|
{ id: 'profile-a', type: 'line', start: { x: -1, y: -1 }, end: { x: 1, y: -1 } },
|
|
{ id: 'profile-b', type: 'line', start: { x: 1, y: -1 }, end: { x: 1, y: 1 } },
|
|
{ id: 'profile-c', type: 'line', start: { x: 1, y: 1 }, end: { x: -1, y: 1 } },
|
|
{ id: 'profile-d', type: 'line', start: { x: -1, y: 1 }, end: { x: -1, y: -1 } },
|
|
]),
|
|
}
|
|
const path: DocumentObjectSnapshot = {
|
|
id: 'pipe-path', typeId: 'Sketcher::SketchObject', properties: [{
|
|
name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data', type: 'App::PropertyPlacement',
|
|
value: { position: { x: 2, y: 3, z: 4 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } },
|
|
}], sketch: createSketch('pipe-path', [
|
|
{ id: 'path-b', type: 'line', start: { x: 5, y: 0 }, end: { x: 5, y: 6 } },
|
|
{ id: 'path-a', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 0 } },
|
|
]),
|
|
}
|
|
const base: DocumentObjectSnapshot = { id: 'pipe-base', typeId: 'Part::Box', properties: [] }
|
|
const sweep: DocumentObjectSnapshot = { id: 'sweep', typeId: 'Part::Sweep', properties: [
|
|
{ name: 'Sections', label: 'Sections', group: 'Sweep', scope: 'data', type: 'App::PropertyLinkList', value: ['pipe-profile'] },
|
|
{ name: 'Spine', label: 'Spine', group: 'Sweep', scope: 'data', type: 'App::PropertyLinkSub', value: { schemaVersion: 1, objectId: 'pipe-path', subElements: [] } },
|
|
] }
|
|
const additive: DocumentObjectSnapshot = { id: 'additive-pipe', typeId: 'PartDesign::AdditivePipe', properties: [
|
|
{ name: 'Profile', label: 'Profile', group: 'Pipe', scope: 'data', type: 'App::PropertyLink', value: 'pipe-profile' },
|
|
{ name: 'Spine', label: 'Spine', group: 'Pipe', scope: 'data', type: 'App::PropertyLinkSub', value: { objectId: 'pipe-path', kind: 'edge', persistentId: 'edge-path', topologyVersion: 4, generation: 1, status: 'stable' } },
|
|
{ name: 'BaseFeature', label: 'Base feature', group: 'Pipe', scope: 'data', type: 'App::PropertyLink', value: 'pipe-base' },
|
|
] }
|
|
const subtractive: DocumentObjectSnapshot = { ...additive, id: 'subtractive-pipe', typeId: 'PartDesign::SubtractivePipe' }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-pipe', version: 4, objects: [base, profile, path, sweep, additive, subtractive] }
|
|
const shapes = new Map<string, ShapeHandle>([['pipe-base', shape('pipe-base-shape')]])
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
|
|
|
assert.equal((await executor(sweep, document, context)).status, 'success')
|
|
assert.equal(calls[0].mode, 'standalone')
|
|
assert.deepEqual(calls[0].path, [[2, 3, 4], [7, 3, 4], [7, 9, 4]])
|
|
assert.equal((await executor(additive, document, context)).status, 'success')
|
|
assert.deepEqual({ mode: calls[1].mode, base: calls[1].base }, { mode: 'additive', base: 'pipe-base-shape' })
|
|
assert.equal((await executor(subtractive, document, context)).status, 'success')
|
|
assert.equal(calls[2].mode, 'subtractive')
|
|
|
|
const lastValid = shapes.get('additive-pipe')
|
|
pipeShouldFail = true
|
|
const failed = await executor(additive, document, context)
|
|
assert.equal(failed.status, 'failed')
|
|
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.equal(shapes.get('additive-pipe')?.id, lastValid?.id)
|
|
|
|
const branchPath: DocumentObjectSnapshot = { ...path, sketch: createSketch('pipe-path', [
|
|
{ id: 'branch-a', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
|
|
{ id: 'branch-b', type: 'line', start: { x: 1, y: 0 }, end: { x: 2, y: 0 } },
|
|
{ id: 'branch-c', type: 'line', start: { x: 1, y: 0 }, end: { x: 1, y: 1 } },
|
|
]) }
|
|
const branchResult = await executor(sweep, { ...document, objects: document.objects.map((object) => object.id === path.id ? branchPath : object) }, context)
|
|
assert.equal(branchResult.status, 'failed')
|
|
assert.equal(branchResult.errors?.[0].code, 'PIPE_PATH_BRANCH')
|
|
const unsupportedTransition: DocumentObjectSnapshot = { ...sweep, properties: [...sweep.properties, { name: 'Transition', label: 'Transition', group: 'Sweep', scope: 'data', type: 'App::PropertyEnumeration', value: 'Right corner', options: ['Transformed', 'Right corner', 'Round corner'] }] }
|
|
const transitionResult = await executor(unsupportedTransition, document, context)
|
|
assert.equal(transitionResult.status, 'failed')
|
|
assert.equal(transitionResult.errors?.[0].code, 'PIPE_TRANSITION_UNSUPPORTED')
|
|
const frenet: DocumentObjectSnapshot = { ...sweep, properties: [...sweep.properties, { name: 'Frenet', label: 'Frenet', group: 'Sweep', scope: 'data', type: 'App::PropertyBool', value: true }] }
|
|
assert.equal((await executor(frenet, document, context)).errors?.[0].code, 'PIPE_FRENET_UNSUPPORTED')
|
|
const spineSubelement: DocumentObjectSnapshot = { ...sweep, properties: sweep.properties.map((property) => property.name === 'Spine' ? { ...property, value: { schemaVersion: 1 as const, objectId: 'pipe-path', subElements: ['Edge1'] } } : property) }
|
|
assert.equal((await executor(spineSubelement, document, context)).errors?.[0].code, 'PIPE_SPINE_SUBELEMENT_UNSUPPORTED')
|
|
})
|
|
|
|
test('Part primitive and boolean nodes execute through the same Shape cache', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-part', documentVersion: 2 })
|
|
const calls: string[] = []
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async (input) => { calls.push(`box:${input.width}:${input.length}:${input.height}:${input.center?.join(',')}:${input.originOnCenter}`); return shape('box-shape') },
|
|
createCylinder: async (input) => { calls.push(`cylinder:${input.center?.join(',')}:${input.direction?.join(',')}:${input.originOnCenter}`); return shape('cylinder-shape') },
|
|
createSphere: async (input) => { calls.push(`sphere:${input.center?.join(',')}`); return shape('sphere-shape') },
|
|
createCone: async (input) => { calls.push(`cone:${input.center?.join(',')}:${input.direction?.join(',')}`); return shape('cone-shape') },
|
|
createTorus: async (input) => { calls.push(`torus:${input.majorRadius}:${input.minorRadius}:${input.center?.join(',')}:${input.direction?.join(',')}:${input.angle}`); return shape('torus-shape') },
|
|
createPrism: async (input) => { calls.push(`prism:${input.polygon}:${input.circumradius}:${input.height}:${input.firstAngle}:${input.secondAngle}:${input.center?.join(',')}`); return shape('prism-shape') },
|
|
createWedge: async (input) => { calls.push(`wedge:${input.xmin}:${input.ymin}:${input.zmin}:${input.z2min}:${input.x2min}:${input.xmax}:${input.ymax}:${input.zmax}:${input.z2max}:${input.x2max}:${input.center?.join(',')}`); return shape('wedge-shape') },
|
|
createEllipsoid: async (input) => { calls.push(`ellipsoid:${input.radius1}:${input.radius2}:${input.radius3}:${input.angle1}:${input.angle2}:${input.angle3}:${input.center?.join(',')}`); return shape('ellipsoid-shape') },
|
|
extrude: async () => shape('sphere-wedge'),
|
|
applyPlacement: async (input) => { calls.push(`placement:${input.placement.translation.join(',')}`); return shape('placed-shape') },
|
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}:${input.keepEdges}`); return shape('fuse-shape') },
|
|
cut: async () => shape('cut-shape'),
|
|
intersection: async (input) => { calls.push(`intersection:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('common-shape') },
|
|
pad: async () => shape('pad-shape'),
|
|
pocket: async () => shape('pocket-shape'),
|
|
revolution: async () => shape('revolution-shape'),
|
|
fillet: async () => shape('fillet-shape'),
|
|
chamfer: async () => shape('chamfer-shape'),
|
|
release: async () => undefined,
|
|
}
|
|
const boxProperties = (width: number) => [
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: width },
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5 },
|
|
]
|
|
const document: DocumentSnapshot = {
|
|
id: 'doc-part', label: 'Part fixture', version: 2, dirty: true, readOnly: false, units: 'mm',
|
|
tree: [{ id: 'boxA', label: 'Box', type: 'feature', state: 'dirty' }, { id: 'boxB', label: 'Box001', type: 'feature', state: 'dirty' }, { id: 'fuse', label: 'Union', type: 'feature', state: 'dirty' }],
|
|
objects: [
|
|
{ id: 'boxA', typeId: 'Part::Box', properties: boxProperties(2) },
|
|
{ id: 'boxB', typeId: 'Part::Box', properties: boxProperties(3) },
|
|
{ id: 'fuse', typeId: 'Part::Fuse', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxA' },
|
|
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxB' },
|
|
{ name: 'Refine', label: 'Refine shape', group: 'Boolean', scope: 'data', type: 'App::PropertyBool' as const, value: false },
|
|
] },
|
|
],
|
|
dependencies: [{ sourceId: 'fuse', targetId: 'boxA', relation: 'link' }, { sourceId: 'fuse', targetId: 'boxB', relation: 'link' }],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { boxA: 'touched', boxB: 'touched', fuse: 'touched' }, dirtyObjects: ['boxA', 'boxB'], order: [], errors: [] },
|
|
}
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const result = await new RecomputeCoordinator(createFacadeGeometryRecomputeExecutor(runtime, shapes), () => 2).run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.equal(shapes.get('boxA')?.id, 'box-shape')
|
|
assert.equal(shapes.get('fuse')?.id, 'fuse-shape')
|
|
assert.ok(calls.includes('box:4:5:2:2,1,2.5:true'))
|
|
assert.ok(calls.includes('box:4:5:3:2,1.5,2.5:true'))
|
|
assert.ok(calls.includes('union:box-shape,box-shape:true'))
|
|
|
|
const refinedFuse: DocumentObjectSnapshot = {
|
|
...document.objects[2],
|
|
id: 'refined-fuse',
|
|
properties: document.objects[2].properties.map((property) => property.name === 'Refine' ? { ...property, value: true } : property),
|
|
}
|
|
const refined = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(refinedFuse, { ...document, objects: [...document.objects, refinedFuse] }, { documentId: document.id, documentVersion: 2, generation: 2, signal: new AbortController().signal })
|
|
assert.equal(refined.status, 'success')
|
|
assert.ok(calls.includes('union:box-shape,box-shape:false'))
|
|
|
|
const sphere = { id: 'sphere', typeId: 'Part::Sphere', properties: [
|
|
{ name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5 },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Sphere', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: -45 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Sphere', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 45 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Sphere', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 120 },
|
|
] }
|
|
const sphereResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(sphere, { ...document, objects: [...document.objects, sphere] }, { documentId: document.id, documentVersion: 2, generation: 3, signal: new AbortController().signal })
|
|
assert.equal(sphereResult.status, 'success')
|
|
assert.ok(calls.includes('sphere:0,0,0'))
|
|
|
|
const torus: DocumentObjectSnapshot = { id: 'torus', typeId: 'Part::Torus', properties: [
|
|
{ name: 'Radius1', label: 'Major radius', group: 'Torus', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Radius2', label: 'Minor radius', group: 'Torus', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: -180 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: 180 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: 270 },
|
|
] }
|
|
const torusResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(torus, { ...document, objects: [...document.objects, torus] }, { documentId: document.id, documentVersion: 2, generation: 4, signal: new AbortController().signal })
|
|
assert.equal(torusResult.status, 'success')
|
|
assert.ok(calls.includes('torus:10:2:0,0,0:0,0,1:270'))
|
|
const trimmedTorus = { ...torus, id: 'torus-trimmed', properties: torus.properties.map((property) => property.name === 'Angle1' ? { ...property, value: -90 } : property) }
|
|
const trimmedTorusResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(trimmedTorus, { ...document, objects: [...document.objects, trimmedTorus] }, { documentId: document.id, documentVersion: 2, generation: 5, signal: new AbortController().signal })
|
|
assert.equal(trimmedTorusResult.status, 'failed')
|
|
assert.equal(trimmedTorusResult.errors?.[0].code, 'TORUS_MINOR_TRIM_UNSUPPORTED')
|
|
|
|
const prism: DocumentObjectSnapshot = { id: 'prism', typeId: 'Part::Prism', properties: [
|
|
{ name: 'Polygon', label: 'Polygon sides', group: 'Prism', scope: 'data', type: 'App::PropertyInteger', value: 6 },
|
|
{ name: 'Circumradius', label: 'Circumradius', group: 'Prism', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Height', label: 'Height', group: 'Prism', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'FirstAngle', label: 'First angle', group: 'Prism', scope: 'data', type: 'App::PropertyAngle', value: 10 },
|
|
{ name: 'SecondAngle', label: 'Second angle', group: 'Prism', scope: 'data', type: 'App::PropertyAngle', value: -5 },
|
|
] }
|
|
const prismResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(prism, { ...document, objects: [...document.objects, prism] }, { documentId: document.id, documentVersion: 2, generation: 6, signal: new AbortController().signal })
|
|
assert.equal(prismResult.status, 'success')
|
|
assert.ok(calls.includes('prism:6:2:10:10:-5:0,0,0'))
|
|
const invalidPrism = { ...prism, id: 'prism-invalid', properties: prism.properties.map((property) => property.name === 'Polygon' ? { ...property, value: 2 } : property) }
|
|
const invalidPrismResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(invalidPrism, { ...document, objects: [...document.objects, invalidPrism] }, { documentId: document.id, documentVersion: 2, generation: 7, signal: new AbortController().signal })
|
|
assert.equal(invalidPrismResult.status, 'failed')
|
|
assert.equal(invalidPrismResult.errors?.[0].code, 'PRISM_POLYGON_INVALID')
|
|
const wedge: DocumentObjectSnapshot = { id: 'wedge', typeId: 'Part::Wedge', properties: [
|
|
...[['Xmin', 0], ['Ymin', 0], ['Zmin', 0], ['Z2min', 0], ['X2min', 0], ['Xmax', 10], ['Ymax', 10], ['Zmax', 10], ['Z2max', 8], ['X2max', 8]].map(([name, value]) => ({ name: String(name), label: String(name), group: 'Wedge', scope: 'data' as const, type: 'App::PropertyLength' as const, value: Number(value) })),
|
|
] }
|
|
const wedgeResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(wedge, { ...document, objects: [...document.objects, wedge] }, { documentId: document.id, documentVersion: 2, generation: 8, signal: new AbortController().signal })
|
|
assert.equal(wedgeResult.status, 'success')
|
|
assert.ok(calls.includes('wedge:0:0:0:0:0:10:10:10:8:8:0,0,0'))
|
|
const invalidWedge = { ...wedge, id: 'wedge-invalid', properties: wedge.properties.map((property) => property.name === 'X2max' ? { ...property, value: -1 } : property) }
|
|
const invalidWedgeResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(invalidWedge, { ...document, objects: [...document.objects, invalidWedge] }, { documentId: document.id, documentVersion: 2, generation: 9, signal: new AbortController().signal })
|
|
assert.equal(invalidWedgeResult.status, 'failed')
|
|
assert.equal(invalidWedgeResult.errors?.[0].code, 'WEDGE_REMOTE_SPAN_INVALID')
|
|
const ellipsoid: DocumentObjectSnapshot = { id: 'ellipsoid', typeId: 'Part::Ellipsoid', properties: [
|
|
{ name: 'Radius1', label: 'Z radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Radius2', label: 'X radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 4 },
|
|
{ name: 'Radius3', label: 'Y radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: -90 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: 90 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: 360 },
|
|
] }
|
|
const ellipsoidResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(ellipsoid, { ...document, objects: [...document.objects, ellipsoid] }, { documentId: document.id, documentVersion: 2, generation: 10, signal: new AbortController().signal })
|
|
assert.equal(ellipsoidResult.status, 'success')
|
|
assert.ok(calls.includes('ellipsoid:2:4:0:-90:90:360:0,0,0'))
|
|
const trimmedEllipsoid = { ...ellipsoid, id: 'ellipsoid-trimmed', properties: ellipsoid.properties.map((property) => property.name === 'Angle3' ? { ...property, value: 180 } : property) }
|
|
const trimmedEllipsoidResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(trimmedEllipsoid, { ...document, objects: [...document.objects, trimmedEllipsoid] }, { documentId: document.id, documentVersion: 2, generation: 11, signal: new AbortController().signal })
|
|
assert.equal(trimmedEllipsoidResult.status, 'failed')
|
|
assert.equal(trimmedEllipsoidResult.errors?.[0].code, 'ELLIPSOID_TRIM_UNSUPPORTED')
|
|
})
|
|
|
|
test('Mirrored recompute mirrors a whole Shape across a document plane and releases fused copies', async () => {
|
|
const calls: string[] = []
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-mirrored', documentVersion: 4 })
|
|
let mirrorSequence = 0
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => shape('box-shape'),
|
|
createCylinder: async () => shape('cylinder'),
|
|
createSphere: async () => shape('sphere'),
|
|
createCone: async () => shape('cone'),
|
|
applyPlacement: async (input) => input.shape,
|
|
mirror: async (input) => { const result = shape(`mirror-${++mirrorSequence}`); calls.push(`mirror:${input.shape.id}:${input.origin.join(',')}:${input.normal.join(',')}`); return result },
|
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('mirrored-union') },
|
|
cut: async () => shape('cut'), intersection: async () => shape('intersection'), pad: async () => shape('pad'), pocket: async () => shape('pocket'), revolution: async () => shape('revolution'), fillet: async () => shape('fillet'), chamfer: async () => shape('chamfer'),
|
|
release: async (released) => { calls.push(`release:${released.id}`) },
|
|
}
|
|
const base: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
|
|
const mirrored: DocumentObjectSnapshot = { id: 'mirrored', typeId: 'PartDesign::Mirrored', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Mirrored', scope: 'data', type: 'App::PropertyLink', value: 'box' },
|
|
{ name: 'Plane', label: 'Plane', group: 'Mirrored', scope: 'data', type: 'App::PropertyEnumeration', value: 'XZ plane', options: ['XY plane', 'XZ plane', 'YZ plane'] },
|
|
{ name: 'Fuse', label: 'Fuse', group: 'Mirrored', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-mirrored', version: 4, tree: [{ id: 'box', label: 'Box', type: 'feature' }, { id: 'mirrored', label: 'Mirrored', type: 'feature' }], objects: [base, mirrored], dependencies: [{ sourceId: 'mirrored', targetId: 'box', relation: 'link' }] }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
await executor(base, document, { documentId: document.id, documentVersion: 4, generation: 1, signal: new AbortController().signal })
|
|
const fused = await executor(mirrored, document, { documentId: document.id, documentVersion: 4, generation: 1, signal: new AbortController().signal })
|
|
assert.equal(fused.status, 'success')
|
|
assert.equal(shapes.get('mirrored')?.id, 'mirrored-union')
|
|
assert.ok(calls.includes('mirror:box-shape:0,0,0:0,1,0'))
|
|
assert.ok(calls.includes('union:box-shape,mirror-1'))
|
|
assert.ok(calls.includes('release:mirror-1'))
|
|
|
|
const unfused = { ...mirrored, id: 'mirrored-unfused', properties: mirrored.properties.map((property) => property.name === 'Fuse' ? { ...property, value: false } : property) }
|
|
const unfusedResult = await executor(unfused, { ...document, objects: [base, unfused] }, { documentId: document.id, documentVersion: 4, generation: 2, signal: new AbortController().signal })
|
|
assert.equal(unfusedResult.status, 'success')
|
|
assert.equal(shapes.get('mirrored-unfused')?.id, 'mirror-2')
|
|
assert.equal(calls.includes('release:mirror-2'), false)
|
|
})
|
|
|
|
test('MultiTransform composes ordered whole-shape steps and releases every transient instance', async () => {
|
|
const calls: string[] = []
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-multi-transform', documentVersion: 5 })
|
|
let copySequence = 0
|
|
let mirrorSequence = 0
|
|
let unionSequence = 0
|
|
let failPlacementAt = 0
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => shape('box-shape'),
|
|
createCylinder: async () => shape('cylinder'), createSphere: async () => shape('sphere'), createCone: async () => shape('cone'),
|
|
applyPlacement: async (input) => {
|
|
calls.push(`placement:${input.shape.id}:${input.placement.translation.join(',')}:${input.placement.rotationAngle}`)
|
|
if (failPlacementAt > 0 && calls.filter((call) => call.startsWith('placement:')).length === failPlacementAt) throw new Error('multi placement failed')
|
|
return shape(`copy-${++copySequence}`)
|
|
},
|
|
mirror: async (input) => { calls.push(`mirror:${input.shape.id}:${input.normal.join(',')}`); return shape(`mirror-${++mirrorSequence}`) },
|
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape(`multi-result-${++unionSequence}`) },
|
|
cut: async () => shape('cut'), intersection: async () => shape('intersection'), pad: async () => shape('pad'), pocket: async () => shape('pocket'), revolution: async () => shape('revolution'), fillet: async () => shape('fillet'), chamfer: async () => shape('chamfer'),
|
|
release: async (released) => { calls.push(`release:${released.id}`) },
|
|
}
|
|
const base: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
|
|
const transformations = { steps: [
|
|
{ id: 'mirror-1', type: 'mirrored' as const, plane: 'YZ plane' as const },
|
|
{ id: 'linear-1', type: 'linear' as const, occurrences: 3, length: 20, direction: 'Vertical' as const },
|
|
] }
|
|
const multi: DocumentObjectSnapshot = { id: 'multi-transform', typeId: 'PartDesign::MultiTransform', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Multi-transform', scope: 'data', type: 'App::PropertyLink', value: 'box' },
|
|
{ name: 'Transformations', label: 'Transformations', group: 'Multi-transform', scope: 'data', type: 'App::PropertyMultiTransform', value: transformations },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-multi-transform', version: 5, tree: [{ id: 'box', label: 'Box', type: 'feature' }, { id: 'multi-transform', label: 'Multi-transform', type: 'feature' }], objects: [base, multi], dependencies: [{ sourceId: multi.id, targetId: base.id, relation: 'link' }] }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
|
await executor(base, document, context)
|
|
const completed = await executor(multi, document, context)
|
|
assert.equal(completed.status, 'success')
|
|
assert.equal(shapes.get(multi.id)?.id, 'multi-result-1')
|
|
assert.ok(calls.includes('mirror:box-shape:1,0,0'))
|
|
assert.ok(calls.includes('union:box-shape,mirror-1,copy-1,copy-2,copy-3,copy-4'))
|
|
for (const id of ['mirror-1', 'copy-1', 'copy-2', 'copy-3', 'copy-4']) assert.ok(calls.includes(`release:${id}`))
|
|
|
|
const placementCalls = calls.filter((call) => call.startsWith('placement:')).length
|
|
failPlacementAt = placementCalls + 2
|
|
const failed = await executor(multi, document, { ...context, generation: 2 })
|
|
assert.equal(failed.status, 'failed')
|
|
assert.match(failed.errors?.[0].message ?? '', /multi placement failed/)
|
|
assert.equal(shapes.get(multi.id)?.id, 'multi-result-1')
|
|
assert.ok(calls.includes('release:mirror-2'))
|
|
assert.ok(calls.includes('release:copy-5'))
|
|
assert.equal(calls.includes('release:multi-result-1'), false)
|
|
|
|
const excessive = { ...multi, properties: multi.properties.map((property) => property.name === 'Transformations' ? { ...property, value: { steps: [
|
|
{ id: 'linear-a', type: 'linear' as const, occurrences: 10, length: 1, direction: 'Horizontal' as const },
|
|
{ id: 'linear-b', type: 'linear' as const, occurrences: 10, length: 1, direction: 'Vertical' as const },
|
|
{ id: 'mirror-a', type: 'mirrored' as const, plane: 'XY plane' as const },
|
|
] } } : property) }
|
|
const invalid = await executor(excessive, { ...document, objects: [base, excessive] }, { ...context, generation: 3 })
|
|
assert.equal(invalid.status, 'failed')
|
|
assert.equal(invalid.errors?.[0].code, 'MULTITRANSFORM_INVALID')
|
|
assert.match(invalid.errors?.[0].message ?? '', /more than 100/)
|
|
})
|
|
|
|
test('object Placement transforms recomputed shapes before committing the cache', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-placement', documentVersion: 3 })
|
|
const calls: string[] = []
|
|
const boxCenters: string[] = []
|
|
let localShapeId = 'box-local'
|
|
let placementShouldFail = false
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async (input) => { calls.push(`box:${localShapeId}`); boxCenters.push(input.center?.join(',') ?? ''); return shape(localShapeId) },
|
|
createCylinder: async () => shape('cylinder-local'),
|
|
createSphere: async () => shape('sphere-local'),
|
|
createCone: async () => shape('cone-local'),
|
|
applyPlacement: async (input) => {
|
|
calls.push(`placement:${input.shape.id}:${input.placement.translation.join(',')}:${input.placement.rotationAxis.join(',')}:${input.placement.rotationAngle}`)
|
|
if (placementShouldFail) throw new Error('OCCT placement failed')
|
|
return shape('box-placed')
|
|
},
|
|
union: async () => shape('union'),
|
|
cut: async () => shape('cut'),
|
|
intersection: async () => shape('common'),
|
|
pad: async () => shape('pad'),
|
|
pocket: async () => shape('pocket'),
|
|
revolution: async () => shape('revolution'),
|
|
fillet: async () => shape('fillet'),
|
|
chamfer: async () => shape('chamfer'),
|
|
release: async (released) => { calls.push(`release:${released.id}`) },
|
|
}
|
|
const object = { id: 'box', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 3 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
|
|
{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 5, y: -2, z: 1 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 90 } } },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-placement', version: 3, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const result = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(object, document, { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal })
|
|
assert.equal(result.status, 'success')
|
|
assert.equal(shapes.get('box')?.id, 'box-placed')
|
|
assert.deepEqual(calls, ['box:box-local', 'placement:box-local:5,-2,1:0,1,0:90', 'release:box-local'])
|
|
assert.equal(boxCenters.at(-1), '1,1.5,2')
|
|
|
|
const support = { id: 'support', typeId: 'Part::Box', properties: [
|
|
{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 10, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 90 } } },
|
|
] }
|
|
const attached = { id: 'attached', typeId: 'Part::Box', properties: [
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
|
|
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyLink' as const, value: { objectId: 'support' } },
|
|
{ name: 'AttachmentOffset', label: 'Attachment offset', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 2, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } },
|
|
] }
|
|
await createFacadeGeometryRecomputeExecutor(runtime, shapes)(attached, { ...document, tree: [{ id: 'support', label: 'Support', type: 'feature' }, { id: 'attached', label: 'Attached', type: 'feature' }], objects: [support, attached] }, { documentId: document.id, documentVersion: document.version, generation: 5, signal: new AbortController().signal })
|
|
assert.equal(calls.at(-2), 'placement:box-local:10,2,0:0,0,1:90')
|
|
assert.equal(calls.at(-1), 'release:box-local')
|
|
|
|
const translated = { ...object, id: 'box-translated', properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: { x: 7, y: 8, z: 9 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } } : property) }
|
|
const placementsBeforeTranslation = calls.filter((call) => call.startsWith('placement:')).length
|
|
const translatedResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(translated, { ...document, objects: [translated] }, { documentId: document.id, documentVersion: document.version, generation: 6, signal: new AbortController().signal })
|
|
assert.equal(translatedResult.status, 'success')
|
|
assert.equal(boxCenters.at(-1), '8,9.5,11')
|
|
assert.equal(calls.filter((call) => call.startsWith('placement:')).length, placementsBeforeTranslation)
|
|
|
|
const identity = { ...object, id: 'box-identity', properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } } : property) }
|
|
const placementsBeforeIdentity = calls.filter((call) => call.startsWith('placement:')).length
|
|
await createFacadeGeometryRecomputeExecutor(runtime, shapes)(identity, { ...document, objects: [identity] }, { documentId: document.id, documentVersion: document.version, generation: 2, signal: new AbortController().signal })
|
|
assert.equal(calls.filter((call) => call.startsWith('placement:')).length, placementsBeforeIdentity)
|
|
|
|
localShapeId = 'box-retry'
|
|
placementShouldFail = true
|
|
const failed = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(object, document, { documentId: document.id, documentVersion: document.version, generation: 3, signal: new AbortController().signal })
|
|
assert.equal(failed.status, 'failed')
|
|
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.match(failed.errors?.[0].message ?? '', /OCCT placement failed/)
|
|
assert.equal(shapes.get('box')?.id, 'box-placed')
|
|
assert.ok(calls.includes('release:box-retry'))
|
|
assert.equal(calls.includes('release:box-placed'), false)
|
|
|
|
const malformed = { ...object, properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: null, rotation: {} } as never } : property) }
|
|
const boxCallsBeforeMalformedInput = calls.filter((call) => call.startsWith('box:')).length
|
|
const malformedResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(malformed, { ...document, objects: [malformed] }, { documentId: document.id, documentVersion: document.version, generation: 4, signal: new AbortController().signal })
|
|
assert.equal(malformedResult.status, 'failed')
|
|
assert.equal(malformedResult.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.match(malformedResult.errors?.[0].message ?? '', /invalid structure/)
|
|
assert.equal(calls.filter((call) => call.startsWith('box:')).length, boxCallsBeforeMalformedInput)
|
|
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(document)
|
|
const loaded = await persistence.load(document.id)
|
|
const loadedObject = loaded?.objects.find((candidate) => candidate.id === object.id)
|
|
assert.ok(loaded && loadedObject)
|
|
calls.length = 0
|
|
localShapeId = 'box-restored'
|
|
placementShouldFail = false
|
|
const restoredShapes = new Map<string, ShapeHandle>()
|
|
const restored = await createFacadeGeometryRecomputeExecutor(runtime, restoredShapes)(loadedObject, loaded, { documentId: loaded.id, documentVersion: loaded.version, generation: 5, signal: new AbortController().signal })
|
|
assert.equal(restored.status, 'success')
|
|
assert.equal(restoredShapes.get('box')?.id, 'box-placed')
|
|
assert.ok(calls.includes('placement:box-restored:5,-2,1:0,1,0:90'))
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('geometry recompute captures generation topology history and isolates capture failures', async () => {
|
|
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
|
|
let currentFace = square
|
|
let topologyShouldFail = false
|
|
const releases: string[] = []
|
|
const shape = (id: string, documentVersion: number): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-topology-generation', documentVersion })
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async (input) => shape(`box-${input.documentVersion}`, input.documentVersion),
|
|
createCylinder: async (input) => shape('cylinder', input.documentVersion),
|
|
createSphere: async (input) => shape('sphere', input.documentVersion),
|
|
createCone: async (input) => shape('cone', input.documentVersion),
|
|
applyPlacement: async (input) => input.shape,
|
|
union: async (input) => shape('union', input.documentVersion),
|
|
cut: async (input) => shape('cut', input.documentVersion),
|
|
intersection: async (input) => shape('intersection', input.documentVersion),
|
|
pad: async (input) => shape('pad', input.documentVersion),
|
|
pocket: async (input) => shape('pocket', input.documentVersion),
|
|
revolution: async (input) => shape('revolution', input.documentVersion),
|
|
fillet: async (input) => shape('fillet', input.documentVersion),
|
|
chamfer: async (input) => shape('chamfer', input.documentVersion),
|
|
topology: async (handle): Promise<SubshapeTopology> => {
|
|
if (topologyShouldFail) throw new Error('topology capture failed')
|
|
const created = createSubshapeRefs(handle.id, handle.documentVersion, [currentFace])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
},
|
|
release: async (released) => { releases.push(released.id) },
|
|
}
|
|
const object: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const firstDocument: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-topology-generation', version: 1, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] }
|
|
const first = await executor(object, firstDocument, { documentId: firstDocument.id, documentVersion: 1, generation: 1, signal: new AbortController().signal })
|
|
assert.equal(first.status, 'success')
|
|
assert.equal(first.updatedObject?.topology?.generation, 1)
|
|
assert.equal(first.updatedObject?.topology?.history.counts.generated, 1)
|
|
assert.equal(first.updatedObject?.topology?.entries[0].ref.status, 'new')
|
|
|
|
currentFace = translated
|
|
const secondObject = first.updatedObject as DocumentObjectSnapshot
|
|
const secondDocument = { ...firstDocument, version: 2, objects: [secondObject] }
|
|
const second = await executor(secondObject, secondDocument, { documentId: secondDocument.id, documentVersion: 2, generation: 2, signal: new AbortController().signal })
|
|
assert.equal(second.status, 'success')
|
|
assert.equal(second.updatedObject?.topology?.generation, 2)
|
|
assert.equal(second.updatedObject?.topology?.migration.previousGeneration, 1)
|
|
assert.equal(second.updatedObject?.topology?.history.counts.modified, 1)
|
|
assert.equal(second.updatedObject?.topology?.entries[0].ref.persistentId, first.updatedObject?.topology?.entries[0].ref.persistentId)
|
|
assert.equal(shapes.get('box')?.id, 'box-2')
|
|
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save({ ...secondDocument, objects: [second.updatedObject as DocumentObjectSnapshot] })
|
|
assert.deepEqual((await persistence.load(secondDocument.id))?.objects[0].topology, second.updatedObject?.topology)
|
|
await persistence.dispose()
|
|
|
|
const staleObject = second.updatedObject as DocumentObjectSnapshot
|
|
const staleDocument = { ...secondDocument, version: 3, objects: [staleObject] }
|
|
await assert.rejects(() => executor(staleObject, staleDocument, { documentId: staleDocument.id, documentVersion: 3, generation: 3, signal: new AbortController().signal, isCurrent: () => false }), /no longer current/)
|
|
assert.equal(shapes.get('box')?.id, 'box-2')
|
|
assert.ok(releases.includes('box-3'))
|
|
|
|
topologyShouldFail = true
|
|
const thirdObject = second.updatedObject as DocumentObjectSnapshot
|
|
const thirdDocument = { ...secondDocument, version: 4, objects: [thirdObject] }
|
|
const failed = await executor(thirdObject, thirdDocument, { documentId: thirdDocument.id, documentVersion: 4, generation: 4, signal: new AbortController().signal })
|
|
assert.equal(failed.status, 'failed')
|
|
assert.match(failed.errors?.[0].message ?? '', /topology capture failed/)
|
|
assert.equal(shapes.get('box')?.id, 'box-2')
|
|
assert.ok(releases.includes('box-4'))
|
|
})
|
|
|
|
test('generation topology migration updates LinkSub and external geometry without forcing ambiguous or deleted refs', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const previous = createSubshapeRefs('shape-old', 1, [face])
|
|
const current = createSubshapeRefs('shape-new', 2, [face, face])
|
|
const previousEntries = previous.refs.map((ref, index) => ({ ref, signature: previous.signatures[index] }))
|
|
const currentEntries = current.refs.map((ref, index) => ({ ref, signature: current.signatures[index] }))
|
|
const initialDuplicate = migrateTopoRefs('source', [], currentEntries, 1)
|
|
assert.equal(initialDuplicate.counts.ambiguous, 2)
|
|
const migration = migrateTopoRefs('source', previousEntries, currentEntries, 2)
|
|
const topology: ObjectTopologySnapshot = {
|
|
shapeId: 'shape-new', documentVersion: 2, generation: 2,
|
|
entries: currentEntries,
|
|
migration: { previousGeneration: 1, matches: migration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:2', [{ objectId: 'source', entries: previousEntries }], currentEntries),
|
|
}
|
|
const reference = createPersistedTopoRef('source', previous.refs[0], 1)
|
|
const sketch = createSketch('owner')
|
|
sketch.externalGeometry.push({ id: 'external', source: { ...reference }, projection: { id: 'projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true })
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(), id: 'doc-topology-migration', version: 2,
|
|
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'sketch' }],
|
|
objects: [
|
|
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
|
|
{ id: 'owner', typeId: 'Sketcher::SketchObject', properties: [{ name: 'SupportFace', label: 'Support face', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: { ...reference } }], sketch },
|
|
], dependencies: [],
|
|
}
|
|
const ambiguous = migrateDocumentTopologyReferences(document, ['source'])
|
|
const property = document.objects[1].properties[0].value
|
|
const external = document.objects[1].sketch?.externalGeometry[0].source
|
|
assert.equal(property && typeof property === 'object' && !Array.isArray(property) && 'status' in property ? property.status : null, 'ambiguous')
|
|
assert.equal(external?.status, 'ambiguous')
|
|
assert.equal(external?.generation, 2)
|
|
assert.equal(external?.candidates?.length, 2)
|
|
assert.deepEqual(ambiguous.changedOwnerIds, ['owner'])
|
|
assert.deepEqual(ambiguous.issues.map((issue) => issue.referenceName), ['SupportFace', 'ExternalGeometry:external'])
|
|
const propertyCandidate = external?.candidates?.[0] as string
|
|
const resolvedProperty = resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'SupportFace', candidatePersistentId: propertyCandidate })
|
|
assert.equal(resolvedProperty.status, 'stable')
|
|
assert.equal(resolvedProperty.generation, 2)
|
|
const externalCandidate = external?.candidates?.[1] as string
|
|
assert.equal(resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'ExternalGeometry:external', candidatePersistentId: externalCandidate }).persistentId, externalCandidate)
|
|
assert.throws(() => resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'SupportFace', candidatePersistentId: 'missing' }), /not available/)
|
|
|
|
const deletedMigration = migrateTopoRefs('source', previousEntries, [], 3)
|
|
document.objects[0].topology = {
|
|
shapeId: 'shape-empty', documentVersion: 3, generation: 3, entries: [],
|
|
migration: { previousGeneration: 1, matches: deletedMigration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:3', [{ objectId: 'source', entries: previousEntries }], []),
|
|
}
|
|
document.objects[1].properties[0].value = { ...reference }
|
|
if (document.objects[1].sketch) document.objects[1].sketch.externalGeometry[0].source = { ...reference }
|
|
const deleted = migrateDocumentTopologyReferences(document, ['source'])
|
|
assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'deleted')
|
|
assert.equal(document.objects[1].sketch?.externalGeometry[0].source.status, 'deleted')
|
|
assert.ok(deleted.issues.every((issue) => issue.status === 'deleted'))
|
|
|
|
const currentSingle = createSubshapeRefs('shape-later', 4, [face])
|
|
const currentSingleEntries = currentSingle.refs.map((ref, index) => ({ ref: { ...ref, persistentId: reference.persistentId }, signature: currentSingle.signatures[index] }))
|
|
const skippedGeneration = migrateTopoRefs('source', [], currentSingleEntries, 4)
|
|
document.objects[0].topology = {
|
|
shapeId: 'shape-later', documentVersion: 4, generation: 4, entries: currentSingleEntries,
|
|
migration: { previousGeneration: 3, matches: skippedGeneration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:4', [], currentSingleEntries),
|
|
}
|
|
document.objects[1].properties[0].value = { ...reference }
|
|
const resolvedAcrossGap = migrateDocumentTopologyReferences(document, ['source'])
|
|
assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'stable')
|
|
assert.equal((document.objects[1].properties[0].value as typeof reference).generation, 4)
|
|
assert.equal(resolvedAcrossGap.issues.length, 1)
|
|
})
|
|
|
|
test('structured Support TopoRef migrates and reports deleted face support', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const previous = createSubshapeRefs('support-old', 1, [face])
|
|
const current = createSubshapeRefs('support-new', 2, [face])
|
|
const previousEntries = previous.refs.map((ref, index) => ({ ref, signature: previous.signatures[index] }))
|
|
const currentEntries = current.refs.map((ref, index) => ({ ref: { ...ref, persistentId: previous.refs[index].persistentId }, signature: current.signatures[index] }))
|
|
const migration = migrateTopoRefs('source', previousEntries, currentEntries, 2)
|
|
const reference = createPersistedTopoRef('source', previous.refs[0], 1)
|
|
const topology: ObjectTopologySnapshot = { shapeId: 'support-new', documentVersion: 2, generation: 2, entries: currentEntries, migration: { previousGeneration: 1, matches: migration.matches }, history: captureSignatureTopologyHistory('support:2', [{ objectId: 'source', entries: previousEntries }], currentEntries) }
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(), id: 'doc-support-migration', version: 2,
|
|
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'sketch', label: 'Sketch', type: 'sketch' }],
|
|
objects: [
|
|
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
|
|
{ id: 'sketch', typeId: 'Sketcher::SketchObject', properties: [{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: { objectId: 'source', subElement: reference } }], sketch: createSketch('sketch') },
|
|
], dependencies: [],
|
|
}
|
|
const migrated = migrateDocumentTopologyReferences(document, ['source'])
|
|
const support = document.objects[1].properties[0].value as { objectId: string; subElement: typeof reference }
|
|
assert.equal(support.objectId, 'source')
|
|
assert.equal(support.subElement.generation, 2)
|
|
assert.equal(migrated.issues.length, 0)
|
|
const resolved = resolveDocumentTopologyReference(document, { ownerObjectId: 'sketch', referenceName: 'Support', candidatePersistentId: currentEntries[0].ref.persistentId })
|
|
assert.equal(resolved.status, 'stable')
|
|
|
|
document.objects[0].topology = { shapeId: 'support-empty', documentVersion: 3, generation: 3, entries: [], migration: { previousGeneration: 2, matches: migrateTopoRefs('source', currentEntries, [], 3).matches }, history: captureSignatureTopologyHistory('support:3', [{ objectId: 'source', entries: currentEntries }], []) }
|
|
const deleted = migrateDocumentTopologyReferences(document, ['source'])
|
|
assert.equal((document.objects[1].properties[0].value as { subElement: typeof reference }).subElement.status, 'deleted')
|
|
assert.equal(deleted.issues[0]?.referenceName, 'Support')
|
|
})
|
|
|
|
test('PropertyLinkSubList migrates stable TopoRefs and resolves selected candidates', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const previous = createSubshapeRefs('link-list-old', 1, [face])
|
|
const current = createSubshapeRefs('link-list-new', 2, [face])
|
|
const previousEntries = previous.refs.map((ref, index) => ({ ref, signature: previous.signatures[index] }))
|
|
const currentEntries = current.refs.map((ref, index) => ({ ref: { ...ref, persistentId: previous.refs[index].persistentId }, signature: current.signatures[index] }))
|
|
const migration = migrateTopoRefs('source', previousEntries, currentEntries, 2)
|
|
const topology: ObjectTopologySnapshot = { shapeId: 'link-list-new', documentVersion: 2, generation: 2, entries: currentEntries, migration: { previousGeneration: 1, matches: migration.matches }, history: captureSignatureTopologyHistory('link-list:2', [{ objectId: 'source', entries: previousEntries }], currentEntries) }
|
|
const reference = createPersistedTopoRef('source', previous.refs[0], 1)
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(), id: 'doc-link-sub-list', version: 2,
|
|
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'feature' }],
|
|
objects: [
|
|
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
|
|
{ id: 'owner', typeId: 'Part::Feature', properties: [{ name: 'References', label: 'References', group: 'Links', scope: 'data', type: 'App::PropertyLinkSubList', value: { schemaVersion: 1, entries: [{ objectId: 'source', subElement: reference }, { objectId: 'source', subElement: 'Face2' }] } }] },
|
|
], dependencies: [],
|
|
}
|
|
const migrated = migrateDocumentTopologyReferences(document, ['source'])
|
|
const list = document.objects[1].properties[0].value as { schemaVersion: 1; entries: Array<{ objectId: string; subElement: typeof reference | string }> }
|
|
assert.equal(typeof list.entries[0].subElement === 'object' ? list.entries[0].subElement.generation : null, 2)
|
|
assert.equal(list.entries[1].subElement, 'Face2')
|
|
assert.deepEqual(migrated.issues, [])
|
|
const resolved = resolveDocumentTopologyReference(document, { ownerObjectId: 'owner', referenceName: 'References', currentPersistentId: reference.persistentId, candidatePersistentId: currentEntries[0].ref.persistentId })
|
|
assert.equal(resolved.status, 'stable')
|
|
const resolvedList = document.objects[1].properties[0].value as typeof list
|
|
assert.equal((resolvedList.entries[0].subElement as typeof reference).persistentId, currentEntries[0].ref.persistentId)
|
|
})
|
|
|
|
test('Facade topology replacement is versioned, rebuilds dependencies and is undoable', async () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const current = createSubshapeRefs('shape-current', 5, [face, face])
|
|
const entries = current.refs.map((ref, index) => ({ ref, signature: current.signatures[index] }))
|
|
const migration = migrateTopoRefs('source', [], entries, 5)
|
|
const candidateIds = migration.matches.map((match) => match.current.persistentId)
|
|
const reference = createPersistedTopoRef('source', { ...current.refs[0], status: 'ambiguous', candidates: candidateIds }, 4)
|
|
const topology: ObjectTopologySnapshot = {
|
|
shapeId: 'shape-current', documentVersion: 5, generation: 5, entries,
|
|
migration: { previousGeneration: 4, matches: migration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:5', [], entries),
|
|
}
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(), id: 'doc-topology-repair', version: 5,
|
|
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'feature' }],
|
|
objects: [
|
|
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
|
|
{ id: 'owner', typeId: 'PartDesign::Feature', properties: [{ name: 'SupportFace', label: 'Support face', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: reference, recompute: true }] },
|
|
], dependencies: [{ sourceId: 'owner', targetId: 'source', relation: 'topo-ref', propertyName: 'SupportFace', reference: reference.persistentId }],
|
|
recompute: { generation: 5, status: 'idle', objectStates: { source: 'up-to-date', owner: 'touched' }, dirtyObjects: ['owner'], order: [], errors: [] },
|
|
}
|
|
const facade = createMockFacade()
|
|
await facade.project.save(document)
|
|
await facade.app.document.load(document.id)
|
|
const versionBefore = facade.app.document.getActive().version
|
|
const resolved = facade.app.document.resolveTopologyReference({ ownerObjectId: 'owner', referenceName: 'SupportFace', candidatePersistentId: candidateIds[1] })
|
|
assert.equal(resolved.status, 'stable')
|
|
assert.equal(resolved.persistentId, candidateIds[1])
|
|
assert.equal(facade.app.document.getActive().version, versionBefore + 1)
|
|
assert.equal(facade.app.document.getActive().dirty, true)
|
|
assert.equal(facade.app.document.getDependencies().find((edge) => edge.propertyName === 'SupportFace')?.reference, candidateIds[1])
|
|
facade.history.undo()
|
|
assert.equal((facade.app.document.getObject('owner')?.properties[0].value as typeof reference).status, 'ambiguous')
|
|
facade.history.redo()
|
|
assert.equal((facade.app.document.getObject('owner')?.properties[0].value as typeof reference).persistentId, candidateIds[1])
|
|
facade.geometry.dispose()
|
|
})
|
|
|
|
test('facade async recompute commits only an accepted generation', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })
|
|
const result = await facade.app.document.recomputeAsync()
|
|
assert.equal(result.status, 'completed')
|
|
assert.equal(facade.app.document.getActive().recompute?.generation, result.generation)
|
|
assert.equal(facade.app.document.getActive().recompute?.dirtyObjects.length, 0)
|
|
})
|
|
|
|
test('feature suppression persists and unsuppress recomputes only its dependent closure', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
|
|
const suppressed = facade.app.document.recompute()
|
|
assert.equal(suppressed.status, 'completed')
|
|
assert.deepEqual(suppressed.order, ['pocket', 'fillet', 'body'])
|
|
assert.deepEqual(new Set(suppressed.affected), new Set(['pocket', 'fillet', 'body']))
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'suppressed')
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'upstream-suppressed')
|
|
|
|
await facade.project.save()
|
|
assert.equal((await facade.project.load('doc-pump-housing'))?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'Suppressed')?.value, true)
|
|
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: false })
|
|
const restored = await facade.app.document.recomputeAsync()
|
|
assert.equal(restored.status, 'completed')
|
|
assert.deepEqual(restored.order, ['pocket', 'fillet'])
|
|
assert.deepEqual(new Set(restored.affected), new Set(['pocket', 'fillet']))
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'up-to-date')
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'up-to-date')
|
|
})
|
|
|
|
test('persistence writes are serialized and continue after a failed write', async () => {
|
|
const queue = new PersistenceWriteQueue()
|
|
let inFlight = 0
|
|
let maximumInFlight = 0
|
|
const order: string[] = []
|
|
const write = (id: string, shouldFail = false) => queue.run(async () => {
|
|
inFlight += 1
|
|
maximumInFlight = Math.max(maximumInFlight, inFlight)
|
|
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
order.push(id)
|
|
inFlight -= 1
|
|
if (shouldFail) throw new Error('expected write failure')
|
|
return id
|
|
})
|
|
const results = await Promise.allSettled([write('one'), write('two', true), write('three')])
|
|
assert.equal(maximumInFlight, 1)
|
|
assert.deepEqual(order, ['one', 'two', 'three'])
|
|
assert.equal(results[0].status, 'fulfilled')
|
|
assert.equal(results[1].status, 'rejected')
|
|
assert.equal(results[2].status, 'fulfilled')
|
|
})
|
|
|
|
test('persistence write queue preserves all 1000 transaction slots under repeated failures', async () => {
|
|
const queue = new PersistenceWriteQueue()
|
|
const order: number[] = []
|
|
let inFlight = 0
|
|
let maximumInFlight = 0
|
|
const writes = Array.from({ length: 1000 }, (_, index) => queue.run(async () => {
|
|
inFlight += 1
|
|
maximumInFlight = Math.max(maximumInFlight, inFlight)
|
|
await Promise.resolve()
|
|
order.push(index)
|
|
inFlight -= 1
|
|
if (index % 137 === 0) throw new Error(`expected failure ${index}`)
|
|
return index
|
|
}))
|
|
const results = await Promise.allSettled(writes)
|
|
await queue.drain()
|
|
assert.equal(maximumInFlight, 1)
|
|
assert.deepEqual(order, Array.from({ length: 1000 }, (_, index) => index))
|
|
assert.equal(results.filter((result) => result.status === 'rejected').length, 8)
|
|
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 992)
|
|
})
|
|
|
|
test('project persistence broadcasts saved document versions across clients', async () => {
|
|
const writer = createSqliteProjectPersistence()
|
|
const observer = createSqliteProjectPersistence()
|
|
const notice = new Promise<{ documentId: string; documentVersion: number }>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('Expected cross-tab save notice.')), 250)
|
|
const unsubscribe = observer.subscribeExternalChanges((change) => {
|
|
clearTimeout(timer)
|
|
unsubscribe()
|
|
resolve(change)
|
|
})
|
|
})
|
|
const document = recomputeDocumentFixture()
|
|
const saved = await writer.save(document)
|
|
assert.equal(writer.capabilities().crossTabWriteLock, 'local-queue')
|
|
const received = await notice
|
|
assert.equal(received.documentId, saved.documentId)
|
|
assert.equal(received.documentVersion, saved.documentVersion)
|
|
await writer.dispose()
|
|
await observer.dispose()
|
|
})
|
|
|
|
test('autosave scheduler coalesces idle document changes and flushes the latest version', async () => {
|
|
const savedVersions: number[] = []
|
|
const scheduler = new ProjectAutosaveScheduler(async (document) => { savedVersions.push(document.version); return { documentId: document.id, documentVersion: document.version, persistedAt: Date.now(), mode: 'sqlite-memory' } }, 5)
|
|
const document = (version: number) => ({ id: 'doc', label: 'Autosave', version, dirty: true, readOnly: false, units: 'mm', tree: [], objects: [] })
|
|
scheduler.schedule(document(1))
|
|
scheduler.schedule(document(2))
|
|
await new Promise((resolve) => setTimeout(resolve, 15))
|
|
assert.deepEqual(savedVersions, [2])
|
|
scheduler.schedule(document(3))
|
|
const result = await scheduler.flush()
|
|
assert.equal(result?.documentVersion, 3)
|
|
scheduler.cancel()
|
|
})
|
|
|
|
test('workbench and selection changes are event driven', () => {
|
|
const facade = createMockFacade()
|
|
const events: string[] = []
|
|
const unsubscribe = facade.subscribe((event) => events.push(event.type))
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
facade.selection.select('sketch')
|
|
unsubscribe()
|
|
assert.equal(facade.getState().activeWorkbench, 'Sketcher')
|
|
assert.equal(facade.selection.getObjectId(), 'sketch')
|
|
facade.selection.selectObjects(['pad', 'pocket', 'pad', 'missing'])
|
|
assert.deepEqual(facade.selection.getObjectIds(), ['pad', 'pocket'])
|
|
assert.equal(facade.selection.getObjectId(), 'pad')
|
|
assert.deepEqual(facade.getState().selectedObjectIds, ['pad', 'pocket'])
|
|
facade.selection.clear()
|
|
assert.deepEqual(facade.selection.getObjectIds(), [])
|
|
assert.ok(events.includes('state.changed'))
|
|
assert.ok(events.includes('notice'))
|
|
})
|
|
|
|
test('subshape selection rejects transient or unavailable topology IDs', () => {
|
|
const facade = createMockFacade()
|
|
assert.throws(() => facade.selection.selectSubshape({ objectId: 'pad', kind: 'face', persistentId: 'faceIndex:0' }), /not available/)
|
|
assert.equal(facade.selection.getSubshape(), null)
|
|
assert.equal(facade.selection.getPreselection(), null)
|
|
facade.selection.preselectSubshape(null)
|
|
facade.geometry.dispose()
|
|
})
|
|
|
|
test('commands and tasks preserve apply/cancel lifecycle', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
assert.equal(facade.task.getActive()?.status, 'preview')
|
|
facade.task.update({ support: 'XY_Plane' })
|
|
assert.equal(facade.task.getActive()?.draft.support, 'XY_Plane')
|
|
facade.task.cancel()
|
|
assert.equal(facade.task.getActive()?.status, 'cancelled')
|
|
})
|
|
|
|
test('disabled commands return a reason instead of mutating the document', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.getState().document.version
|
|
const state = facade.gui.command.getState('pad')
|
|
assert.equal(state.status, 'enabled')
|
|
facade.selection.clear()
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
const disabled = facade.gui.command.getState('pad')
|
|
assert.equal(disabled.status, 'disabled')
|
|
assert.match(disabled.reason || '', /Part Design|compatible object/)
|
|
facade.gui.command.execute({ commandId: 'pad' })
|
|
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_DISABLED')
|
|
assert.equal(facade.getState().document.version, before)
|
|
})
|
|
|
|
test('profile feature commands reject an open Sketch before entering recompute', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
facade.task.apply()
|
|
facade.selection.select('sketch001')
|
|
assert.equal(facade.gui.command.getState('pad').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('pad').reason || '', /closed supported Sketcher profile/)
|
|
facade.app.sketcher.addGeometry('sketch001', { id: 'edge-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 } })
|
|
facade.app.sketcher.addGeometry('sketch001', { id: 'edge-2', type: 'line', start: { x: 2, y: 0 }, end: { x: 2, y: 1 } })
|
|
facade.app.sketcher.addGeometry('sketch001', { id: 'edge-3', type: 'line', start: { x: 2, y: 1 }, end: { x: 0, y: 1 } })
|
|
facade.app.sketcher.addGeometry('sketch001', { id: 'edge-4', type: 'line', start: { x: 0, y: 1 }, end: { x: 0, y: 0 } })
|
|
assert.equal(facade.gui.command.getState('pad').status, 'enabled')
|
|
facade.geometry.dispose()
|
|
})
|
|
|
|
test('manifest commands without a Bitbybit executor are explicitly unsupported', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.getState().document.version
|
|
const state = facade.gui.command.getState('additive-primitive')
|
|
assert.equal(state.status, 'disabled')
|
|
assert.match(state.reason || '', /business executor/)
|
|
facade.gui.command.execute({ commandId: 'additive-primitive' })
|
|
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
|
|
assert.equal(facade.getState().document.version, before)
|
|
})
|
|
|
|
test('implemented commands enforce their FreeCAD workbench preconditions', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
assert.equal(facade.gui.command.getState('create-body').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('create-body').reason || '', /Part Design/)
|
|
assert.equal(facade.gui.command.getState('new-sketch').status, 'enabled')
|
|
facade.gui.workbench.setActive('Part Design')
|
|
assert.equal(facade.gui.command.getState('new-sketch').status, 'disabled')
|
|
assert.equal(facade.gui.command.getState('pocket').status, 'enabled')
|
|
})
|
|
|
|
test('command events carry a document-scoped request context', () => {
|
|
const facade = createMockFacade()
|
|
const events: string[] = []
|
|
let requestId = ''
|
|
facade.subscribe((event) => { events.push(event.type); if (event.type === 'command.started') requestId = event.context.requestId })
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
assert.ok(events.includes('command.started'))
|
|
assert.ok(events.includes('command.completed'))
|
|
assert.match(requestId, /^req-/)
|
|
})
|
|
|
|
test('document mutations use the facade history boundary', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'new-document' })
|
|
assert.equal(facade.app.document.getActive().label, 'Untitled document')
|
|
assert.equal(facade.history.canUndo(), true)
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getActive().label, 'Pump Housing')
|
|
assert.equal(facade.history.canRedo(), true)
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getActive().label, 'Untitled document')
|
|
})
|
|
|
|
test('typed properties are validated, versioned and undoable through the document boundary', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.app.document.getActive().version
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42)
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 50 })
|
|
assert.equal(facade.app.document.getActive().version, before + 1)
|
|
assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'pad')?.state, 'dirty')
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Status')?.value, 'Touched')
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: -1 }), /negative/)
|
|
assert.equal(facade.app.document.getActive().version, before + 1)
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42)
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 50)
|
|
})
|
|
|
|
test('Placement properties are deeply cloned, validated, undoable and persisted', async () => {
|
|
const facade = createMockFacade()
|
|
const placement = { position: { x: 10, y: -2, z: 4 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 45 } }
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: placement })
|
|
placement.position.x = 999
|
|
const stored = facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value
|
|
assert.deepEqual(stored, { position: { x: 10, y: -2, z: 4 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 45 } })
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 0 }, angle: 0 } } }), /axis cannot be zero/)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 361 } } }), /between 0 and 360/)
|
|
facade.history.undo()
|
|
assert.deepEqual(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value, { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } })
|
|
facade.history.redo()
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(facade.app.document.getActive())
|
|
assert.deepEqual((await persistence.load('doc-pump-housing'))?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Placement')?.value, stored)
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('Sketch attachment support, MapMode and local offset compose deterministically', async () => {
|
|
const supportPlacement = { position: { x: 10, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 90 } }
|
|
const offset = { position: { x: 2, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 90 } }
|
|
assert.deepEqual(composeAttachmentPlacement(supportPlacement, offset), {
|
|
position: { x: 10, y: 2, z: 0 },
|
|
rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 180 },
|
|
})
|
|
assert.deepEqual(composeAttachmentPlacement(supportPlacement, identityAttachmentOffset()), supportPlacement)
|
|
assert.doesNotThrow(() => validateAttachmentMapMode('FlatFace'))
|
|
assert.throws(() => validateAttachmentMapMode('Unsupported'), /not supported/)
|
|
assert.doesNotThrow(() => validateAttachmentSupport({ objectId: 'pad', subElement: 'Face6' }))
|
|
assert.throws(() => validateAttachmentSupport({ objectId: '' }), /objectId/)
|
|
assert.doesNotThrow(() => validateAttachmentOffset(identityAttachmentOffset()))
|
|
assert.throws(() => validateAttachmentOffset({ position: { x: 0, y: 0, z: 0 } }), /position and rotation/)
|
|
|
|
const facade = createMockFacade()
|
|
const sketch = facade.app.document.getObject('sketch')
|
|
assert.equal(sketch?.properties.find((property) => property.name === 'MapMode')?.value, 'Deactivated')
|
|
facade.app.document.setProperty({ objectId: 'sketch', propertyName: 'MapMode', value: 'FlatFace' })
|
|
facade.app.document.setProperty({ objectId: 'sketch', propertyName: 'AttachmentOffset', value: offset })
|
|
facade.app.document.setProperty({ objectId: 'sketch', propertyName: 'Support', value: { objectId: 'pad', subElement: 'Face6' } })
|
|
assert.equal(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'MapMode')?.value, 'FlatFace')
|
|
assert.deepEqual(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'Support')?.value, { objectId: 'pad', subElement: 'Face6' })
|
|
assert.equal(facade.app.document.getDependencies().find((edge) => edge.sourceId === 'sketch' && edge.propertyName === 'Support')?.targetId, 'pad')
|
|
await facade.project.save()
|
|
const restored = await facade.project.load('doc-pump-housing')
|
|
assert.deepEqual(restored?.objects.find((object) => object.id === 'sketch')?.properties.find((property) => property.name === 'Support')?.value, { objectId: 'pad', subElement: 'Face6' })
|
|
assert.deepEqual(restored?.objects.find((object) => object.id === 'sketch')?.properties.find((property) => property.name === 'AttachmentOffset')?.value, offset)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'sketch', propertyName: 'MapMode', value: 'BadMode' }), /enumeration/)
|
|
})
|
|
|
|
test('Body Tip redirects to the last valid single-solid feature after suppression', () => {
|
|
assert.equal(resolveBodyTip([
|
|
{ id: 'sketch', typeId: 'Sketcher::SketchObject', suppressed: false, upstreamSuppressed: false, solid: false },
|
|
{ id: 'pad', typeId: 'PartDesign::Pad', suppressed: false, upstreamSuppressed: false, solid: true },
|
|
{ id: 'pocket', typeId: 'PartDesign::Pocket', suppressed: true, upstreamSuppressed: false, solid: true },
|
|
]), 'pad')
|
|
assert.equal(resolveBodyTip([{ id: 'pad', typeId: 'PartDesign::Pad', suppressed: true, upstreamSuppressed: false, solid: true }]), null)
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Suppressed', value: true })
|
|
facade.app.document.recompute()
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pocket')
|
|
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Suppressed', value: false })
|
|
facade.app.document.recompute()
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
})
|
|
|
|
test('Body Tip follows feature reorder and deletion without selecting a sketch', () => {
|
|
const facade = createMockFacade()
|
|
const document = facade.app.document
|
|
const body = document.getObject('body')
|
|
assert.ok(body)
|
|
document.setProperty({ objectId: 'fillet', propertyName: 'Suppressed', value: true })
|
|
document.recompute()
|
|
assert.equal(document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pocket')
|
|
document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
|
|
document.recompute()
|
|
assert.equal(document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad')
|
|
|
|
const snapshot = recomputeDocumentFixture()
|
|
snapshot.tree = [{ id: 'body', label: 'Body', type: 'body', children: ['sketch', 'pad', 'deleted', 'pocket'] }]
|
|
snapshot.objects = [
|
|
{ id: 'body', typeId: 'PartDesign::Body', properties: [{ name: 'Tip', label: 'Tip', type: 'App::PropertyLink', value: 'deleted' }] },
|
|
{ id: 'sketch', typeId: 'Sketcher::SketchObject', properties: [] },
|
|
{ id: 'pad', typeId: 'PartDesign::Pad', properties: [] },
|
|
{ id: 'pocket', typeId: 'PartDesign::Pocket', properties: [] },
|
|
]
|
|
assert.deepEqual(redirectBodyTips(snapshot, {}), ['body'])
|
|
assert.equal(snapshot.objects[0].properties[0].value, 'pocket')
|
|
snapshot.tree[0].children = ['sketch', 'pocket', 'pad']
|
|
assert.deepEqual(redirectBodyTips(snapshot, {}), ['body'])
|
|
assert.equal(snapshot.objects[0].properties[0].value, 'pad')
|
|
})
|
|
|
|
test('MultiTransform tasks validate, deeply clone, undo and persist ordered steps', async () => {
|
|
const facade = createMockFacade()
|
|
assert.equal(facade.gui.command.getState('multi-transform').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'multi-transform' })
|
|
const transformations: MultiTransformValue = { steps: [
|
|
{ id: 'mirror-1', type: 'mirrored', plane: 'XZ plane' },
|
|
{ id: 'polar-1', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' },
|
|
] }
|
|
facade.task.update({ transformations })
|
|
facade.task.apply()
|
|
transformations.steps[1] = { id: 'polar-1', type: 'polar', occurrences: 9, angle: 90, axis: 'Horizontal' }
|
|
const created = facade.app.document.getObject('multi-transform')
|
|
assert.equal(created?.typeId, 'PartDesign::MultiTransform')
|
|
assert.equal(created?.properties.find((property) => property.name === 'Base')?.value, 'pad')
|
|
assert.deepEqual(created?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [
|
|
{ id: 'mirror-1', type: 'mirrored', plane: 'XZ plane' },
|
|
{ id: 'polar-1', type: 'polar', occurrences: 3, angle: 180, axis: 'Normal' },
|
|
] })
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'multi-transform')
|
|
|
|
const revised: MultiTransformValue = { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] }
|
|
facade.app.document.setProperty({ objectId: 'multi-transform', propertyName: 'Transformations', value: revised })
|
|
revised.steps[0] = { id: 'linear-1', type: 'linear', occurrences: 2, length: 1, direction: 'Horizontal' }
|
|
assert.deepEqual(facade.app.document.getObject('multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] })
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'multi-transform', propertyName: 'Transformations', value: { steps: [
|
|
{ id: 'a', type: 'linear', occurrences: 10, length: 1, direction: 'Horizontal' },
|
|
{ id: 'b', type: 'polar', occurrences: 10, angle: 360, axis: 'Normal' },
|
|
{ id: 'c', type: 'mirrored', plane: 'YZ plane' },
|
|
] } }), /more than 100/)
|
|
facade.history.undo()
|
|
assert.equal((facade.app.document.getObject('multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value as MultiTransformValue).steps[1].type, 'polar')
|
|
facade.history.redo()
|
|
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(facade.app.document.getActive())
|
|
const loaded = await persistence.load('doc-pump-housing')
|
|
assert.deepEqual(loaded?.objects.find((object) => object.id === 'multi-transform')?.properties.find((property) => property.name === 'Transformations')?.value, { steps: [{ id: 'linear-1', type: 'linear', occurrences: 4, length: 30, direction: 'Vertical' }] })
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('LinkSub properties preserve versioned TopoRefs and create topology dependencies', async () => {
|
|
const facade = createMockFacade()
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const translatedFace = { ...face, vertexCoord: face.vertexCoord.map((value, index) => index % 3 === 2 ? value + 3 : value) }
|
|
const topology = createSubshapeRefs('pad-shape', 18, [face, translatedFace])
|
|
const topoRef = createPersistedTopoRef('pad', topology.refs[0], 4)
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: topoRef })
|
|
const stored = facade.app.document.getObject('pocket')?.properties.find((property) => property.name === 'UpToFace')?.value
|
|
assert.deepEqual(stored, topoRef)
|
|
assert.notEqual(stored, topoRef)
|
|
const dependency = facade.app.document.getDependencies().find((edge) => edge.sourceId === 'pocket' && edge.relation === 'topo-ref')
|
|
assert.equal(dependency?.targetId, 'pad')
|
|
assert.equal(dependency?.reference, topoRef.persistentId)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...topoRef, faceIndex: 1 } as never }), /must not persist transient faceIndex/)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...topoRef, objectId: 'missing' } }), /target does not exist/)
|
|
|
|
const secondTopoRef = createPersistedTopoRef('pad', topology.refs[1], 4)
|
|
const multiRef = { schemaVersion: 1 as const, objectId: 'pad', subElements: [topoRef, secondTopoRef] }
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: multiRef })
|
|
const storedMulti = facade.app.document.getObject('pocket')?.properties.find((property) => property.name === 'UpToFace')?.value
|
|
assert.deepEqual(storedMulti, multiRef)
|
|
assert.notEqual(storedMulti, multiRef)
|
|
assert.notEqual((storedMulti as typeof multiRef).subElements[0], topoRef)
|
|
const multiDependencies = facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'pocket' && edge.relation === 'topo-ref')
|
|
assert.deepEqual(multiDependencies.map((edge) => edge.reference).sort(), [topoRef.persistentId, secondTopoRef.persistentId].sort())
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...multiRef, subElements: [topoRef, topoRef] } }), /must be unique/)
|
|
|
|
await facade.project.save()
|
|
const loaded = await facade.project.load('doc-pump-housing')
|
|
assert.deepEqual(loaded?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'UpToFace')?.value, multiRef)
|
|
})
|
|
|
|
test('expressions are evaluated in the facade and create document dependencies', () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: '1 in + 2 mm' })
|
|
const property = facade.app.document.getObject('pad')?.properties.find((candidate) => candidate.name === 'Length')
|
|
assert.equal(property?.expression, '1 in + 2 mm')
|
|
assert.ok(Math.abs(Number(property?.value) - 27.4) < 1e-9)
|
|
assert.equal(facade.getState().document.recompute?.objectStates.pad, 'touched')
|
|
|
|
facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length * 2' })
|
|
const edge = facade.app.document.getDependencies().find((candidate) => candidate.sourceId === 'pocket' && candidate.targetId === 'pad' && candidate.relation === 'expression')
|
|
assert.equal(edge?.propertyName, 'Length')
|
|
const result = facade.app.document.recompute()
|
|
assert.equal(result.status, 'completed')
|
|
assert.deepEqual(result.order, ['pad', 'pocket', 'fillet', 'body'])
|
|
assert.equal(facade.getState().document.recompute?.dirtyObjects.length, 0)
|
|
assert.equal(facade.app.document.getObject('pocket')?.properties.find((candidate) => candidate.name === 'Length')?.value, 54.8)
|
|
})
|
|
|
|
test('expression cycles fail recompute without silently accepting a result', () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: 'pocket.Length' })
|
|
facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length' })
|
|
const result = facade.app.document.recompute()
|
|
assert.equal(result.status, 'failed')
|
|
assert.ok(result.errors.every((error) => error.code === 'DEPENDENCY_CYCLE'))
|
|
assert.equal(facade.getState().document.recompute?.objectStates.pad, 'error')
|
|
assert.equal(facade.getState().document.recompute?.objectStates.pocket, 'error')
|
|
})
|
|
|
|
test('recompute diagnostics expose root causes and repair only the affected branch', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: 'pocket.Length' })
|
|
facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length' })
|
|
const failed = facade.app.document.recompute()
|
|
assert.equal(failed.status, 'failed')
|
|
|
|
const diagnostics = facade.diagnostics.list().filter((diagnostic) => diagnostic.source === 'recompute')
|
|
const pocketRoot = diagnostics.find((diagnostic) => diagnostic.objectId === 'pocket' && diagnostic.code === 'DEPENDENCY_CYCLE')
|
|
const fillet = diagnostics.find((diagnostic) => diagnostic.objectId === 'fillet')
|
|
assert.ok(pocketRoot)
|
|
assert.equal(pocketRoot.generation, failed.generation)
|
|
assert.equal(pocketRoot.documentVersion, facade.app.document.getActive().version)
|
|
assert.equal(pocketRoot.repairActions?.find((action) => action.id === 'suppress-root')?.enabled, false)
|
|
assert.deepEqual(fillet?.dependencyPath, ['fillet', 'pocket'])
|
|
assert.equal(fillet?.rootCauseObjectId, 'pocket')
|
|
|
|
const tree = facade.diagnostics.tree()
|
|
assert.ok(tree.find((node) => node.diagnostic.id === pocketRoot.id)?.children.some((node) => node.diagnostic.objectId === 'fillet'))
|
|
const focused: Array<{ diagnosticId: string; objectId: string }> = []
|
|
const unsubscribe = facade.subscribe((event) => { if (event.type === 'diagnostic.focused') focused.push({ diagnosticId: event.diagnosticId, objectId: event.objectId }) })
|
|
const selected = await facade.diagnostics.repair(pocketRoot.id, 'select-object')
|
|
unsubscribe()
|
|
assert.equal(selected.status, 'completed')
|
|
assert.equal(facade.selection.getObjectId(), 'pocket')
|
|
assert.deepEqual(focused, [{ diagnosticId: pocketRoot.id, objectId: 'pocket' }])
|
|
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: '25 mm' })
|
|
const repaired = await facade.diagnostics.repair(pocketRoot.id, 'recompute-root')
|
|
assert.equal(repaired.status, 'completed')
|
|
assert.equal(facade.diagnostics.list().some((diagnostic) => diagnostic.source === 'recompute'), false)
|
|
})
|
|
|
|
test('view properties do not mark the geometry object touched', () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false })
|
|
assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'fillet')?.state, 'valid')
|
|
assert.equal(facade.app.document.getObject('fillet')?.properties.find((property) => property.name === 'Visibility')?.value, false)
|
|
})
|
|
|
|
test('project persistence remains behind the facade contract', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 })
|
|
const saved = await facade.project.save()
|
|
assert.equal(saved.documentId, 'doc-pump-housing')
|
|
const projects = await facade.project.list()
|
|
assert.equal(projects.length, 1)
|
|
const { updatedAt, ...summary } = projects[0]
|
|
assert.ok(updatedAt > 0)
|
|
assert.deepEqual(summary, { documentId: 'doc-pump-housing', label: 'Pump Housing', documentVersion: saved.documentVersion, objectCount: 7, dirty: true, readOnly: false })
|
|
const loaded = await facade.project.load('doc-pump-housing')
|
|
assert.equal(loaded?.label, 'Pump Housing')
|
|
assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 61 })
|
|
const restored = await facade.app.document.load('doc-pump-housing')
|
|
assert.equal(restored?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)
|
|
assert.equal(restored?.recompute?.status, 'completed')
|
|
assert.equal(facade.selection.getObjectId(), '')
|
|
assert.equal(facade.task.getActive(), null)
|
|
assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
|
|
const recovery = await facade.project.recovery('doc-pump-housing')
|
|
assert.equal(recovery.integrity, 'ok')
|
|
assert.equal(recovery.lastSavedVersion, loaded?.version)
|
|
})
|
|
|
|
test('save command reports success only after the persistence boundary completes', async () => {
|
|
const facade = createMockFacade()
|
|
const notices: string[] = []
|
|
const unsubscribe = facade.subscribe((event) => { if (event.type === 'notice') notices.push(event.message) })
|
|
facade.gui.command.execute({ commandId: 'save' })
|
|
assert.equal(notices.includes('Saved to local workspace'), false)
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
assert.equal(notices.includes('Saved to local workspace'), true)
|
|
assert.equal((await facade.project.list()).some((project) => project.documentId === facade.app.document.getActive().id), true)
|
|
unsubscribe()
|
|
facade.geometry.dispose()
|
|
})
|
|
|
|
test('recovery report identifies missing snapshots in the transient fallback store', async () => {
|
|
const facade = createMockFacade()
|
|
const recovery = await facade.project.recovery('missing-document')
|
|
assert.equal(recovery.integrity, 'unavailable')
|
|
assert.equal(recovery.lastSavedVersion, null)
|
|
assert.ok(recovery.warnings.some((warning) => /No saved snapshot/.test(warning)))
|
|
})
|
|
|
|
test('project recovery checkpoints retain the latest five document versions', async () => {
|
|
const persistence = createSqliteProjectPersistence()
|
|
const base = createMockFacade().app.document.getActive()
|
|
for (let version = 1; version <= 7; version += 1) await persistence.save({ ...base, version, label: `Checkpoint ${version}` })
|
|
const recovery = await persistence.recovery(base.id)
|
|
assert.deepEqual(recovery.checkpoints.map((checkpoint) => checkpoint.version), [7, 6, 5, 4, 3])
|
|
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
|
|
assert.equal(await persistence.loadCheckpoint(base.id, 2), null)
|
|
assert.equal((await persistence.loadCheckpoint(base.id))?.version, 7)
|
|
const restored = await persistence.loadCheckpoint(base.id, 4)
|
|
if (restored) restored.label = 'mutated clone'
|
|
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => {
|
|
const documentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Document SchemaVersion="4">
|
|
<Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Imported assembly"/></Property></Properties>
|
|
<Objects Count="3">
|
|
<Object type="PartDesign::Body" name="Body"/>
|
|
<Object type="Vendor::CustomFeature" name="Custom"/>
|
|
<Object type="Part::FeaturePython" name="Scripted"/>
|
|
</Objects>
|
|
<ObjectData Count="3">
|
|
<Object name="Body"><Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Main body"/></Property></Properties></Object>
|
|
<Object name="Custom"><Properties Count="0"/></Object>
|
|
<Object name="Scripted"><Properties Count="0"/></Object>
|
|
</ObjectData>
|
|
</Document>`
|
|
const archive = zipSync({
|
|
'Document.xml': strToU8(documentXml),
|
|
'GuiDocument.xml': strToU8('<GuiDocument/>'),
|
|
'Macro/unsafe.py': strToU8('raise RuntimeError("must not run")'),
|
|
})
|
|
const inspection = inspectFcstdArchive(archive)
|
|
assert.equal(inspection.label, 'Imported assembly')
|
|
assert.equal(inspection.schemaVersion, '4')
|
|
assert.equal(inspection.objects.find((object) => object.name === 'Body')?.label, 'Main body')
|
|
assert.deepEqual(inspection.objects.find((object) => object.name === 'Body')?.properties, [{ name: 'Label', typeId: 'App::PropertyString', element: 'String', value: 'Main body' }])
|
|
assert.equal(inspection.compatibility.recognizedObjects, 1)
|
|
assert.equal(inspection.compatibility.proxyObjects, 1)
|
|
assert.equal(inspection.compatibility.blockedObjects, 1)
|
|
assert.equal(inspection.compatibility.level, 'blocked')
|
|
assert.equal(inspection.compatibility.codeExecutionBlocked, true)
|
|
assert.deepEqual(inspection.compatibility.unknownTypeIds, ['Vendor::CustomFeature'])
|
|
assert.ok(inspection.entries.some((entry) => entry.role === 'script'))
|
|
assert.equal(inspection.proxyDocument.readOnly, true)
|
|
assert.equal(inspection.proxyDocument.objects.length, 3)
|
|
assert.equal(inspection.proxyDocument.objects[1].properties.find((property) => property.name === 'ImportSupport')?.value, 'proxy')
|
|
})
|
|
|
|
test('FCStd inspection recognizes the implemented Part and PartDesign feature vocabulary', () => {
|
|
const typeIds = [
|
|
'Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Ellipsoid', 'Part::Cone', 'Part::Torus', 'Part::Prism', 'Part::Wedge', 'Part::Fuse', 'Part::Cut', 'Part::Common',
|
|
'Part::Extrusion', 'Part::Revolution', 'Part::Loft', 'Part::Sweep', 'Part::Fillet', 'Part::Chamfer',
|
|
'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Groove', 'PartDesign::AdditiveLoft',
|
|
'PartDesign::SubtractiveLoft', 'PartDesign::AdditivePipe', 'PartDesign::SubtractivePipe', 'PartDesign::Fillet',
|
|
'PartDesign::Chamfer', 'PartDesign::Draft', 'PartDesign::Thickness', 'PartDesign::Mirrored', 'PartDesign::MultiTransform',
|
|
'PartDesign::LinearPattern', 'PartDesign::PolarPattern', 'PartDesign::Hole',
|
|
]
|
|
const document = { ...recomputeDocumentFixture(), id: 'fcstd-implemented-vocabulary', label: 'Implemented vocabulary', tree: typeIds.map((typeId, index) => ({ id: `object-${index}`, label: typeId, type: 'feature' as const })), objects: typeIds.map((typeId, index) => ({ id: `object-${index}`, typeId, properties: [] })) }
|
|
const inspection = inspectFcstdArchive(serializeFcstdMetadataArchive(document))
|
|
assert.equal(inspection.compatibility.level, 'metadata-compatible')
|
|
assert.equal(inspection.compatibility.recognizedObjects, typeIds.length)
|
|
assert.equal(inspection.compatibility.proxyObjects, 0)
|
|
assert.deepEqual(inspection.compatibility.unknownTypeIds, [])
|
|
|
|
const legacy = { ...recomputeDocumentFixture(), id: 'legacy-extrude', label: 'Legacy extrusion', tree: [{ id: 'Extrude', label: 'Extrude', type: 'feature' as const }], objects: [{ id: 'Extrude', typeId: 'Part::Extrude', properties: [] }] }
|
|
const legacyArchive = serializeFcstdMetadataArchive(legacy)
|
|
const legacyXml = new TextDecoder().decode(unzipSync(legacyArchive)['Document.xml'])
|
|
assert.match(legacyXml, /<Object name="Extrude" type="Part::Extrusion"/)
|
|
assert.doesNotMatch(legacyXml, /type="Part::Extrude"/)
|
|
assert.equal(inspectFcstdArchive(legacyArchive).objects[0].typeId, 'Part::Extrusion')
|
|
})
|
|
|
|
test('FCStd inspection rejects missing metadata, traversal paths, and suspicious compression ratios', () => {
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'GuiDocument.xml': strToU8('<GuiDocument/>') })), /Document\.xml/)
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ '../Document.xml': strToU8('<Document/>') })), /Unsafe FCStd entry path/)
|
|
const compressed = zipSync({ 'Document.xml': strToU8(`<Document>${' '.repeat(20_000)}</Document>`) })
|
|
assert.throws(() => inspectFcstdArchive(compressed, { maxCompressionRatio: 2 }), /compression ratio/)
|
|
const invalidGui = zipSync({ 'Document.xml': strToU8('<Document/>'), 'GuiDocument.xml': strToU8('<Views/>') })
|
|
assert.throws(() => inspectFcstdArchive(invalidGui), /GuiDocument\.xml root/)
|
|
const invalidDocument = zipSync({ 'Document.xml': strToU8('<Objects/>') })
|
|
assert.throws(() => inspectFcstdArchive(invalidDocument), /Document\.xml root/)
|
|
const malformedDocument = zipSync({ 'Document.xml': strToU8('<Document><Objects></Document>') })
|
|
assert.throws(() => inspectFcstdArchive(malformedDocument), /invalid XML/)
|
|
const duplicateObject = zipSync({ 'Document.xml': strToU8('<Document><Objects><Object name="Body" type="PartDesign::Body"/><Object name="Body" type="Part::Feature"/></Objects></Document>') })
|
|
assert.throws(() => inspectFcstdArchive(duplicateObject), /Duplicate FCStd object declaration name: Body/)
|
|
const duplicateObjectData = zipSync({ 'Document.xml': strToU8('<Document><ObjectData><Object name="Body"/><Object name="Body"/></ObjectData></Document>') })
|
|
assert.throws(() => inspectFcstdArchive(duplicateObjectData), /Duplicate FCStd object data name: Body/)
|
|
const deepDocument = zipSync({ 'Document.xml': strToU8('<Document><A><B><C><D><E/></D></C></B></A></Document>') })
|
|
assert.throws(() => inspectFcstdArchive(deepDocument, { maxXmlDepth: 2 }), /nesting depth 2/)
|
|
const nodeHeavyDocument = zipSync({ 'Document.xml': strToU8('<Document><A/><B/><C/></Document>') })
|
|
assert.throws(() => inspectFcstdArchive(nodeHeavyDocument, { maxXmlNodes: 3 }), /exceeds 3 XML nodes/)
|
|
})
|
|
|
|
test('FCStd metadata writer rejects ambiguous object identity', () => {
|
|
const document = createMockFacade().app.document.getActive()
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [...document.objects, { ...document.objects[0] }] }), /unique object ids/)
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], id: ' ' }] }), /non-empty object ids/)
|
|
})
|
|
|
|
test('FCStd inspection inventories object extensions without instantiating them', () => {
|
|
const xml = '<Document SchemaVersion="4"><Objects><Object name="Body" type="PartDesign::Body"><Extensions><Extension type="App::GroupExtension"/></Extensions></Object></Objects><ObjectData><Object name="Body"><Extensions><Extension type="PartDesign::BodyExtension"/><Extension type="App::GroupExtension"/></Extensions><Properties/></Object></ObjectData></Document>'
|
|
const inspection = inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(xml) }))
|
|
assert.deepEqual(inspection.objects[0].extensions, ['App::GroupExtension', 'PartDesign::BodyExtension'])
|
|
})
|
|
|
|
test('FCStd metadata writer round-trips Document and GuiDocument while preserving opaque resources', () => {
|
|
const document = {
|
|
id: 'fcstd-writer-doc', label: 'Writer fixture', version: 1, dirty: false, readOnly: false, units: 'mm',
|
|
tree: [{ id: 'body', label: 'Body', type: 'body' as const, state: 'active' as const }],
|
|
objects: [{ id: 'body', typeId: 'PartDesign::Body', properties: [
|
|
{ name: 'Label', label: 'Label', type: 'App::PropertyString', value: 'Body', expression: 'Spreadsheet.Width * 2', recompute: false },
|
|
{ name: 'Length', label: 'Length', type: 'App::PropertyLength', value: 12.5 },
|
|
{ name: 'Visible', label: 'Visible', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Base', label: 'Base', type: 'App::PropertyLink', value: 'pad' },
|
|
] }],
|
|
dependencies: [], recompute: { generation: 0, status: 'idle' as const, objectStates: { body: 'up-to-date' as const }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const archive = serializeFcstdMetadataArchive(document, { guiDocumentXml: '<GuiDocument SchemaVersion="4"><Views><View name="Front"/><View name="Top"/></Views></GuiDocument>', opaqueEntries: { 'Unknown/opaque.bin': new Uint8Array([7, 11, 13]) } })
|
|
const inspection = inspectFcstdArchive(archive)
|
|
assert.equal(inspection.label, 'Writer fixture')
|
|
assert.equal(inspection.objects[0].typeId, 'PartDesign::Body')
|
|
assert.equal(inspection.objects[0].properties[0].value, 'Body')
|
|
assert.equal(inspection.objects[0].properties[0].expression, 'Spreadsheet.Width * 2')
|
|
assert.equal(inspection.objects[0].properties.find((property) => property.name === 'Length')?.element, 'Float')
|
|
assert.equal(inspection.objects[0].properties.find((property) => property.name === 'Visible')?.element, 'Bool')
|
|
assert.equal(inspection.objects[0].properties.find((property) => property.name === 'Base')?.element, 'Link')
|
|
assert.deepEqual(inspection.guiDocument, { present: true, rootName: 'GuiDocument', schemaVersion: '4', viewCount: 2, contentHash: inspection.guiDocument.contentHash, views: [{ name: 'Front', type: '', visibility: '' }, { name: 'Top', type: '', visibility: '' }], viewProviders: [] })
|
|
assert.equal(inspection.proxyDocument.objects[0].properties.find((property) => property.name === 'Expression:Label')?.value, 'Spreadsheet.Width * 2')
|
|
assert.equal(inspection.entries.find((entry) => entry.path === 'Unknown/opaque.bin')?.role, 'resource')
|
|
assert.throws(() => serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Document.xml': new Uint8Array() } }), /reserved path/)
|
|
})
|
|
|
|
test('FCStd typed property codec decodes numeric, boolean and LinkSub values without executing expressions', () => {
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Length', typeId: 'App::PropertyLength', element: 'Length', value: '12.5' }), { value: 12.5, decoded: true })
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Polygon', typeId: 'App::PropertyIntegerConstraint', element: 'Integer', value: '6' }), { value: 6, decoded: true })
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Deviation', typeId: 'App::PropertyFloatConstraint', element: 'Float', value: '0.25' }), { value: 0.25, decoded: true })
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Reversed', typeId: 'App::PropertyBool', element: 'Bool', value: 'true' }), { value: true, decoded: true })
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Support', typeId: 'App::PropertyLinkSub', element: 'String', value: '{"objectId":"pad","subElement":"Face6"}', expression: 'Spreadsheet.Width' }), { value: { objectId: 'pad', subElement: 'Face6' }, decoded: true })
|
|
assert.equal(decodeFcstdPropertyValue({ name: 'Support', typeId: 'App::PropertyLinkSub', element: 'String', value: 'Face6' }).decoded, false)
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'Base', typeId: 'App::PropertyLink', element: 'Link', value: 'pad' }), { value: 'pad', decoded: true })
|
|
assert.deepEqual(decodeFcstdPropertyValue({ name: 'OptionalBase', typeId: 'App::PropertyLink', element: 'Link', value: '' }), { value: null, decoded: true })
|
|
})
|
|
|
|
test('FCStd native Part primitive properties omit dynamic metadata and use Float payloads', () => {
|
|
const document = recomputeDocumentFixture([
|
|
{ sourceId: 'Extrusion', targetId: 'Profile', relation: 'link', propertyName: 'Base' },
|
|
{ sourceId: 'Revolution', targetId: 'Profile', relation: 'link', propertyName: 'Source' },
|
|
{ sourceId: 'Loft', targetId: 'Profile', relation: 'link', propertyName: 'Sections', reference: 'Sections[0]' },
|
|
{ sourceId: 'Loft', targetId: 'Profile2', relation: 'link', propertyName: 'Sections', reference: 'Sections[1]' },
|
|
{ sourceId: 'Sweep', targetId: 'Profile', relation: 'link', propertyName: 'Sections', reference: 'Sections[0]' },
|
|
{ sourceId: 'Sweep', targetId: 'Profile2', relation: 'link', propertyName: 'Spine' },
|
|
])
|
|
document.objects = [
|
|
{ id: 'Profile', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'Profile2', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'Box', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 3 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 4 },
|
|
{ name: 'Status', label: 'Status', group: 'Web', scope: 'data', type: 'App::PropertyString', value: 'Valid', readOnly: true },
|
|
] },
|
|
{ id: 'Cylinder', typeId: 'Part::Cylinder', properties: [{ name: 'Angle', label: 'Angle', group: 'Cylinder', scope: 'data', type: 'App::PropertyAngle', value: 180 }] },
|
|
{ id: 'Prism', typeId: 'Part::Prism', properties: [
|
|
{ name: 'Polygon', label: 'Polygon sides', group: 'Prism', scope: 'data', type: 'App::PropertyInteger', value: 6 },
|
|
{ name: 'Circumradius', label: 'Circumradius', group: 'Prism', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Height', label: 'Height', group: 'Prism', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'FirstAngle', label: 'First angle', group: 'Prism', scope: 'data', type: 'App::PropertyAngle', value: 10 },
|
|
{ name: 'SecondAngle', label: 'Second angle', group: 'Prism', scope: 'data', type: 'App::PropertyAngle', value: -5 },
|
|
] },
|
|
{ id: 'Wedge', typeId: 'Part::Wedge', properties: [
|
|
{ name: 'Xmin', label: 'X minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Ymin', label: 'Y minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Zmin', label: 'Z minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Z2min', label: 'Z2 minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'X2min', label: 'X2 minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Xmax', label: 'X maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Ymax', label: 'Y maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Zmax', label: 'Z maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Z2max', label: 'Z2 maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 8 },
|
|
{ name: 'X2max', label: 'X2 maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 8 },
|
|
] },
|
|
{ id: 'Ellipsoid', typeId: 'Part::Ellipsoid', properties: [
|
|
{ name: 'Radius1', label: 'Z radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Radius2', label: 'X radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 4 },
|
|
{ name: 'Radius3', label: 'Y radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: -90 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: 90 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: 360 },
|
|
] },
|
|
{ id: 'Hole', typeId: 'PartDesign::Hole', properties: [
|
|
{ name: 'Diameter', label: 'Diameter', group: 'Hole', scope: 'data', type: 'App::PropertyLength', value: 6 },
|
|
{ name: 'DepthType', label: 'Depth type', group: 'Hole', scope: 'data', type: 'App::PropertyEnumeration', value: 'Dimension', options: ['Dimension', 'ThroughAll'] },
|
|
{ name: 'Depth', label: 'Length', group: 'Hole', scope: 'data', type: 'App::PropertyLength', value: 25 },
|
|
{ name: 'DrillPoint', label: 'Drill point', group: 'Hole', scope: 'data', type: 'App::PropertyEnumeration', value: 'Angled', options: ['Flat', 'Angled'] },
|
|
{ name: 'DrillPointAngle', label: 'Drill point angle', group: 'Hole', scope: 'data', type: 'App::PropertyAngle', value: 118 },
|
|
{ name: 'DrillForDepth', label: 'Drill for depth', group: 'Hole', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Tapered', label: 'Tapered', group: 'Hole', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'TaperedAngle', label: 'Taper angle', group: 'Hole', scope: 'data', type: 'App::PropertyAngle', value: 90 },
|
|
{ name: 'Position', label: 'Position', group: 'Hole', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 0 } },
|
|
] },
|
|
{ id: 'Extrusion', typeId: 'Part::Extrusion', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Extrude', scope: 'data', type: 'App::PropertyLink', value: 'Profile' },
|
|
{ name: 'Dir', label: 'Direction', group: 'Extrude', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 } },
|
|
{ name: 'DirMode', label: 'Direction mode', group: 'Extrude', scope: 'data', type: 'App::PropertyEnumeration', value: 'Custom', options: ['Custom', 'Edge', 'Normal'] },
|
|
{ name: 'LengthFwd', label: 'Forward length', group: 'Extrude', scope: 'data', type: 'App::PropertyDistance', value: 5 },
|
|
{ name: 'LengthRev', label: 'Reverse length', group: 'Extrude', scope: 'data', type: 'App::PropertyDistance', value: 0 },
|
|
{ name: 'Solid', label: 'Solid', group: 'Extrude', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Extrude', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Symmetric', label: 'Symmetric', group: 'Extrude', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'TaperAngle', label: 'Taper angle', group: 'Extrude', scope: 'data', type: 'App::PropertyAngle', value: 0 },
|
|
{ name: 'TaperAngleRev', label: 'Reverse taper angle', group: 'Extrude', scope: 'data', type: 'App::PropertyAngle', value: 0 },
|
|
{ name: 'WebDirection', label: 'Web direction', group: 'Web', scope: 'data', type: 'App::PropertyString', value: 'profile-normal' },
|
|
] },
|
|
{ id: 'Revolution', typeId: 'Part::Revolution', properties: [
|
|
{ name: 'Source', label: 'Source', group: 'Revolve', scope: 'data', type: 'App::PropertyLink', value: 'Profile' },
|
|
{ name: 'Base', label: 'Axis base', group: 'Revolve', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 0 } },
|
|
{ name: 'Axis', label: 'Axis', group: 'Revolve', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 1, z: 0 } },
|
|
{ name: 'Angle', label: 'Angle', group: 'Revolve', scope: 'data', type: 'App::PropertyAngle', value: 180 },
|
|
{ name: 'Symmetric', label: 'Symmetric', group: 'Revolve', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Solid', label: 'Solid', group: 'Revolve', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] },
|
|
{ id: 'Loft', typeId: 'Part::Loft', properties: [
|
|
{ name: 'Sections', label: 'Sections', group: 'Loft', scope: 'data', type: 'App::PropertyLinkList', value: ['Profile', 'Profile2'] },
|
|
{ name: 'Solid', label: 'Solid', group: 'Loft', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Ruled', label: 'Ruled', group: 'Loft', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Closed', label: 'Closed', group: 'Loft', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'MaxDegree', label: 'Maximum degree', group: 'Loft', scope: 'data', type: 'App::PropertyIntegerConstraint', value: 5 },
|
|
{ name: 'Linearize', label: 'Linearize', group: 'Loft', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
] },
|
|
{ id: 'Sweep', typeId: 'Part::Sweep', properties: [
|
|
{ name: 'Sections', label: 'Sections', group: 'Sweep', scope: 'data', type: 'App::PropertyLinkList', value: ['Profile'] },
|
|
{ name: 'Spine', label: 'Spine', group: 'Sweep', scope: 'data', type: 'App::PropertyLinkSub', value: { schemaVersion: 1, objectId: 'Profile2', subElements: [] } },
|
|
{ name: 'Solid', label: 'Solid', group: 'Sweep', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Frenet', label: 'Frenet', group: 'Sweep', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Transition', label: 'Transition', group: 'Sweep', scope: 'data', type: 'App::PropertyEnumeration', value: 'Transformed', options: ['Transformed', 'Right corner', 'Round corner'] },
|
|
{ name: 'Linearize', label: 'Linearize', group: 'Sweep', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Mode', label: 'Web orientation', group: 'Web', scope: 'data', type: 'App::PropertyString', value: 'Standard' },
|
|
] },
|
|
]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /<Property name="Length" type="App::PropertyLength"><Float value="2"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Width" type="App::PropertyLength"><Float value="3"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Height" type="App::PropertyLength"><Float value="4"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Angle" type="App::PropertyAngle"><Float value="180"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Polygon" type="App::PropertyInteger"><Integer value="6"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Circumradius" type="App::PropertyLength"><Float value="2"\/><\/Property>/)
|
|
assert.match(written, /<Property name="FirstAngle" type="App::PropertyAngle"><Float value="10"\/><\/Property>/)
|
|
assert.match(written, /<Property name="SecondAngle" type="App::PropertyAngle"><Float value="-5"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Radius1" type="App::PropertyLength"><Float value="2"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Radius2" type="App::PropertyLength"><Float value="4"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Radius3" type="App::PropertyLength"><Float value="0"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Angle1" type="App::PropertyAngle"><Float value="-90"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Angle2" type="App::PropertyAngle"><Float value="90"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Angle3" type="App::PropertyAngle"><Float value="360"\/><\/Property>/)
|
|
assert.match(written, /<Property name="DepthType" type="App::PropertyEnumeration"><Integer value="0"\/><\/Property>/)
|
|
assert.match(written, /<Property name="DrillPoint" type="App::PropertyEnumeration"><Integer value="1"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Diameter" type="App::PropertyLength"><Float value="6"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Position" type="App::PropertyVector" group="Hole"/)
|
|
assert.match(written, /<ObjectDeps Name="Extrusion" Count="1"><Dep Name="Profile"\/><\/ObjectDeps><Object name="Extrusion" type="Part::Extrusion"/)
|
|
assert.match(written, /<Property name="LengthFwd" type="App::PropertyDistance"><Float value="5"\/><\/Property>/)
|
|
assert.match(written, /<Property name="DirMode" type="App::PropertyEnumeration"><Integer value="0"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Source" type="App::PropertyLink"><Link value="Profile"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Axis" type="App::PropertyVector"><PropertyVector valueX="0" valueY="1" valueZ="0"\/><\/Property>/)
|
|
assert.match(written, /<Property name="WebDirection" type="App::PropertyString" group="Web"/)
|
|
assert.match(written, /<Property name="MaxDegree" type="App::PropertyIntegerConstraint"><Integer value="5"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Spine" type="App::PropertyLinkSub"><LinkSub value="Profile2" count="0"><\/LinkSub><\/Property>/)
|
|
assert.match(written, /<Property name="Transition" type="App::PropertyEnumeration"><Integer value="0"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Mode" type="App::PropertyString" group="Web"/)
|
|
assert.doesNotMatch(written, /name="(?:Sections|MaxDegree|Linearize|Frenet|Transition|Spine)"[^>]*\sgroup=/)
|
|
assert.doesNotMatch(written, /name="(?:Base|Dir|DirMode|LengthFwd|LengthRev|Solid|Reversed|Symmetric|TaperAngle|TaperAngleRev|Source|Axis)"[^>]*\sgroup=/)
|
|
assert.match(written, /<Property name="Status" type="App::PropertyString" group="Web" doc="Status" attr="0" ro="1" hide="0">/)
|
|
assert.doesNotMatch(written, /name="(?:Length|Width|Height|Angle)"[^>]*\sgroup=/)
|
|
})
|
|
|
|
test('FCStd native Part Boolean links emit dependency records without dynamic metadata', () => {
|
|
const document = recomputeDocumentFixture([
|
|
{ sourceId: 'Cut', targetId: 'BoxA', relation: 'link', propertyName: 'Base' },
|
|
{ sourceId: 'Cut', targetId: 'BoxB', relation: 'link', propertyName: 'Tool' },
|
|
])
|
|
document.objects = [
|
|
{ id: 'BoxA', typeId: 'Part::Box', properties: [] },
|
|
{ id: 'BoxB', typeId: 'Part::Box', properties: [] },
|
|
{ id: 'Cut', typeId: 'Part::Cut', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: 'BoxA' },
|
|
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink', value: 'BoxB' },
|
|
{ name: 'Refine', label: 'Refine shape', group: 'Boolean', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
] },
|
|
]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /<ObjectDeps Name="Cut" Count="2"><Dep Name="BoxA"\/><Dep Name="BoxB"\/><\/ObjectDeps>/)
|
|
assert.match(written, /<Property name="Base" type="App::PropertyLink"><Link value="BoxA"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Tool" type="App::PropertyLink"><Link value="BoxB"\/><\/Property>/)
|
|
assert.match(written, /<Property name="Refine" type="App::PropertyBool"><Bool value="false"\/><\/Property>/)
|
|
assert.doesNotMatch(written, /name="Refine"[^>]*\sgroup=/)
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, dependencies: [{ sourceId: 'Cut', targetId: 'Missing', relation: 'link' }] }), /undeclared object/)
|
|
})
|
|
|
|
test('FCStd dynamic Enumeration writes the native indexed CustomEnumList representation', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'Feature', typeId: 'Part::Feature', properties: [{ name: 'WebMode', label: 'Web mode', group: 'Web', scope: 'data', type: 'App::PropertyEnumeration', value: 'Manufacturing', options: ['Design', 'Manufacturing', 'Inspection'] }] }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(written, /<Property name="WebMode" type="App::PropertyEnumeration" group="Web" doc="Web mode" attr="0" ro="0" hide="0"><Integer value="1" CustomEnum="true"\/><CustomEnumList count="3"><Enum value="Design"\/><Enum value="Manufacturing"\/><Enum value="Inspection"\/><\/CustomEnumList><\/Property>/)
|
|
const summary = inspectFcstdArchive(archive).objects[0].properties[0]
|
|
assert.deepEqual(summary.enumOptions, ['Design', 'Manufacturing', 'Inspection'])
|
|
assert.equal(decodeFcstdPropertyValue(summary).value, 'Manufacturing')
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], properties: [{ ...document.objects[0].properties[0], value: 'Missing' }] }] }), /value from its options/)
|
|
const malformed = '<Document><Objects><Object name="Feature" type="Part::Feature"/></Objects><ObjectData><Object name="Feature"><Properties><Property name="Mode" type="App::PropertyEnumeration"><Integer value="0" CustomEnum="true"/><CustomEnumList count="2"><Enum value="Only"/></CustomEnumList></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(malformed) })), /CustomEnumList count/)
|
|
})
|
|
|
|
test('FCStd scalar codecs enforce native payload types and numeric ranges', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'Feature', typeId: 'Part::Feature', properties: [
|
|
{ name: 'Progress', label: 'Progress', group: 'Web', scope: 'data', type: 'App::PropertyPercent', value: 35 },
|
|
{ name: 'Retries', label: 'Retries', group: 'Web', scope: 'data', type: 'App::PropertyInteger', value: 2 },
|
|
{ name: 'Enabled', label: 'Enabled', group: 'Web', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] }]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /type="App::PropertyPercent"[^>]*><Integer value="35"\/>/)
|
|
assert.match(written, /type="App::PropertyInteger"[^>]*><Integer value="2"\/>/)
|
|
assert.match(written, /type="App::PropertyBool"[^>]*><Bool value="true"\/>/)
|
|
const withValue = (value: number, type: 'App::PropertyFloat' | 'App::PropertyInteger' | 'App::PropertyPercent') => ({ ...document, objects: [{ ...document.objects[0], properties: [{ name: 'Value', label: 'Value', group: 'Web', scope: 'data' as const, type, value }] }] })
|
|
assert.throws(() => serializeFcstdMetadataArchive(withValue(Number.NaN, 'App::PropertyFloat')), /finite number/)
|
|
assert.throws(() => serializeFcstdMetadataArchive(withValue(1.5, 'App::PropertyInteger')), /safe integer/)
|
|
assert.throws(() => serializeFcstdMetadataArchive(withValue(101, 'App::PropertyPercent')), /between 0 and 100/)
|
|
})
|
|
|
|
test('FCStd Sketcher codec round-trips native geometry and core constraints with stable Web IDs', () => {
|
|
const sketch = createSketch('Sketch', [
|
|
{ id: 'origin-point', type: 'point', position: { x: 0, y: 0 }, construction: true },
|
|
{ id: 'base-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
|
{ id: 'side-line', type: 'line', start: { x: 4, y: 0 }, end: { x: 4, y: 3 } },
|
|
{ id: 'profile-circle', type: 'circle', center: { x: 8, y: 2 }, radius: 2 },
|
|
{ id: 'profile-arc', type: 'arc', center: { x: 12, y: 2 }, radius: 1.5, startAngle: 0, endAngle: Math.PI },
|
|
{ id: 'profile-ellipse', type: 'ellipse', center: { x: 16, y: 2 }, majorRadius: 3, minorRadius: 1, rotation: 0.25 },
|
|
{ id: 'profile-spline', type: 'bspline', degree: 2, controlPoints: [{ x: 20, y: 0 }, { x: 21, y: 2 }, { x: 22, y: 0 }], weights: [1, 2, 1], knots: [0, 0, 0, 1, 1, 1], periodic: false },
|
|
], [
|
|
{ id: 'join', type: 'coincident', first: { geometryId: 'base-line', point: 'end' }, second: { geometryId: 'side-line', point: 'start' } },
|
|
{ id: 'base-horizontal', type: 'horizontal', geometryId: 'base-line' },
|
|
{ id: 'side-vertical', type: 'vertical', geometryId: 'side-line' },
|
|
{ id: 'base-length', type: 'distance', first: { geometryId: 'base-line', point: 'start' }, second: { geometryId: 'base-line', point: 'end' }, value: 4 },
|
|
{ id: 'circle-radius', type: 'radius', geometryId: 'profile-circle', value: 2, driving: false },
|
|
{ id: 'lines-perpendicular', type: 'perpendicular', firstGeometryId: 'base-line', secondGeometryId: 'side-line' },
|
|
{ id: 'point-on-base', type: 'pointOnObject', point: { geometryId: 'origin-point', point: 'position' }, geometryId: 'base-line' },
|
|
{ id: 'ellipse-block', type: 'block', geometryId: 'profile-ellipse' },
|
|
])
|
|
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::PropertyGeometryList" status="8192"><GeometryList count="7">/)
|
|
assert.match(written, /type="Sketcher::PropertyConstraintList"><ConstraintList count="8">/)
|
|
assert.match(written, /<Constrain Name="base-horizontal" Type="2"/)
|
|
assert.match(written, /<StringList count="7"><String value="origin-point"/)
|
|
const inspection = inspectFcstdArchive(archive)
|
|
const restored = inspection.objects[0].sketch
|
|
assert.ok(restored)
|
|
assert.deepEqual(restored.geometry, sketch.geometry)
|
|
assert.deepEqual(restored.constraints, sketch.constraints.map((constraint) => ({ ...constraint, driving: constraint.driving !== false })))
|
|
assert.deepEqual(inspection.proxyDocument.objects[0].sketch, restored)
|
|
assert.deepEqual(decodeFcstdPropertyValue(inspection.objects[0].properties.find((property) => property.name === 'Geometry')!).value, sketch.geometry.map((geometry, index) => ({ ...geometry, id: String(index + 1) })))
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(written.replace('<GeometryList count="7">', '<GeometryList count="8">')) })), /GeometryList count/)
|
|
|
|
const advanced = createSketch('Sketch', [{ id: 'spline', type: 'bspline', degree: 2, controlPoints: [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 0 }], weights: [1, 1, 1], knots: [0, 0, 0, 1, 1, 1] }], [{ id: 'knot-alignment', type: 'internalAlignment', geometryId: 'spline', internalGeometryIndex: 2, alignmentType: 'bspline-knot' }])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: advanced }] }), /knot index is outside/)
|
|
const invalidReference = createSketch('Sketch', [{ id: 'line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [{ id: 'reference-horizontal', type: 'horizontal', geometryId: 'line', driving: false }])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: invalidReference }] }), /reference constraint.*must be dimensional/)
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], properties: [{ name: 'Geometry', label: 'Geometry', group: 'Sketch', scope: 'data', type: 'App::PropertyString', value: 'duplicate' }] }] }), /must come from object\.sketch/)
|
|
})
|
|
|
|
test('FCStd Sketcher codec synthesizes native B-spline control-point helpers for Weight', () => {
|
|
const sketch = createSketch('Sketch', [{
|
|
id: 'weighted-spline',
|
|
type: 'bspline',
|
|
degree: 3,
|
|
controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 4, y: 2 }, { x: 6, y: 0 }],
|
|
weights: [1, 0.75, 1.25, 1],
|
|
knots: [0, 0, 0, 0, 1, 1, 1, 1],
|
|
periodic: false,
|
|
}], [
|
|
{ id: 'middle-weight', type: 'weight', geometryId: 'weighted-spline', controlPointIndex: 1, value: 0.75 },
|
|
{ id: 'third-alignment', type: 'internalAlignment', geometryId: 'weighted-spline', internalGeometryIndex: 2, alignmentType: 'bspline-control-point' },
|
|
{ id: 'third-weight', type: 'weight', geometryId: 'weighted-spline', controlPointIndex: 2, value: 1.25 },
|
|
])
|
|
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, /<GeometryList count="3">/)
|
|
assert.equal((written.match(/internalGeometryType="9"/g) ?? []).length, 2)
|
|
assert.match(written, /<ConstraintList count="4"><Constrain Name="__WebSyntheticAlignment0" Type="15" InternalAlignmentType="9" InternalAlignmentIndex="1"/)
|
|
assert.match(written, /<Constrain Name="middle-weight" Type="19"[^>]*First="1"/)
|
|
assert.match(written, /<Constrain Name="third-alignment" Type="15" InternalAlignmentType="9" InternalAlignmentIndex="2"[^>]*First="2" FirstPos="3" Second="0"/)
|
|
assert.match(written, /<Constrain Name="third-weight" Type="19"[^>]*First="2"/)
|
|
assert.match(written, /<Property name="WebGeometryIds"[^>]*>.*<StringList count="1"><String value="weighted-spline"/)
|
|
assert.match(written, /<Property name="WebSyntheticConstraintIds"[^>]*>.*<StringList count="1"><String value="__WebSyntheticAlignment0"/)
|
|
|
|
const inspection = inspectFcstdArchive(archive)
|
|
const restored = inspection.objects[0].sketch
|
|
assert.ok(restored)
|
|
assert.deepEqual(restored.geometry, sketch.geometry)
|
|
assert.deepEqual(restored.constraints, sketch.constraints.map((constraint) => ({ ...constraint, driving: true })))
|
|
const nativeGeometry = decodeFcstdPropertyValue(inspection.objects[0].properties.find((property) => property.name === 'Geometry')!).value as Array<Record<string, unknown>>
|
|
assert.equal(nativeGeometry.length, 3)
|
|
assert.deepEqual(nativeGeometry.slice(1).map((geometry) => geometry.freecadInternalType), [9, 9])
|
|
const missingSynthetic = written.replace('<String value="__WebSyntheticAlignment0"/></StringList></Property>', '<String value="missing-synthetic-alignment"/></StringList></Property>')
|
|
assert.notEqual(missingSynthetic, written)
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(missingSynthetic) })), /references a missing native constraint/)
|
|
|
|
const duplicateWeight = createSketch('Sketch', sketch.geometry, [
|
|
{ id: 'weight-a', type: 'weight', geometryId: 'weighted-spline', controlPointIndex: 1, value: 0.75 },
|
|
{ id: 'weight-b', type: 'weight', geometryId: 'weighted-spline', controlPointIndex: 1, value: 0.8 },
|
|
])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: duplicateWeight }] }), /duplicate Weight/)
|
|
const invalidIndex = createSketch('Sketch', sketch.geometry, [{ id: 'bad-weight', type: 'weight', geometryId: 'weighted-spline', controlPointIndex: 4, value: 1 }])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: invalidIndex }] }), /index is outside/)
|
|
})
|
|
|
|
test('FCStd Sketcher codec round-trips ellipse and B-spline knot internal geometry', () => {
|
|
const sketch = createSketch('Sketch', [
|
|
{ id: 'ellipse', type: 'ellipse', center: { x: 4, y: 5 }, majorRadius: 3, minorRadius: 2, rotation: 0.25 },
|
|
{ id: 'spline', type: 'bspline', degree: 2, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 4, y: 0 }], weights: [1, 0.8, 1], knots: [0, 0, 0, 1, 1, 1], periodic: false },
|
|
], [
|
|
{ id: 'ellipse-major', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-major' },
|
|
{ id: 'ellipse-minor', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-minor' },
|
|
{ id: 'ellipse-focus-1', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-focus' },
|
|
{ id: 'ellipse-focus-2', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 1, alignmentType: 'ellipse-focus' },
|
|
{ id: 'spline-knot-start', type: 'internalAlignment', geometryId: 'spline', internalGeometryIndex: 0, alignmentType: 'bspline-knot' },
|
|
{ id: 'spline-knot-end', type: 'internalAlignment', geometryId: 'spline', internalGeometryIndex: 1, alignmentType: 'bspline-knot' },
|
|
])
|
|
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, /<GeometryList count="8">/)
|
|
assert.deepEqual([...written.matchAll(/internalGeometryType="(\d+)"/g)].map((match) => Number(match[1])).filter(Boolean), [1, 2, 3, 4, 10, 10])
|
|
assert.match(written, /Name="ellipse-major" Type="15" InternalAlignmentType="1" InternalAlignmentIndex="-1"[^>]*FirstPos="0" Second="0"/)
|
|
assert.match(written, /Name="ellipse-focus-2" Type="15" InternalAlignmentType="4" InternalAlignmentIndex="-1"[^>]*FirstPos="1" Second="0"/)
|
|
assert.match(written, /Name="spline-knot-end" Type="15" InternalAlignmentType="10" InternalAlignmentIndex="1"[^>]*FirstPos="1" Second="1"/)
|
|
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 })))
|
|
|
|
const invalidEllipseIndex = createSketch('Sketch', [sketch.geometry[0]], [{ id: 'bad-major', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 1, alignmentType: 'ellipse-major' }])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: invalidEllipseIndex }] }), /invalid ellipse internal index/)
|
|
})
|
|
|
|
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'] }
|
|
const vertexSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'vertex' as const, persistentId: 'Vertex2', topologyVersion: 7, generation: 4, status: 'stable' as const }
|
|
sketch.externalGeometry = [
|
|
{ id: 'external-edge', source: edgeSource, projection: { id: 'edge-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 }, construction: true }, construction: true },
|
|
{ id: 'external-vertex', source: vertexSource, projection: { id: 'vertex-projection', type: 'point', position: { x: 2, y: 0 }, construction: true }, construction: true },
|
|
]
|
|
sketch.constraints = [
|
|
{ id: 'start-on-external-edge', type: 'pointOnObject', point: { geometryId: 'profile', point: 'start' }, geometryId: 'edge-projection' },
|
|
{ id: 'end-at-external-vertex', type: 'coincident', first: { geometryId: 'profile', point: 'end' }, second: { geometryId: 'vertex-projection', point: 'position' } },
|
|
]
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [
|
|
{ id: 'Source', typeId: 'Part::Box', properties: [] },
|
|
{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch },
|
|
]
|
|
document.dependencies = [{ sourceId: 'Sketch', targetId: 'Source', relation: 'topo-ref', propertyName: 'ExternalGeometry:external-edge', reference: 'Edge1' }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(written, /<Property name="ExternalGeo" type="Part::PropertyGeometryList" status="67108864"><GeometryList count="4">/)
|
|
assert.match(written, /<GeoExtension type="Sketcher::ExternalGeometryExtension" Ref="Source\.Edge1" Flags="0" RefIndex="0"\/>/)
|
|
assert.match(written, /<Property name="ExternalGeometry" type="App::PropertyLinkSubList" status="16777216"><LinkSubList count="2"><Link obj="Source" sub="Edge1"\/><Link obj="Source" sub="Vertex2"\/>/)
|
|
assert.match(written, /<Property name="ExternalTypes" type="App::PropertyIntegerList" status="67108864"><IntegerList count="2"><I v="0"\/><I v="0"\/>/)
|
|
assert.match(written, /<Constrain Name="start-on-external-edge" Type="13"[^>]*Second="-3"/)
|
|
assert.match(written, /<Constrain Name="end-at-external-vertex" Type="1"[^>]*Second="-4"/)
|
|
const inspection = inspectFcstdArchive(archive)
|
|
const restored = inspection.objects.find((object) => object.name === 'Sketch')?.sketch
|
|
assert.ok(restored)
|
|
assert.deepEqual(restored.externalGeometry, sketch.externalGeometry)
|
|
assert.deepEqual(restored.constraints, sketch.constraints.map((constraint) => ({ ...constraint, driving: true })))
|
|
const externalTypes = inspection.objects.find((object) => object.name === 'Sketch')?.properties.find((property) => property.name === 'ExternalTypes')
|
|
assert.deepEqual(decodeFcstdPropertyValue(externalTypes!).value, [0, 0])
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(written.replace('<IntegerList count="2">', '<IntegerList count="3">')) })), /IntegerList count/)
|
|
const definingArchive = inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(written.replace('Ref="Source.Edge1" Flags="0"', 'Ref="Source.Edge1" Flags="1"')) }))
|
|
assert.equal(definingArchive.objects.find((object) => object.name === 'Sketch')?.sketch?.externalGeometry[0].defining, true)
|
|
|
|
const serializeExternal = (source: TopoRefValue, projection: SketchGeometry = sketch.externalGeometry[0].projection) => serializeFcstdMetadataArchive({
|
|
...document,
|
|
objects: [document.objects[0], { ...document.objects[1], sketch: { ...sketch, externalGeometry: [{ id: 'external', source, projection, construction: true }], constraints: [] } }],
|
|
})
|
|
assert.throws(() => serializeExternal({ ...edgeSource, objectId: 'Missing' }), /undeclared object/)
|
|
assert.throws(() => serializeExternal({ ...edgeSource, status: 'ambiguous' }), /stable topology status/)
|
|
assert.throws(() => serializeExternal({ ...edgeSource, persistentId: 'Face1' }), /not a lossless native EdgeN/)
|
|
assert.throws(() => serializeExternal({ ...edgeSource, persistentId: 'topo-edge-123' }), /not a lossless native EdgeN/)
|
|
assert.throws(() => serializeExternal({ ...edgeSource, topologyVersion: -1 }), /non-negative integer topology versions/)
|
|
assert.throws(() => serializeExternal({ ...edgeSource, kind: 'vertex', persistentId: 'Vertex1' }), /requires a point projection/)
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [document.objects[0], { ...document.objects[1], properties: [{ name: 'ExternalGeo', label: 'External geometry', group: 'Sketch', scope: 'data', type: 'App::PropertyString', value: 'duplicate' }] }] }), /must come from object\.sketch/)
|
|
})
|
|
|
|
test('FCStd Sketcher codec groups one native Face link across multiple external projections', () => {
|
|
const sketch = createSketch('Sketch', [{ id: 'datum', type: 'point', position: { x: 0, y: 5 } }])
|
|
const faceSource = { schemaVersion: 1 as const, objectId: 'Box', kind: 'face' as const, persistentId: 'Face6', topologyVersion: 4, generation: 3, status: 'stable' as const, signature: 'box-top-face' }
|
|
sketch.externalGeometry = [
|
|
{ id: 'face-left', source: faceSource, projection: { id: 'face-left-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 0, y: 10 }, construction: true }, construction: true },
|
|
{ id: 'face-top', source: faceSource, projection: { id: 'face-top-projection', type: 'line', start: { x: 0, y: 10 }, end: { x: 20, y: 10 }, construction: true }, construction: true },
|
|
{ id: 'face-right', source: faceSource, projection: { id: 'face-right-projection', type: 'line', start: { x: 20, y: 0 }, end: { x: 20, y: 10 }, construction: true }, construction: true },
|
|
{ id: 'face-bottom', source: faceSource, projection: { id: 'face-bottom-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 20, y: 0 }, construction: true }, construction: true },
|
|
]
|
|
sketch.constraints = [{ id: 'datum-on-face-left', type: 'pointOnObject', point: { geometryId: 'datum', point: 'position' }, geometryId: 'face-left-projection' }]
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [
|
|
{ id: 'Box', typeId: 'Part::Box', properties: [] },
|
|
{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch },
|
|
]
|
|
document.dependencies = [{ sourceId: 'Sketch', targetId: 'Box', relation: 'topo-ref', propertyName: 'ExternalGeometry:face-left', reference: 'Face6' }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(written, /<Property name="ExternalGeometry"[^>]*><LinkSubList count="1"><Link obj="Box" sub="Face6"\/><\/LinkSubList>/)
|
|
assert.match(written, /<Property name="ExternalTypes"[^>]*><IntegerList count="1"><I v="0"\/><\/IntegerList>/)
|
|
assert.match(written, /<Property name="ExternalGeo"[^>]*><GeometryList count="6">/)
|
|
assert.equal((written.match(/ExternalGeometryExtension" Ref="Box\.Face6" Flags="0" RefIndex="0"/g) ?? []).length, 4)
|
|
assert.match(written, /Name="datum-on-face-left" Type="13"[^>]*First="0" FirstPos="1" Second="-3"/)
|
|
const restored = inspectFcstdArchive(archive).objects.find((object) => object.name === 'Sketch')?.sketch
|
|
assert.ok(restored)
|
|
assert.deepEqual(restored.externalGeometry, sketch.externalGeometry)
|
|
assert.deepEqual(restored.constraints, [{ ...sketch.constraints[0], driving: true }])
|
|
|
|
const reordered = structuredClone(sketch)
|
|
reordered.externalGeometry[1].source = { signature: 'box-top-face', status: 'stable', generation: 3, topologyVersion: 4, persistentId: 'Face6', kind: 'face', objectId: 'Box', schemaVersion: 1 }
|
|
assert.doesNotThrow(() => serializeFcstdMetadataArchive({ ...document, objects: [document.objects[0], { ...document.objects[1], sketch: reordered }] }))
|
|
const conflicting = structuredClone(sketch)
|
|
conflicting.externalGeometry[1].source = { ...conflicting.externalGeometry[1].source, signature: 'conflicting-face-signature' }
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [document.objects[0], { ...document.objects[1], sketch: conflicting }] }), /conflicting TopoRef metadata/)
|
|
const duplicateEdge = structuredClone(sketch)
|
|
duplicateEdge.externalGeometry = duplicateEdge.externalGeometry.slice(0, 2).map((external) => ({ ...external, source: { ...external.source, kind: 'edge' as const, persistentId: 'Edge1' } }))
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [document.objects[0], { ...document.objects[1], sketch: duplicateEdge }] }), /external edge.*exactly one projection/)
|
|
})
|
|
|
|
test('FCStd Sketcher codec round-trips Projection, Intersection, Both and native external state flags', () => {
|
|
const sketch = createSketch('Sketch')
|
|
const intersectionSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'edge' as const, persistentId: 'Edge9', topologyVersion: 2, generation: 1, status: 'stable' as const, signature: 'vertical-edge' }
|
|
const bothSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'face' as const, persistentId: 'Face1', topologyVersion: 2, generation: 1, status: 'stable' as const, signature: 'side-face' }
|
|
const projectionSource = { schemaVersion: 1 as const, objectId: 'Source', kind: 'edge' as const, persistentId: 'Edge1', topologyVersion: 2, generation: 1, status: 'stable' as const, signature: 'bottom-edge' }
|
|
sketch.externalGeometry = [
|
|
{ id: 'intersection-point', source: intersectionSource, projection: { id: 'intersection-point-projection', type: 'point', position: { x: 0, y: 0 }, construction: true }, construction: true, mode: 'intersection', defining: true },
|
|
{ id: 'both-projection', source: bothSource, projection: { id: 'both-line-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, construction: true }, construction: true, mode: 'both', frozen: true, sync: true },
|
|
{ id: 'both-intersection', source: bothSource, projection: { id: 'both-point-projection', type: 'point', position: { x: 0, y: 0 }, construction: true }, construction: true, mode: 'both', frozen: true, sync: true },
|
|
{ id: 'frozen-projection', source: projectionSource, projection: { id: 'frozen-line-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, construction: true }, construction: true, defining: true, frozen: true, detached: true, missing: true },
|
|
]
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [
|
|
{ id: 'Source', typeId: 'Part::Box', properties: [] },
|
|
{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch },
|
|
]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(written, /<Property name="ExternalTypes"[^>]*><IntegerList count="3"><I v="1"\/><I v="2"\/><I v="0"\/><\/IntegerList>/)
|
|
assert.equal((written.match(/Ref="Source\.Edge9" Flags="1" RefIndex="0"/g) ?? []).length, 1)
|
|
assert.equal((written.match(/Ref="Source\.Face1" Flags="18" RefIndex="1"/g) ?? []).length, 2)
|
|
assert.equal((written.match(/Ref="Source\.Edge1" Flags="15" RefIndex="2"/g) ?? []).length, 1)
|
|
const restored = inspectFcstdArchive(archive).objects.find((object) => object.name === 'Sketch')?.sketch
|
|
assert.deepEqual(restored?.externalGeometry, sketch.externalGeometry)
|
|
|
|
const conflictingMode = structuredClone(sketch)
|
|
conflictingMode.externalGeometry[2].mode = 'intersection'
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [document.objects[0], { ...document.objects[1], sketch: conflictingMode }] }), /conflicting mode or state flags/)
|
|
const invalidSync = structuredClone(sketch)
|
|
invalidSync.externalGeometry[1].frozen = false
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [document.objects[0], { ...document.objects[1], sketch: invalidSync }] }), /only synchronize while frozen/)
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(written.replace('Ref="Source.Edge9" Flags="1"', 'Ref="Source.Edge9" Flags="32"')) })), /unsupported native flags/)
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(written.replace('Ref="Source.Face1" Flags="18"', 'Ref="Source.Face1" Flags="2"')) })), /conflicting native flags/)
|
|
})
|
|
|
|
test('FCStd Sketch attachment codec maps Support, FlatFace, offset, and TopoRef metadata to native FreeCAD fields', () => {
|
|
const supportTopoRef: TopoRefValue = { schemaVersion: 1, objectId: 'Source', kind: 'face', persistentId: 'Face1', topologyVersion: 8, generation: 3, status: 'stable', signature: 'support-face-signature', candidates: ['Face1'] }
|
|
const sketch = createSketch('Sketch', [{ id: 'profile', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 } }])
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [
|
|
{ id: 'Source', typeId: 'Part::Box', properties: [] },
|
|
{
|
|
id: 'Sketch',
|
|
typeId: 'Sketcher::SketchObject',
|
|
properties: [
|
|
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: { objectId: 'Source', subElement: supportTopoRef } },
|
|
{ name: 'MapMode', label: 'Map mode', group: 'Attachment', scope: 'data', type: 'App::PropertyEnumeration', value: 'FlatFace', options: [...ATTACHMENT_MAP_MODES] },
|
|
{ name: 'AttachmentOffset', label: 'Attachment offset', group: 'Attachment', scope: 'data', type: 'App::PropertyPlacement', value: { position: { x: 1, y: 2, z: 3 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 90 } } },
|
|
],
|
|
sketch,
|
|
},
|
|
]
|
|
document.dependencies = [{ sourceId: 'Sketch', targetId: 'Source', relation: 'topo-ref', propertyName: 'Support', reference: 'Face1' }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(written, /<Property name="AttachmentSupport" type="App::PropertyLinkSubList"><LinkSubList count="1"><Link obj="Source" sub="Face1"\/><\/LinkSubList><\/Property>/)
|
|
assert.match(written, /<Property name="MapMode" type="App::PropertyEnumeration"><Integer value="5"\/><\/Property>/)
|
|
assert.match(written, /<Property name="WebSupportValue" type="App::PropertyString"[^>]*><String value="%7B%22objectId%22%3A%22Source%22/)
|
|
assert.doesNotMatch(written, /<Property name="Support" type=/)
|
|
const inspection = inspectFcstdArchive(archive)
|
|
const summary = inspection.objects.find((object) => object.name === 'Sketch')!
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary.properties.find((property) => property.name === 'AttachmentSupport')!), { value: { schemaVersion: 1, entries: [{ objectId: 'Source', subElement: 'Face1' }] }, decoded: true })
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary.properties.find((property) => property.name === 'MapMode')!), { value: 'FlatFace', decoded: true })
|
|
const proxySketch = inspection.proxyDocument.objects.find((object) => object.id === 'Sketch')!
|
|
assert.deepEqual(proxySketch.properties.find((property) => property.name === 'Support')?.value, { objectId: 'Source', subElement: supportTopoRef })
|
|
assert.equal(proxySketch.properties.find((property) => property.name === 'MapMode')?.value, 'FlatFace')
|
|
const offset = proxySketch.properties.find((property) => property.name === 'AttachmentOffset')?.value as { position: { x: number; y: number; z: number }; rotation: { angle: number } }
|
|
assert.deepEqual(offset.position, { x: 1, y: 2, z: 3 })
|
|
assert.ok(Math.abs(offset.rotation.angle - 90) < 1e-9)
|
|
|
|
const withAttachment = (support: unknown, mapMode = 'FlatFace') => serializeFcstdMetadataArchive({
|
|
...document,
|
|
objects: [{ ...document.objects[0] }, { ...document.objects[1], properties: [
|
|
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: support as never },
|
|
{ name: 'MapMode', label: 'Map mode', group: 'Attachment', scope: 'data', type: 'App::PropertyEnumeration', value: mapMode, options: [...ATTACHMENT_MAP_MODES] },
|
|
] }],
|
|
})
|
|
assert.throws(() => withAttachment({ objectId: 'Missing', subElement: 'Face1' }), /undeclared object/)
|
|
assert.throws(() => withAttachment({ objectId: 'Source', subElement: { ...supportTopoRef, status: 'ambiguous' } }), /stable topology status/)
|
|
assert.throws(() => withAttachment({ objectId: 'Source', subElement: 'FaceX' }), /lossless native FaceN/)
|
|
assert.throws(() => withAttachment({ objectId: 'Source', subElement: 'Edge1' }), /FlatFace Support requires.*FaceN/)
|
|
assert.throws(() => withAttachment({ objectId: 'Source', subElement: 'Face1' }, 'NormalToEdge'), /NormalToEdge Support requires.*EdgeN/)
|
|
assert.throws(() => withAttachment({ objectId: 'Source', subElement: 'Face1' }, 'ObjectXY'), /whole object without/)
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0] }, { ...document.objects[1], properties: [{ name: 'AttachmentSupport', label: 'AttachmentSupport', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSubList', value: { schemaVersion: 1, entries: [] } }] }] }), /reserved for the native attachment codec/)
|
|
})
|
|
|
|
test('FCStd Sketch SnellsLaw uses two endpoint references and a third native boundary curve', () => {
|
|
const sketch = createSketch('SnellSketch', [
|
|
{ id: 'incident-ray', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 5 } },
|
|
{ id: 'refracted-ray', type: 'line', start: { x: 5, y: 5 }, end: { x: 10, y: 2 } },
|
|
{ id: 'boundary', type: 'line', start: { x: 0, y: 5 }, end: { x: 10, y: 5 }, construction: true },
|
|
], [{ id: 'refraction', type: 'snellsLaw', first: { geometryId: 'incident-ray', point: 'end' }, second: { geometryId: 'refracted-ray', point: 'start' }, boundaryGeometryId: 'boundary', value: 1.2 }])
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'SnellSketch', typeId: 'Sketcher::SketchObject', properties: [], sketch }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const written = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(written, /<Constrain Name="refraction" Type="16" Value="1\.2"[^>]*First="0" FirstPos="2" Second="1" SecondPos="1" Third="2" ThirdPos="0"[^>]*ElementIds="0 1 2" ElementPositions="2 1 0"/)
|
|
const restored = inspectFcstdArchive(archive).objects[0].sketch
|
|
assert.ok(restored)
|
|
assert.deepEqual(restored.constraints, [{ ...sketch.constraints[0], driving: true }])
|
|
|
|
const legacy = createSketch('SnellSketch', sketch.geometry, [{ id: 'legacy', type: 'snellsLaw', firstGeometryId: 'incident-ray', secondGeometryId: 'refracted-ray', value: 1.2 }])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: legacy }] }), /requires two endpoint references and a boundary geometry/)
|
|
const pointBoundary = createSketch('SnellSketch', [...sketch.geometry.slice(0, 2), { id: 'boundary-point', type: 'point', position: { x: 5, y: 5 } }], [{ id: 'invalid-boundary', type: 'snellsLaw', first: { geometryId: 'incident-ray', point: 'end' }, second: { geometryId: 'refracted-ray', point: 'start' }, boundaryGeometryId: 'boundary-point', value: 1.2 }])
|
|
assert.throws(() => serializeFcstdMetadataArchive({ ...document, objects: [{ ...document.objects[0], sketch: pointBoundary }] }), /requires curve geometries/)
|
|
})
|
|
|
|
test('FCStd LinkSub uses native multi-subelement XML and round-trips without JSON attributes', () => {
|
|
const xml = '<Document SchemaVersion="4"><Objects><Object name="pad" type="PartDesign::Feature"/><Object name="fillet" type="PartDesign::Fillet"/></Objects><ObjectData><Object name="fillet"><Properties><Property name="Base" type="App::PropertyLinkSub"><LinkSub value="pad" count="2"><Sub value="Edge1"/><Sub value="Edge7"/></LinkSub></Property></Properties></Object></ObjectData></Document>'
|
|
const inspected = inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(xml) }))
|
|
const summary = inspected.objects.find((object) => object.name === 'fillet')?.properties.find((property) => property.name === 'Base')
|
|
assert.deepEqual(summary, { name: 'Base', typeId: 'App::PropertyLinkSub', element: 'LinkSub', value: 'pad', subElements: ['Edge1', 'Edge7'] })
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: { schemaVersion: 1, objectId: 'pad', subElements: ['Edge1', 'Edge7'] }, decoded: true })
|
|
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'pad', typeId: 'PartDesign::Feature', properties: [] }, { id: 'fillet', typeId: 'PartDesign::Fillet', properties: [{
|
|
name: 'Base', label: 'Base', group: 'Fillet', scope: 'data', type: 'App::PropertyLinkSub',
|
|
value: { schemaVersion: 1, objectId: 'pad', subElements: ['Edge1', 'Edge7'] },
|
|
}] }]
|
|
const written = unzipSync(serializeFcstdMetadataArchive(document))['Document.xml']
|
|
const writtenXml = new TextDecoder().decode(written)
|
|
assert.match(writtenXml, /<LinkSub value="pad" count="2"><Sub value="Edge1"\/><Sub value="Edge7"\/><\/LinkSub>/)
|
|
const roundTripped = inspectFcstdArchive(zipSync({ 'Document.xml': written })).objects.find((object) => object.name === 'fillet')?.properties[0]
|
|
assert.deepEqual(roundTripped?.subElements, ['Edge1', 'Edge7'])
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(xml.replace('count="2"', 'count="3"')) })), /LinkSub count/)
|
|
})
|
|
|
|
test('FCStd LinkSubList uses native obj/sub children and preserves structured entries', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'holder', typeId: 'Part::Feature', properties: [{ name: 'References', label: 'References', group: 'Links', scope: 'data', type: 'App::PropertyLinkSubList', value: { schemaVersion: 1, entries: [{ objectId: 'pad', subElement: 'Face1' }, { objectId: 'pad', subElement: 'Edge3' }, { objectId: 'body', subElement: null }] } }] }, { id: 'pad', typeId: 'PartDesign::Feature', properties: [] }, { id: 'body', typeId: 'PartDesign::Body', properties: [] }]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /<Objects Count="3" Dependencies="1">/)
|
|
assert.match(written, /<ObjectData Count="3">/)
|
|
assert.match(written, /<Object name="holder"><Properties Count="1" TransientCount="0">/)
|
|
assert.match(written, /<Property name="References" type="App::PropertyLinkSubList" group="Links" doc="References" attr="0" ro="0" hide="0">/)
|
|
assert.match(written, /<LinkSubList count="3"><Link obj="pad" sub="Face1"\/><Link obj="pad" sub="Edge3"\/><Link obj="body" sub=""\/><\/LinkSubList>/)
|
|
assert.doesNotMatch(written, /\{"schemaVersion":1,"entries"/)
|
|
const summary = inspectFcstdArchive(serializeFcstdMetadataArchive(document)).objects.find((object) => object.name === 'holder')?.properties[0]
|
|
assert.deepEqual(summary?.linkSubs, [{ objectId: 'pad', subElement: 'Face1' }, { objectId: 'pad', subElement: 'Edge3' }, { objectId: 'body', subElement: '' }])
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: { schemaVersion: 1, entries: [{ objectId: 'pad', subElement: 'Face1' }, { objectId: 'pad', subElement: 'Edge3' }, { objectId: 'body', subElement: '' }] }, decoded: true })
|
|
const malformed = '<Document><Objects><Object name="holder" type="Part::Feature"/></Objects><ObjectData><Object name="holder"><Properties><Property name="References" type="App::PropertyLinkSubList"><LinkSubList count="2"><Link obj="pad" sub="Face1"/></LinkSubList></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(malformed) })), /LinkSubList count/)
|
|
})
|
|
|
|
test('FCStd LinkList uses native count and Link children', () => {
|
|
const xml = '<Document SchemaVersion="4"><Objects><Object name="loft" type="Part::Loft"/></Objects><ObjectData><Object name="loft"><Properties><Property name="Sections" type="App::PropertyLinkList"><LinkList count="3"><Link value="section-a"/><Link value="section-b"/><Link value="section-c"/></LinkList></Property></Properties></Object></ObjectData></Document>'
|
|
const summary = inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(xml) })).objects[0].properties[0]
|
|
assert.deepEqual(summary.links, ['section-a', 'section-b', 'section-c'])
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary), { value: ['section-a', 'section-b', 'section-c'], decoded: true })
|
|
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'loft', typeId: 'Part::Loft', properties: [{ name: 'Sections', label: 'Sections', group: 'Loft', scope: 'data', type: 'App::PropertyLinkList', value: ['section-a', 'section-b'] }] }]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /<LinkList count="2"><Link value="section-a"\/><Link value="section-b"\/><\/LinkList>/)
|
|
assert.doesNotMatch(written, /\["section-a"/)
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(xml.replace('count="3"', 'count="2"')) })), /LinkList count/)
|
|
})
|
|
|
|
test('FCStd FloatList preserves native numeric list children for pattern spacings', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'pattern', typeId: 'PartDesign::LinearPattern', properties: [{ name: 'Spacings', label: 'Spacings', group: 'Pattern', scope: 'data', type: 'App::PropertyFloatList', value: [3, -1, 5] }] }]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /<FloatList count="3"><Float value="3"\/><Float value="-1"\/><Float value="5"\/><\/FloatList>/)
|
|
const summary = inspectFcstdArchive(serializeFcstdMetadataArchive(document)).objects[0].properties[0]
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary), { value: [3, -1, 5], decoded: true })
|
|
const malformed = '<Document><Objects><Object name="pattern" type="PartDesign::LinearPattern"/></Objects><ObjectData><Object name="pattern"><Properties><Property name="Spacings" type="App::PropertyFloatList"><FloatList count="2"><Float value="3"/></FloatList></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(malformed) })), /FloatList count/)
|
|
})
|
|
|
|
test('FCStd StringList preserves native string children', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'feature', typeId: 'Part::Feature', properties: [{ name: 'Tags', label: 'Tags', group: 'Data', scope: 'data', type: 'App::PropertyStringList', value: ['machined', 'inspection'] }] }]
|
|
const written = new TextDecoder().decode(unzipSync(serializeFcstdMetadataArchive(document))['Document.xml'])
|
|
assert.match(written, /<StringList count="2"><String value="machined"\/><String value="inspection"\/><\/StringList>/)
|
|
const summary = inspectFcstdArchive(serializeFcstdMetadataArchive(document)).objects[0].properties[0]
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary), { value: ['machined', 'inspection'], decoded: true })
|
|
const malformed = '<Document><Objects><Object name="feature" type="Part::Feature"/></Objects><ObjectData><Object name="feature"><Properties><Property name="Tags" type="App::PropertyStringList"><StringList count="2"><String value="machined"/></StringList></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(malformed) })), /StringList count/)
|
|
})
|
|
|
|
test('FCStd ExpressionEngine uses native expression mappings and rejects ambiguous payloads', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'spreadsheet', typeId: 'Spreadsheet::Sheet', properties: [{ name: 'Length', label: 'Length', group: 'Spreadsheet', scope: 'data', type: 'App::PropertyLength', value: 12, expression: 'Spreadsheet.Width * 2' }, { name: 'Width', label: 'Width', group: 'Spreadsheet', scope: 'data', type: 'App::PropertyLength', value: 6 }] }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const writtenXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(writtenXml, /<Object name="spreadsheet"><Properties Count="3" TransientCount="0">/)
|
|
assert.match(writtenXml, /<Property name="ExpressionEngine" type="App::PropertyExpressionEngine"><ExpressionEngine count="1"><Expression path="Length" expression="Spreadsheet.Width \* 2"\/><\/ExpressionEngine><\/Property>/)
|
|
assert.doesNotMatch(writtenXml, /<Property name="Length"[^>]*\sexpression=/)
|
|
const inspection = inspectFcstdArchive(archive)
|
|
const length = inspection.objects[0].properties.find((property) => property.name === 'Length')
|
|
assert.equal(length?.expression, 'Spreadsheet.Width * 2')
|
|
assert.equal(inspection.objects[0].properties.find((property) => property.name === 'ExpressionEngine')?.typeId, 'App::PropertyExpressionEngine')
|
|
|
|
const malformedCount = '<Document><Objects><Object name="spreadsheet" type="Spreadsheet::Sheet"/></Objects><ObjectData><Object name="spreadsheet"><Properties><Property name="ExpressionEngine" type="App::PropertyExpressionEngine"><ExpressionEngine count="2"><Expression path="Length" expression="Spreadsheet.Width * 2"/></ExpressionEngine></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(malformedCount) })), /ExpressionEngine count/)
|
|
const duplicatePath = '<Document><Objects><Object name="spreadsheet" type="Spreadsheet::Sheet"/></Objects><ObjectData><Object name="spreadsheet"><Properties><Property name="ExpressionEngine" type="App::PropertyExpressionEngine"><ExpressionEngine count="2"><Expression path="Length" expression="Spreadsheet.Width * 2"/><Expression path="Length" expression="Spreadsheet.Height"/></ExpressionEngine></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(duplicatePath) })), /duplicate path/)
|
|
|
|
const legacy = '<Document><Objects><Object name="spreadsheet" type="Spreadsheet::Sheet"/></Objects><ObjectData><Object name="spreadsheet"><Properties><Property name="Length" type="App::PropertyLength" expression="Spreadsheet.Width * 2"><Length value="12"/></Property></Properties></Object></ObjectData></Document>'
|
|
assert.equal(inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(legacy) })).objects[0].properties[0].expression, 'Spreadsheet.Width * 2')
|
|
})
|
|
|
|
test('FCStd Vector and Placement use native FreeCAD attributes', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'feature', typeId: 'Part::Feature', properties: [
|
|
{ name: 'Direction', label: 'Direction', group: 'Geometry', scope: 'data', type: 'App::PropertyVector', value: { x: 1.25, y: -2, z: 3.5 } },
|
|
{ name: 'Placement', label: 'Placement', group: 'Base', scope: 'data', type: 'App::PropertyPlacement', value: { position: { x: 4, y: 5, z: 6 }, rotation: { axis: { x: 0, y: 0, z: 2 }, angle: 90 } } },
|
|
] }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
|
|
assert.match(documentXml, /<PropertyVector valueX="1.25" valueY="-2" valueZ="3.5"\/>/)
|
|
assert.match(documentXml, /<PropertyPlacement Px="4" Py="5" Pz="6" Q0="0" Q1="0" Q2="0\.707/)
|
|
assert.match(documentXml, /<Property name="Placement" type="App::PropertyPlacement" status="8388608">/)
|
|
assert.doesNotMatch(documentXml, /\{"position"/)
|
|
const summaries = inspectFcstdArchive(archive).objects[0].properties
|
|
assert.deepEqual(decodeFcstdPropertyValue(summaries[0]), { value: { x: 1.25, y: -2, z: 3.5 }, decoded: true })
|
|
const decodedPlacement = decodeFcstdPropertyValue(summaries[1])
|
|
assert.equal(decodedPlacement.decoded, true)
|
|
const placement = decodedPlacement.value as { position: { x: number; y: number; z: number }; rotation: { axis: { x: number; y: number; z: number }; angle: number } }
|
|
assert.deepEqual(placement.position, { x: 4, y: 5, z: 6 })
|
|
assert.ok(Math.abs(placement.rotation.angle - 90) < 1e-9)
|
|
assert.deepEqual(placement.rotation.axis, { x: 0, y: 0, z: 1 })
|
|
|
|
const quaternionOnly = '<Document><Objects><Object name="feature" type="Part::Feature"/></Objects><ObjectData><Object name="feature"><Properties><Property name="Placement" type="App::PropertyPlacement"><PropertyPlacement Px="1" Py="2" Pz="3" Q0="0" Q1="0" Q2="0.7071067811865475" Q3="0.7071067811865476"/></Property></Properties></Object></ObjectData></Document>'
|
|
const quaternionSummary = inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(quaternionOnly) })).objects[0].properties[0]
|
|
const quaternionPlacement = decodeFcstdPropertyValue(quaternionSummary).value as typeof placement
|
|
assert.ok(Math.abs(quaternionPlacement.rotation.angle - 90) < 1e-9)
|
|
assert.ok(Math.abs(quaternionPlacement.rotation.axis.z - 1) < 1e-9)
|
|
})
|
|
|
|
test('FCStd structured GuiDocument views round-trip without raw XML assembly', () => {
|
|
const document = recomputeDocumentFixture()
|
|
const archive = serializeFcstdMetadataArchive(document, { guiViews: [{ name: 'Axonometric', type: 'Gui::View3D', visibility: 'true' }, { name: 'Hidden', visibility: 'false' }] })
|
|
assert.deepEqual(inspectFcstdArchive(archive).guiDocument.views, [
|
|
{ name: 'Axonometric', type: 'Gui::View3D', visibility: 'true' },
|
|
{ name: 'Hidden', type: '', visibility: 'false' },
|
|
])
|
|
assert.throws(() => serializeFcstdMetadataArchive(document, { guiDocumentXml: '<GuiDocument/>', guiViews: [] }), /either guiDocumentXml or guiViews/)
|
|
assert.throws(() => serializeFcstdMetadataArchive(document, { guiViews: [{ name: ' ' }] }), /names must be non-empty/)
|
|
})
|
|
|
|
test('FCStd native GuiDocument separates view properties and decodes ViewProvider appearance', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'Box', typeId: 'Part::Feature', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Visibility', label: 'Visibility', group: 'Display', scope: 'view', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Transparency', label: 'Transparency', group: 'Display', scope: 'view', type: 'App::PropertyPercent', value: 25 },
|
|
{ name: 'LineColor', label: 'Line color', group: 'Display', scope: 'view', type: 'App::PropertyColor', value: '#112233' },
|
|
{ name: 'PointColor', label: 'Point color', group: 'Display', scope: 'view', type: 'App::PropertyColor', value: '#445566ff' },
|
|
{ name: 'ShapeColor', label: 'Shape color', group: 'Display', scope: 'view', type: 'App::PropertyColor', value: '#579a9c' },
|
|
{ name: 'Deviation', label: 'Deviation', group: 'Display', scope: 'view', type: 'App::PropertyFloat', value: 0.1 },
|
|
] }]
|
|
const archive = serializeFcstdMetadataArchive(document)
|
|
const files = unzipSync(archive)
|
|
const documentXml = new TextDecoder().decode(files['Document.xml'])
|
|
const guiXml = new TextDecoder().decode(files['GuiDocument.xml'])
|
|
assert.match(documentXml, /name="Length"/)
|
|
assert.doesNotMatch(documentXml, /name="Visibility"|name="LineColor"/)
|
|
assert.match(guiXml, /^<Document SchemaVersion="1">/)
|
|
const inspection = inspectFcstdArchive(archive).guiDocument
|
|
assert.equal(inspection.rootName, 'Document')
|
|
assert.equal(inspection.viewCount, 1)
|
|
assert.deepEqual(inspection.viewProviders[0], { objectName: 'Box', expanded: false, treeRank: -1, propertyCount: 6, visibility: true, transparency: 25, lineColor: '#112233ff', pointColor: '#445566ff', shapeColor: '#579a9cff', deviation: 0.1, shapeAppearanceResource: 'WebShapeAppearance0', shapeAppearance: [{ ambientColor: '#333333ff', diffuseColor: '#579a9cff', specularColor: '#000000ff', emissiveColor: '#000000ff', shininess: 0.20000000298023224, transparency: 0.25, image: '', imagePath: '', uuid: '' }] })
|
|
assert.equal(inspectFcstdArchive(archive).proxyDocument.objects[0].properties.find((property) => property.name === 'ShapeColor')?.value, '#579a9cff')
|
|
|
|
const nativeGui = '<Document SchemaVersion="1"><ViewProviderData Count="1"><ViewProvider name="Box" expanded="1" treeRank="2"><Properties Count="7"><Property name="Visibility" type="App::PropertyBool"><Bool value="false"/></Property><Property name="Transparency" type="App::PropertyPercent"><Integer value="40"/></Property><Property name="LineColor" type="App::PropertyColor"><PropertyColor value="287454207"/></Property><Property name="PointColor" type="App::PropertyColor"><PropertyColor value="1146447615"/></Property><Property name="Deviation" type="App::PropertyFloatConstraint"><Float value="0.25"/></Property><Property name="DisplayMode" type="App::PropertyEnumeration"><Integer value="1"/></Property><Property name="ShapeAppearance" type="App::PropertyMaterialList"><MaterialList file="ShapeAppearance" version="3"/></Property></Properties></ViewProvider></ViewProviderData></Document>'
|
|
const materialBytes = new Uint8Array([1, 0, 0, 0, 255, 51, 51, 51, 255, 204, 204, 204, 255, 0, 0, 0, 255, 0, 0, 0, 205, 204, 76, 62, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
|
|
const nativeArchive = zipSync({ 'Document.xml': strToU8('<Document><Objects><Object name="Box" type="Part::Feature"/></Objects><ObjectData><Object name="Box"><Properties/></Object></ObjectData></Document>'), 'GuiDocument.xml': strToU8(nativeGui), ShapeAppearance: materialBytes })
|
|
assert.deepEqual(inspectFcstdArchive(nativeArchive).guiDocument.viewProviders[0], { objectName: 'Box', expanded: true, treeRank: 2, propertyCount: 7, visibility: false, transparency: 40, lineColor: '#112233ff', pointColor: '#445566ff', shapeColor: '#ccccccff', deviation: 0.25, displayMode: 1, shapeAppearanceResource: 'ShapeAppearance', shapeAppearance: [{ ambientColor: '#333333ff', diffuseColor: '#ccccccff', specularColor: '#000000ff', emissiveColor: '#000000ff', shininess: 0.20000000298023224, transparency: 0, image: '', imagePath: '', uuid: '' }] })
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8('<Document/>'), 'GuiDocument.xml': strToU8(nativeGui) })), /missing ShapeAppearance resource/)
|
|
})
|
|
|
|
test('FCStd metadata rewrite retains unknown and script bytes exactly', () => {
|
|
const original = zipSync({ 'Document.xml': strToU8('<Document SchemaVersion="4"><Properties/><Objects/><ObjectData/></Document>'), 'GuiDocument.xml': strToU8('<GuiDocument SchemaVersion="4"><Views/></GuiDocument>'), 'Unknown/custom.bin': new Uint8Array([0, 255, 17, 42]), 'Macro/do-not-run.py': strToU8('raise RuntimeError()') })
|
|
const document = { id: 'rewrite', label: 'Rewritten', version: 1, dirty: false, readOnly: false, units: 'mm', tree: [], objects: [], dependencies: [], recompute: { generation: 0, status: 'idle' as const, objectStates: {}, dirtyObjects: [], order: [], errors: [] } }
|
|
const rewritten = rewriteFcstdMetadataArchive(original, document)
|
|
const entries = unzipSync(rewritten)
|
|
assert.deepEqual([...entries['Unknown/custom.bin']], [0, 255, 17, 42])
|
|
assert.equal(new TextDecoder().decode(entries['Macro/do-not-run.py']), 'raise RuntimeError()')
|
|
assert.equal(inspectFcstdArchive(rewritten).label, 'Rewritten')
|
|
})
|
|
|
|
test('FCStd read-only proxy rewrite preserves the complete archive byte-for-byte and rejects edits', () => {
|
|
const original = zipSync({
|
|
'Document.xml': strToU8('<Document SchemaVersion="4"><Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Proxy source"/></Property></Properties><Objects Count="1"><Object name="VendorObject" type="Vendor::OpaqueFeature"/></Objects><ObjectData Count="1"><Object name="VendorObject"><Properties Count="1"><Property name="OpaqueState" type="Vendor::PropertyState"><VendorState mode="native"><Nested value="keep-exactly"/></VendorState></Property></Properties></Object></ObjectData></Document>'),
|
|
'GuiDocument.xml': strToU8('<GuiDocument SchemaVersion="4"><VendorView state="opaque"/></GuiDocument>'),
|
|
'Vendor/state.bin': new Uint8Array([0, 255, 17, 42]),
|
|
})
|
|
const proxyDocument = inspectFcstdArchive(original).proxyDocument
|
|
const rewritten = rewriteFcstdMetadataArchive(original, proxyDocument)
|
|
assert.deepEqual([...rewritten], [...original])
|
|
|
|
const modified = structuredClone(proxyDocument)
|
|
modified.label = 'Modified proxy'
|
|
assert.throws(() => rewriteFcstdMetadataArchive(original, modified), /cannot be modified/)
|
|
assert.throws(() => rewriteFcstdMetadataArchive(original, proxyDocument, { guiViews: [] }), /cannot override GuiDocument/)
|
|
})
|
|
|
|
test('FCStd shape resources expose stable BRep payload identities', () => {
|
|
const document = recomputeDocumentFixture()
|
|
const payload = new Uint8Array([0, 1, 2, 3, 4])
|
|
const archive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Shape1.brp': payload } })
|
|
const inspection = inspectFcstdArchive(archive)
|
|
assert.deepEqual(inspection.shapeResources, [{ path: 'Part/Shape1.brp', format: 'brep', mediaType: 'application/x-freecad-brep', byteLength: 5, contentHash: 'ba1e9fef', status: 'available' }])
|
|
const resources = extractFcstdShapeResources(archive)
|
|
assert.deepEqual([...resources[0].bytes], [...payload])
|
|
assert.equal(resources[0].contentHash, inspection.shapeResources[0].contentHash)
|
|
})
|
|
|
|
test('FCStd PropertyPartShape points at an in-archive BRep resource', () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'Holder', typeId: 'Part::Feature', properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: 'Part/Holder.Shape.brp', format: 'brep', elementMap: '4', elementMapEntries: [{ key: 'Dummy', value: 'Dummy' }] } }] }]
|
|
const archive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Holder.Shape.brp': new Uint8Array([1, 2, 3]) } })
|
|
const archiveEntries = unzipSync(archive)
|
|
assert.deepEqual(Object.keys(archiveEntries), ['Document.xml', 'Part/Holder.Shape.brp', 'GuiDocument.xml'])
|
|
const written = new TextDecoder().decode(archiveEntries['Document.xml'])
|
|
assert.match(written, /<Property name="Shape" type="Part::PropertyPartShape"[^>]*><Part ElementMap="4" file="Part\/Holder\.Shape\.brp"\/><ElementMap new="1" count="1">/)
|
|
assert.match(written, /<ElementMap new="1" count="1"><Element key="Dummy" value="Dummy"\/><\/ElementMap>/)
|
|
const summary = inspectFcstdArchive(archive).objects[0].properties[0]
|
|
assert.deepEqual(summary.shapeResource, { path: 'Part/Holder.Shape.brp', elementMap: '4', elementMapEntries: [{ key: 'Dummy', value: 'Dummy' }] })
|
|
assert.deepEqual(decodeFcstdPropertyValue(summary), { value: { path: 'Part/Holder.Shape.brp', elementMap: '4', elementMapEntries: [{ key: 'Dummy', value: 'Dummy' }], format: 'brep' }, decoded: true })
|
|
assert.throws(() => serializeFcstdMetadataArchive(document), /missing resource/)
|
|
const mismatched = structuredClone(document)
|
|
;(mismatched.objects[0].properties[0].value as { path: string }).path = 'Part/Other.Shape.brp'
|
|
assert.throws(() => serializeFcstdMetadataArchive(mismatched, { opaqueEntries: { 'Part/Other.Shape.brp': new Uint8Array([1]) } }), /must use Holder\.Shape/)
|
|
const missing = '<Document><Objects><Object name="holder" type="Part::Feature"/></Objects><ObjectData><Object name="holder"><Properties><Property name="Shape" type="Part::PropertyPartShape"><Part ElementMap="" file="Part\/Missing.brep"/></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(missing) })), /references missing resource/)
|
|
})
|
|
|
|
test('FCStd PropertyPartShape preserves and validates native ElementMap2 resources', () => {
|
|
const document = recomputeDocumentFixture()
|
|
const mapText = 'BeginElementMap v1\n1 PostfixCount 1\nEdge\nMapCount 1\nElementMap 1 1 0\nEdge\nChildCount 0\nNameCount 1\n0\n;H1\nEndMap\n'
|
|
const hasherText = 'StringTableStart v1 0\n'
|
|
document.objects = [{ id: 'Holder', typeId: 'Part::Feature', properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: 'Part/Holder.Shape.brp', format: 'brep', hasherIndex: 0, elementMap: '1.15.2', elementMapResource: 'Part/Holder.Shape.Map.txt' } }] }]
|
|
const archive = serializeFcstdMetadataArchive(document, {
|
|
opaqueEntries: { 'Part/Holder.Shape.brp': new Uint8Array([1, 2, 3]), 'Part/Holder.Shape.Map.txt': strToU8(mapText) },
|
|
stringHasherTable: parseStringHasherTable(hasherText),
|
|
})
|
|
const archiveEntries = unzipSync(archive)
|
|
assert.deepEqual(Object.keys(archiveEntries), ['Document.xml', 'StringHasher.Table.txt', 'Part/Holder.Shape.brp', 'Part/Holder.Shape.Map.txt', 'GuiDocument.xml'])
|
|
const written = new TextDecoder().decode(archiveEntries['Document.xml'])
|
|
assert.match(written, /^<Document SchemaVersion="4" ProgramVersion="1\.1R14556 \(Git\)" FileVersion="1" StringHasher="1"><StringHasher /)
|
|
assert.match(written, /<Part HasherIndex="0" ElementMap="1\.15\.2" file="Part\/Holder\.Shape\.brp"\/>/)
|
|
assert.match(written, /<ElementMap2 file="Part\/Holder\.Shape\.Map\.txt"\/>/)
|
|
const inspection = inspectFcstdArchive(archive)
|
|
const summary = inspection.objects[0].properties[0]
|
|
assert.deepEqual(summary.shapeResource, { path: 'Part/Holder.Shape.brp', hasherIndex: 0, elementMap: '1.15.2', elementMapResource: 'Part/Holder.Shape.Map.txt' })
|
|
const [{ document: inspectedElementMap, ...elementMapSummary }] = inspection.elementMapResources
|
|
assert.deepEqual(elementMapSummary, { path: 'Part/Holder.Shape.Map.txt', format: 'element-map-v1', byteLength: new TextEncoder().encode(mapText).byteLength, contentHash: '9aa4971a', status: 'available', postfixCount: 1, mapCount: 1, sections: [{ name: 'Edge', nameCount: 1 }] })
|
|
assert.equal(inspectedElementMap?.schemaVersion, 2)
|
|
assert.equal(inspection.stringHasherResource?.path, 'StringHasher.Table.txt')
|
|
assert.equal(inspection.stringHasherResource?.status, 'available')
|
|
assert.equal(inspection.stringHasherResource?.format, 'string-hasher-v1')
|
|
assert.equal(inspection.stringHasherResource?.entryCount, 0)
|
|
assert.equal(inspection.stringHasherResource?.validation.valid, true)
|
|
assert.equal(writeStringHasherTable(inspection.stringHasherResource!.document!), hasherText)
|
|
const rewrittenHasher = rewriteFcstdMetadataArchive(archive, document, { stringHasherTable: parseStringHasherTable(hasherText) })
|
|
assert.equal(new TextDecoder().decode(unzipSync(rewrittenHasher)['StringHasher.Table.txt']), hasherText)
|
|
assert.throws(() => serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Holder.Shape.brp': new Uint8Array([1]) } }), /missing ElementMap2 resource/)
|
|
assert.throws(() => serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Holder.Shape.brp': new Uint8Array([1]), 'Part/Holder.Shape.Map.txt': strToU8(mapText) } }), /HasherIndex requires StringHasher/)
|
|
const malformed = '<Document><Objects><Object name="Holder" type="Part::Feature"/></Objects><ObjectData><Object name="Holder"><Properties><Property name="Shape" type="Part::PropertyPartShape"><Part file="Part\/Holder.Shape.brp"/><ElementMap2 file="Part\/Holder.Shape.Map.txt"/></Property></Properties></Object></ObjectData></Document>'
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(malformed), 'Part/Holder.Shape.brp': new Uint8Array([1]), 'Part/Holder.Shape.Map.txt': strToU8('broken') })), /ElementMap2 resource has unsupported header/)
|
|
})
|
|
|
|
test('FCStd BRep instantiation resolves only referenced UTF-8 resources and releases cancelled results', async () => {
|
|
const document = recomputeDocumentFixture()
|
|
document.objects = [{ id: 'Holder', typeId: 'Part::Feature', properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: { path: 'Part/Holder.Shape.brp', format: 'brep', elementMap: '1' } }] }]
|
|
const validPayload = new TextEncoder().encode('DBRep_DrawableShape\n')
|
|
const archive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Holder.Shape.brp': validPayload } })
|
|
const calls: Array<{ format: string; text: string }> = []
|
|
const imported = await instantiateFcstdShapeResource(archive, 'Part/Holder.Shape.brp', async (input) => { calls.push(input); return { token: 'shape-1' } })
|
|
assert.equal(imported.resource.status, 'available')
|
|
assert.deepEqual(imported.references, [{ objectName: 'Holder', propertyName: 'Shape', elementMap: '1' }])
|
|
assert.deepEqual(calls, [{ format: 'brep', text: 'DBRep_DrawableShape\n' }])
|
|
|
|
const cancelled = new AbortController()
|
|
cancelled.abort()
|
|
await assert.rejects(() => instantiateFcstdShapeResource(archive, 'Part/Holder.Shape.brp', async () => ({ token: 'never' }), { signal: cancelled.signal }), /cancelled/i)
|
|
await assert.rejects(() => instantiateFcstdShapeResource(archive, 'Part/Missing.Shape.brp', async () => ({ token: 'never' })), /does not exist/)
|
|
|
|
const emptyArchive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Holder.Shape.brp': new Uint8Array() } })
|
|
await assert.rejects(() => instantiateFcstdShapeResource(emptyArchive, 'Part/Holder.Shape.brp', async () => ({ token: 'never' })), /empty/)
|
|
const invalidArchive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Holder.Shape.brp': new Uint8Array([0xff]) } })
|
|
await assert.rejects(() => instantiateFcstdShapeResource(invalidArchive, 'Part/Holder.Shape.brp', async () => ({ token: 'never' })), /UTF-8|encoding/i)
|
|
|
|
const lateCancel = new AbortController()
|
|
let released = 0
|
|
await assert.rejects(() => instantiateFcstdShapeResource(archive, 'Part/Holder.Shape.brp', async () => { lateCancel.abort(); return { token: 'shape-2' } }, { signal: lateCancel.signal, release: async () => { released += 1 } }), /cancelled/i)
|
|
assert.equal(released, 1)
|
|
})
|
|
|
|
test('FCStd shape resources store through the project resource boundary', async () => {
|
|
const document = recomputeDocumentFixture()
|
|
const archive = serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Part/Shape1.brp': new Uint8Array([8, 13, 21]), 'Part/empty.brep': new Uint8Array() } })
|
|
const writes: Array<{ bytes: Uint8Array; mediaType: string }> = []
|
|
const stored = await storeFcstdShapeResources(archive, async (bytes, mediaType) => { writes.push({ bytes, mediaType }); return { hash: 'resource-hash-1' } })
|
|
assert.deepEqual(stored.map((resource) => resource.path), ['Part/Shape1.brp'])
|
|
assert.equal(writes[0].mediaType, 'application/x-freecad-brep')
|
|
assert.deepEqual([...writes[0].bytes], [8, 13, 21])
|
|
const facade = createMockFacade()
|
|
const facadeStored = await facade.project.fcstd.storeShapes(archive)
|
|
assert.ok((facadeStored[0]?.hash.length ?? 0) > 0)
|
|
})
|
|
|
|
test('geometry import stream enforces UTF-8 decoding, byte limits and cancellation', async () => {
|
|
async function* chunks() { yield new TextEncoder().encode('ISO-10303-'); yield new TextEncoder().encode('21;') }
|
|
assert.equal(await collectGeometryImportText(chunks()), 'ISO-10303-21;')
|
|
async function* oversized() { yield new Uint8Array([1, 2, 3, 4]) }
|
|
await assert.rejects(() => collectGeometryImportText(oversized(), undefined, 3), /exceeds/)
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
await assert.rejects(() => collectGeometryImportText(chunks(), controller.signal), /cancelled/)
|
|
async function* invalidUtf8() { yield new Uint8Array([0xff]) }
|
|
await assert.rejects(() => collectGeometryImportText(invalidUtf8()), /UTF-8|encoding|encoded/i)
|
|
})
|
|
|
|
test('FCStd inspection is available only through the facade project boundary', () => {
|
|
const facade = createMockFacade()
|
|
const archive = zipSync({ 'Document.xml': strToU8('<Document SchemaVersion="4"><Objects Count="0"/><ObjectData Count="0"/></Document>') })
|
|
const inspection = facade.project.fcstd.inspect(archive)
|
|
assert.equal(inspection.format, 'FCStd')
|
|
assert.equal(inspection.compatibility.level, 'metadata-compatible')
|
|
assert.equal(inspection.compatibility.readOnly, true)
|
|
const serialized = facade.project.fcstd.serializeMetadata({ ...facade.app.document.getActive(), id: 'facade-fcstd', label: 'Facade FCStd' })
|
|
assert.equal(facade.project.fcstd.inspect(serialized).label, 'Facade FCStd')
|
|
})
|
|
|
|
test('project resources use content identity and reference counting', async () => {
|
|
const facade = createMockFacade()
|
|
const bytes = new Uint8Array([1, 2, 3, 5, 8])
|
|
const first = await facade.project.resource.put(bytes, 'application/octet-stream')
|
|
const second = await facade.project.resource.put(bytes, 'application/octet-stream')
|
|
assert.equal(first.hash, second.hash)
|
|
assert.equal(second.refCount, 2)
|
|
bytes[0] = 99
|
|
assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8])
|
|
await facade.project.resource.release(first.hash)
|
|
assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8])
|
|
await facade.project.resource.release(first.hash)
|
|
assert.equal(await facade.project.resource.get(first.hash), null)
|
|
})
|
|
|
|
test('resource quota policy reserves capacity and sweep planning is conservative', () => {
|
|
assert.deepEqual(assessResourceQuota({ usage: 600, quota: 1000 }, 300, 0.05), { allowed: true, known: true, usage: 600, quota: 1000, incomingBytes: 300, reservedBytes: 50, availableBytes: 350, reason: undefined })
|
|
const denied = assessResourceQuota({ usage: 600, quota: 1000 }, 351, 0.05)
|
|
assert.equal(denied.allowed, false)
|
|
assert.match(denied.reason || '', /only 350 bytes/)
|
|
assert.equal(assessResourceQuota({}, 10).known, false)
|
|
assert.deepEqual(planResourceSweep([
|
|
{ hash: 'active', byteLength: 10, refCount: 2 },
|
|
{ hash: 'zero', byteLength: 20, refCount: 0 },
|
|
{ hash: 'missing', byteLength: 30, refCount: 1 },
|
|
], ['active', 'zero', 'orphan']), { deleteFileHashes: ['orphan', 'zero'], deleteRecordHashes: ['zero'], missingFileHashes: ['missing'] })
|
|
})
|
|
|
|
test('resource sweep is exposed through the project facade in fallback mode', async () => {
|
|
const facade = createMockFacade()
|
|
await facade.project.resource.put(new Uint8Array([1, 2, 3]), 'application/octet-stream')
|
|
const report = await facade.project.resource.sweep()
|
|
assert.equal(report.inspectedRecords, 1)
|
|
assert.equal(report.deletedFiles, 0)
|
|
assert.match(report.warnings[0], /no separately enumerable orphan files/)
|
|
})
|
|
|
|
test('Part Design feature tasks commit a document object and remain undoable', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.app.document.getActive()
|
|
facade.gui.command.execute({ commandId: 'pad' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'pad')
|
|
facade.task.update({ length: 55, reversed: true, midplane: true })
|
|
facade.task.apply()
|
|
|
|
const committed = facade.app.document.getActive()
|
|
const created = committed.tree.find((item) => item.id === 'pad001')
|
|
assert.equal(created?.label, 'Pad')
|
|
assert.equal(committed.version, before.version + 1)
|
|
assert.equal(facade.selection.getObjectId(), 'pad001')
|
|
assert.equal(committed.dirty, true)
|
|
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Length')?.value, 55)
|
|
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Reversed')?.value, true)
|
|
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Midplane')?.value, true)
|
|
assert.equal(committed.objects.find((object) => object.id === 'body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
|
|
assert.ok(committed.recompute?.dirtyObjects.includes('pad001'))
|
|
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
|
|
|
|
facade.gui.command.execute({ commandId: 'revolution' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'revolution')
|
|
facade.task.apply()
|
|
const revolution = facade.app.document.getObject('revolution')
|
|
assert.equal(revolution?.typeId, 'PartDesign::Revolution')
|
|
assert.equal(revolution?.properties.find((property) => property.name === 'Angle')?.value, 360)
|
|
facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 })
|
|
assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
|
|
|
facade.gui.command.execute({ commandId: 'groove' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'groove')
|
|
facade.task.update({ angle: 120 })
|
|
facade.task.apply()
|
|
const groove = facade.app.document.getObject('groove')
|
|
assert.equal(groove?.typeId, 'PartDesign::Groove')
|
|
assert.equal(groove?.properties.find((property) => property.name === 'Angle')?.value, 120)
|
|
assert.equal(groove?.properties.find((property) => property.name === 'Base')?.value, 'revolution')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'groove')
|
|
|
|
facade.gui.command.execute({ commandId: 'linear-pattern' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'linear-pattern')
|
|
facade.task.update({ occurrences: 4, length: 30, direction: 'Vertical', direction2: 'Horizontal', occurrences2: 2, length2: 10, reversed2: true })
|
|
facade.task.apply()
|
|
const pattern = facade.app.document.getObject('linear-pattern')
|
|
assert.equal(pattern?.typeId, 'PartDesign::LinearPattern')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Base')?.value, 'groove')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Occurrences')?.value, 4)
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Length')?.value, 30)
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Direction')?.value, 'Vertical')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Mode')?.value, 'Extent')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Direction2')?.value, 'Horizontal')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Occurrences2')?.value, 2)
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Length2')?.value, 10)
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Reversed2')?.value, true)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'linear-pattern')
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'linear-pattern', propertyName: 'Occurrences', value: 1 }), /between 2 and 100/)
|
|
|
|
facade.gui.command.execute({ commandId: 'linear-pattern' })
|
|
facade.task.update({ occurrences: 1, length: 0 })
|
|
assert.throws(() => facade.task.apply(), /between 2 and 100/)
|
|
assert.equal(facade.app.document.getObject('linear-pattern001'), null)
|
|
facade.task.cancel()
|
|
|
|
facade.gui.command.execute({ commandId: 'polar-pattern' })
|
|
facade.task.update({ occurrences: 4, angle: 180, axis: 'Normal', mode: 'Spacing', offset: 45, reversed: true })
|
|
facade.task.apply()
|
|
const polarPattern = facade.app.document.getObject('polar-pattern')
|
|
assert.equal(polarPattern?.typeId, 'PartDesign::PolarPattern')
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Base')?.value, 'linear-pattern')
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Occurrences')?.value, 4)
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Axis')?.value, 'Normal')
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Mode')?.value, 'Spacing')
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Offset')?.value, 45)
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Reversed')?.value, true)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'polar-pattern')
|
|
|
|
facade.gui.command.execute({ commandId: 'hole' })
|
|
facade.task.update({ diameter: 6, depth: 12, type: 'Through all', position: { x: 2, y: 3, z: 1 }, direction: { x: 0, y: 0, z: -1 }, reversed: true, holeCutType: 'Counterbore', holeCutDiameter: 10, holeCutDepth: 2, threaded: false })
|
|
facade.task.apply()
|
|
const hole = facade.app.document.getObject('hole')
|
|
assert.equal(hole?.typeId, 'PartDesign::Hole')
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Base')?.value, 'polar-pattern')
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Diameter')?.value, 6)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Depth')?.value, 12)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Type')?.value, 'Through all')
|
|
assert.deepEqual(hole?.properties.find((property) => property.name === 'Position')?.value, { x: 2, y: 3, z: 1 })
|
|
assert.deepEqual(hole?.properties.find((property) => property.name === 'Direction')?.value, { x: 0, y: 0, z: -1 })
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Reversed')?.value, true)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'HoleCutType')?.value, 'Counterbore')
|
|
assert.equal(hole?.properties.find((property) => property.name === 'HoleCutDiameter')?.value, 10)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'HoleCutDepth')?.value, 2)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'DepthType')?.value, 'ThroughAll')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'hole')
|
|
|
|
assert.equal(facade.gui.command.getState('mirrored').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'mirrored' })
|
|
facade.task.update({ plane: 'XZ plane', fuse: false })
|
|
facade.task.apply()
|
|
const mirrored = facade.app.document.getObject('mirrored')
|
|
assert.equal(mirrored?.typeId, 'PartDesign::Mirrored')
|
|
assert.equal(mirrored?.properties.find((property) => property.name === 'Base')?.value, 'hole')
|
|
assert.equal(mirrored?.properties.find((property) => property.name === 'Plane')?.value, 'XZ plane')
|
|
assert.equal(mirrored?.properties.find((property) => property.name === 'Fuse')?.value, false)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'mirrored')
|
|
})
|
|
|
|
test('Part workbench commands create primitive and boolean document objects', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('sketch001')?.typeId, 'Sketcher::SketchObject')
|
|
facade.gui.workbench.setActive('Part')
|
|
facade.selection.select('pad')
|
|
assert.equal(facade.gui.command.getState('extrude-part').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('extrude-part').reason || '', /Sketcher profile/)
|
|
facade.selection.select('sketch')
|
|
facade.app.sketcher.addGeometry('sketch', { id: 'extrude-edge-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 2, y: 0 } })
|
|
facade.app.sketcher.addGeometry('sketch', { id: 'extrude-edge-2', type: 'line', start: { x: 2, y: 0 }, end: { x: 2, y: 1 } })
|
|
facade.app.sketcher.addGeometry('sketch', { id: 'extrude-edge-3', type: 'line', start: { x: 2, y: 1 }, end: { x: 0, y: 1 } })
|
|
facade.app.sketcher.addGeometry('sketch', { id: 'extrude-edge-4', type: 'line', start: { x: 0, y: 1 }, end: { x: 0, y: 0 } })
|
|
assert.equal(facade.gui.command.getState('extrude-part').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'extrude-part' })
|
|
facade.task.update({ lengthfwd: 16, dir: { x: 0, y: 0, z: 2 }, symmetric: true })
|
|
facade.task.apply()
|
|
const extrude = facade.app.document.getObject('extrude')
|
|
assert.equal(extrude?.typeId, 'Part::Extrusion')
|
|
assert.equal(extrude?.properties.find((property) => property.name === 'TypeId')?.value, 'Part::Extrusion')
|
|
assert.equal(extrude?.properties.find((property) => property.name === 'Base')?.value, 'sketch')
|
|
assert.equal(extrude?.properties.find((property) => property.name === 'LengthFwd')?.value, 16)
|
|
assert.deepEqual(extrude?.properties.find((property) => property.name === 'Dir')?.value, { x: 0, y: 0, z: 2 })
|
|
assert.equal(extrude?.properties.find((property) => property.name === 'Symmetric')?.value, true)
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'extrude').map((edge) => edge.targetId), ['sketch'])
|
|
|
|
facade.selection.select('sketch')
|
|
assert.equal(facade.gui.command.getState('revolution-part').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'revolution-part' })
|
|
facade.task.update({ angle: 225, base: { x: 1, y: 2, z: 3 }, axis: { x: 0, y: 1, z: 0 } })
|
|
facade.task.apply()
|
|
const revolution = facade.app.document.getObject('revolution')
|
|
assert.equal(revolution?.typeId, 'Part::Revolution')
|
|
assert.equal(revolution?.properties.find((property) => property.name === 'TypeId')?.value, 'Part::Revolution')
|
|
assert.equal(revolution?.properties.find((property) => property.name === 'Source')?.value, 'sketch')
|
|
assert.equal(revolution?.properties.find((property) => property.name === 'Angle')?.value, 225)
|
|
assert.deepEqual(revolution?.properties.find((property) => property.name === 'Base')?.value, { x: 1, y: 2, z: 3 })
|
|
assert.deepEqual(revolution?.properties.find((property) => property.name === 'Axis')?.value, { x: 0, y: 1, z: 0 })
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'revolution').map((edge) => edge.targetId), ['sketch'])
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
assert.equal(facade.getState().document.tree.find((item) => item.id === 'body')?.children?.includes('extrude'), false)
|
|
assert.equal(facade.getState().document.tree.find((item) => item.id === 'body')?.children?.includes('revolution'), false)
|
|
|
|
facade.selection.select('sketch')
|
|
facade.gui.command.execute({ commandId: 'loft-part' })
|
|
assert.throws(() => facade.task.apply(), /at least two section sketches/)
|
|
facade.task.update({ sections: ['sketch', 'sketch001'], ruled: true })
|
|
facade.task.apply()
|
|
const loft = facade.app.document.getObject('loft')
|
|
assert.equal(loft?.typeId, 'Part::Loft')
|
|
assert.deepEqual(loft?.properties.find((property) => property.name === 'Sections')?.value, ['sketch', 'sketch001'])
|
|
assert.equal(loft?.properties.find((property) => property.name === 'Ruled')?.value, true)
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'loft').map((edge) => edge.targetId), ['sketch', 'sketch001'])
|
|
|
|
facade.selection.select('sketch')
|
|
facade.gui.command.execute({ commandId: 'sweep-part' })
|
|
assert.throws(() => facade.task.apply(), /Spine must reference/)
|
|
facade.task.update({ spine: 'sketch001' })
|
|
facade.task.apply()
|
|
const sweep = facade.app.document.getObject('sweep')
|
|
assert.equal(sweep?.typeId, 'Part::Sweep')
|
|
assert.deepEqual(sweep?.properties.find((property) => property.name === 'Sections')?.value, ['sketch'])
|
|
assert.deepEqual(sweep?.properties.find((property) => property.name === 'Spine')?.value, { schemaVersion: 1, objectId: 'sketch001', subElements: [] })
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'sweep').map((edge) => edge.targetId).sort(), ['sketch', 'sketch001'])
|
|
|
|
assert.equal(facade.gui.command.getState('primitive').status, 'enabled')
|
|
assert.equal(facade.gui.command.getState('check-shape').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'check-shape' })
|
|
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'SHAPE_NOT_RECOMPUTED')
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('box')?.typeId, 'Part::Box')
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.update({ primitiveType: 'Cylinder', radius: 3, height: 12, angle: 180 })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('cylinder')?.typeId, 'Part::Cylinder')
|
|
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Radius')?.value, 3)
|
|
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Height')?.value, 12)
|
|
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
|
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.update({ primitiveType: 'Torus', radius1: 12, radius2: 2.5, angle3: 270 })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('torus')?.typeId, 'Part::Torus')
|
|
assert.equal(facade.app.document.getObject('torus')?.properties.find((property) => property.name === 'Radius1')?.value, 12)
|
|
assert.equal(facade.app.document.getObject('torus')?.properties.find((property) => property.name === 'Radius2')?.value, 2.5)
|
|
assert.equal(facade.app.document.getObject('torus')?.properties.find((property) => property.name === 'Angle3')?.value, 270)
|
|
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.update({ primitiveType: 'Prism', polygon: 8, circumradius: 4, height: 15, firstAngle: 7, secondAngle: -3 })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('prism')?.typeId, 'Part::Prism')
|
|
assert.equal(facade.app.document.getObject('prism')?.properties.find((property) => property.name === 'Polygon')?.value, 8)
|
|
assert.equal(facade.app.document.getObject('prism')?.properties.find((property) => property.name === 'Circumradius')?.value, 4)
|
|
assert.equal(facade.app.document.getObject('prism')?.properties.find((property) => property.name === 'Height')?.value, 15)
|
|
assert.equal(facade.app.document.getObject('prism')?.properties.find((property) => property.name === 'FirstAngle')?.value, 7)
|
|
assert.equal(facade.app.document.getObject('prism')?.properties.find((property) => property.name === 'SecondAngle')?.value, -3)
|
|
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.update({ primitiveType: 'Wedge', xmin: -1, ymin: 2, zmin: 0, z2min: 1, x2min: 0, xmax: 9, ymax: 12, zmax: 10, z2max: 7, x2max: 6 })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('wedge')?.typeId, 'Part::Wedge')
|
|
assert.equal(facade.app.document.getObject('wedge')?.properties.find((property) => property.name === 'Xmin')?.value, -1)
|
|
assert.equal(facade.app.document.getObject('wedge')?.properties.find((property) => property.name === 'Z2max')?.value, 7)
|
|
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.update({ primitiveType: 'Ellipsoid', radius1: 2, radius2: 4, radius3: 3, angle1: -90, angle2: 90, angle3: 360 })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('ellipsoid')?.typeId, 'Part::Ellipsoid')
|
|
assert.equal(facade.app.document.getObject('ellipsoid')?.properties.find((property) => property.name === 'Radius1')?.value, 2)
|
|
assert.equal(facade.app.document.getObject('ellipsoid')?.properties.find((property) => property.name === 'Radius2')?.value, 4)
|
|
assert.equal(facade.app.document.getObject('ellipsoid')?.properties.find((property) => property.name === 'Radius3')?.value, 3)
|
|
assert.equal(facade.app.document.getObject('ellipsoid')?.properties.find((property) => property.name === 'Angle3')?.value, 360)
|
|
|
|
assert.equal(facade.gui.command.getState('union').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'union' })
|
|
facade.task.update({ base: 'box', tool: 'cylinder' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('union')?.typeId, 'Part::Fuse')
|
|
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Base')?.value, 'box')
|
|
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Tool')?.value, 'cylinder')
|
|
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Refine')?.value, false)
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'union').map((edge) => edge.targetId).sort(), ['box', 'cylinder'])
|
|
facade.selection.select('origin')
|
|
assert.equal(facade.gui.command.getState('union').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('union').reason || '', /solid or shape-producing/)
|
|
assert.equal(facade.gui.command.getState('check-shape').status, 'disabled')
|
|
})
|
|
|
|
test('Datum and ShapeBinder stay in the active Body without stealing its Tip and survive FCStd metadata projection', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'create-body' })
|
|
facade.task.apply()
|
|
|
|
const initial = facade.app.document.getActive()
|
|
assert.equal(initial.tree.find((item) => item.id === 'body')?.state, 'valid')
|
|
assert.equal(initial.tree.find((item) => item.id === 'body001')?.state, 'active')
|
|
assert.equal(facade.app.document.getObject('body001')?.properties.find((property) => property.name === 'Tip')?.value, null)
|
|
|
|
facade.gui.command.execute({ commandId: 'datum' })
|
|
facade.task.update({ datumType: 'Line' })
|
|
facade.task.apply()
|
|
const datum = facade.app.document.getObject('datum-line')
|
|
assert.equal(datum?.typeId, 'PartDesign::Line')
|
|
assert.equal(datum?.properties.find((property) => property.name === 'DatumType')?.value, 'Line')
|
|
assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'body001')?.children?.includes('datum-line'), true)
|
|
assert.equal(facade.app.document.getObject('body001')?.properties.find((property) => property.name === 'Tip')?.value, null)
|
|
|
|
facade.selection.select('pad')
|
|
facade.gui.command.execute({ commandId: 'shape-binder' })
|
|
facade.task.apply()
|
|
const binder = facade.app.document.getObject('shape-binder')
|
|
assert.equal(binder?.typeId, 'PartDesign::ShapeBinder')
|
|
assert.deepEqual(binder?.properties.find((property) => property.name === 'Support')?.value, { schemaVersion: 1, objectId: 'pad', subElements: [] })
|
|
assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'body001')?.children?.includes('shape-binder'), true)
|
|
assert.equal(facade.app.document.getObject('body001')?.properties.find((property) => property.name === 'Tip')?.value, null)
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'shape-binder').map((edge) => edge.targetId), ['pad'])
|
|
|
|
const inspection = inspectFcstdArchive(serializeFcstdMetadataArchive(facade.app.document.getActive()))
|
|
assert.equal(inspection.objects.find((object) => object.name === 'datum-line')?.support, 'recognized')
|
|
assert.equal(inspection.objects.find((object) => object.name === 'shape-binder')?.support, 'recognized')
|
|
facade.geometry.dispose()
|
|
})
|
|
|
|
test('Part Design loft and pipe tasks preserve ordered Body Tip bases', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
facade.task.apply()
|
|
|
|
facade.selection.select('sketch')
|
|
facade.gui.command.execute({ commandId: 'additive-loft' })
|
|
facade.task.update({ sections: ['sketch', 'sketch001'] })
|
|
facade.task.apply()
|
|
const additiveLoft = facade.app.document.getObject('additive-loft')
|
|
assert.equal(additiveLoft?.typeId, 'PartDesign::AdditiveLoft')
|
|
assert.equal(additiveLoft?.properties.find((property) => property.name === 'Profile')?.value, 'sketch')
|
|
assert.deepEqual(additiveLoft?.properties.find((property) => property.name === 'Sections')?.value, ['sketch001'])
|
|
assert.equal(additiveLoft?.properties.find((property) => property.name === 'Base')?.value, 'fillet')
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'additive-loft').map((edge) => edge.targetId).sort(), ['fillet', 'sketch', 'sketch001'])
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'additive-loft')
|
|
|
|
facade.selection.select('sketch')
|
|
facade.gui.command.execute({ commandId: 'subtractive-loft' })
|
|
facade.task.update({ sections: ['sketch', 'sketch001'] })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('subtractive-loft')?.typeId, 'PartDesign::SubtractiveLoft')
|
|
assert.equal(facade.app.document.getObject('subtractive-loft')?.properties.find((property) => property.name === 'Base')?.value, 'additive-loft')
|
|
|
|
facade.selection.select('sketch')
|
|
facade.gui.command.execute({ commandId: 'additive-pipe' })
|
|
facade.task.update({ spine: 'sketch001' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('additive-pipe')?.typeId, 'PartDesign::AdditivePipe')
|
|
assert.equal(facade.app.document.getObject('additive-pipe')?.properties.find((property) => property.name === 'Base')?.value, 'subtractive-loft')
|
|
assert.equal(facade.app.document.getObject('additive-pipe')?.properties.find((property) => property.name === 'Profile')?.value, 'sketch')
|
|
assert.deepEqual(facade.app.document.getObject('additive-pipe')?.properties.find((property) => property.name === 'Spine')?.value, { schemaVersion: 1, objectId: 'sketch001', subElements: [] })
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'additive-pipe').map((edge) => edge.targetId).sort(), ['sketch', 'sketch001', 'subtractive-loft'])
|
|
|
|
facade.selection.select('sketch')
|
|
facade.gui.command.execute({ commandId: 'subtractive-pipe' })
|
|
facade.task.update({ spine: 'sketch001' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('subtractive-pipe')?.typeId, 'PartDesign::SubtractivePipe')
|
|
assert.equal(facade.app.document.getObject('subtractive-pipe')?.properties.find((property) => property.name === 'Base')?.value, 'additive-pipe')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'subtractive-pipe')
|
|
})
|
|
|
|
test('Part dress-up commands persist a stable selected edge without changing Body Tip', async () => {
|
|
const facade = createMockFacade()
|
|
const document = facade.getState().document
|
|
const source = createEdgeSubshapeRefs('pad-shape', 2, [{ vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }])
|
|
const pad = document.objects.find((object) => object.id === 'pad')
|
|
assert.ok(pad)
|
|
pad.topology = {
|
|
shapeId: 'pad-shape',
|
|
documentVersion: document.version,
|
|
generation: 2,
|
|
entries: source.refs.map((ref, index) => ({ ref: { ...ref, status: 'stable' }, signature: source.signatures[index] })),
|
|
migration: { previousGeneration: 1, matches: source.refs.map((ref) => ({ current: { ...ref, status: 'stable' }, score: 1, status: 'stable' as const })) },
|
|
history: captureSignatureTopologyHistory('pad:2', [], source.refs.map((ref, index) => ({ ref: { ...ref, status: 'stable' }, signature: source.signatures[index] }))),
|
|
}
|
|
await facade.project.save(document)
|
|
await facade.app.document.load(document.id)
|
|
facade.gui.workbench.setActive('Part')
|
|
facade.selection.select('pad')
|
|
assert.equal(facade.gui.command.getState('fillet-part').status, 'disabled')
|
|
facade.selection.selectSubshape({ objectId: 'pad', kind: 'edge', persistentId: source.refs[1].persistentId })
|
|
assert.equal(facade.gui.command.getState('fillet-part').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'fillet-part' })
|
|
facade.task.update({ radius: 0.8 })
|
|
facade.task.apply()
|
|
const fillet = facade.app.document.getObject('fillet001')
|
|
assert.equal(fillet?.typeId, 'Part::Fillet')
|
|
assert.equal(fillet?.properties.find((property) => property.name === 'Radius')?.value, 0.8)
|
|
const filletBase = fillet?.properties.find((property) => property.name === 'Base')?.value
|
|
assert.equal(typeof filletBase === 'object' && filletBase !== null && 'persistentId' in filletBase ? filletBase.persistentId : null, source.refs[1].persistentId)
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'fillet001').map((edge) => edge.targetId), ['pad'])
|
|
|
|
facade.selection.selectSubshape({ objectId: 'pad', kind: 'edge', persistentId: source.refs[0].persistentId })
|
|
facade.gui.command.execute({ commandId: 'chamfer-part' })
|
|
facade.task.update({ size: 0.6 })
|
|
facade.task.apply()
|
|
const chamfer = facade.app.document.getObject('chamfer')
|
|
assert.equal(chamfer?.typeId, 'Part::Chamfer')
|
|
assert.equal(chamfer?.properties.find((property) => property.name === 'Size')?.value, 0.6)
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'chamfer').map((edge) => edge.targetId), ['pad'])
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
assert.equal(facade.getState().document.tree.find((item) => item.id === 'body')?.children?.includes('fillet001'), false)
|
|
assert.equal(facade.getState().document.tree.find((item) => item.id === 'body')?.children?.includes('chamfer'), false)
|
|
|
|
facade.gui.workbench.setActive('Part Design')
|
|
facade.selection.selectSubshape({ objectId: 'pad', kind: 'edge', persistentId: source.refs[1].persistentId })
|
|
facade.gui.command.execute({ commandId: 'fillet' })
|
|
facade.task.update({ radius: 0.4 })
|
|
facade.task.apply()
|
|
const partDesignFillet = facade.app.document.getObject('fillet002')
|
|
const partDesignBase = partDesignFillet?.properties.find((property) => property.name === 'Base')?.value
|
|
assert.equal(partDesignFillet?.typeId, 'PartDesign::Fillet')
|
|
assert.equal(typeof partDesignBase === 'object' && partDesignBase !== null && 'persistentId' in partDesignBase ? partDesignBase.persistentId : null, source.refs[1].persistentId)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet002')
|
|
})
|
|
|
|
test('Draft and Thickness use Bitbybit contracts with explicit unsupported diagnostics', async () => {
|
|
const makeShape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-dressup', documentVersion: 1 })
|
|
const calls: string[] = []
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => makeShape('box'), createCylinder: async () => makeShape('cylinder'), createSphere: async () => makeShape('sphere'), createCone: async () => makeShape('cone'),
|
|
applyPlacement: async (input) => input.shape, union: async () => makeShape('union'), cut: async () => makeShape('cut'), intersection: async () => makeShape('common'),
|
|
pad: async () => makeShape('pad'), pocket: async () => makeShape('pocket'), revolution: async () => makeShape('revolution'),
|
|
fillet: async () => makeShape('fillet'), chamfer: async () => makeShape('chamfer'),
|
|
draft: async (input) => { calls.push(`draft:${input.angle}:${input.indexes?.join(',') ?? 'all'}:${input.direction.join(',')}`); return makeShape('draft') },
|
|
thickness: async (input) => { calls.push(`thickness:${input.offset}`); return makeShape('thickness') }, release: async () => undefined,
|
|
}
|
|
const faceMesh = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const faceSource = createSubshapeRefs('base-shape', 1, [faceMesh])
|
|
const face = { ...faceSource.refs[0], status: 'stable' as const }
|
|
const base: DocumentObjectSnapshot = { id: 'base', typeId: 'Part::Box', properties: [], topology: {
|
|
shapeId: 'base-shape', documentVersion: 1, generation: 1,
|
|
entries: [{ ref: face, signature: faceSource.signatures[0] }],
|
|
migration: { previousGeneration: null, matches: [{ current: face, score: 1, status: 'new' as const }] },
|
|
history: captureSignatureTopologyHistory('base:1', [], [{ ref: face, signature: faceSource.signatures[0] }]),
|
|
} }
|
|
const topoRef = { schemaVersion: 1 as const, objectId: 'base', kind: 'face' as const, persistentId: face.persistentId, topologyVersion: 1, generation: 1, status: 'stable' as const }
|
|
const draft: DocumentObjectSnapshot = { id: 'draft', typeId: 'PartDesign::Draft', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Draft', scope: 'data', type: 'App::PropertyLinkSub', value: topoRef },
|
|
{ name: 'Angle', label: 'Angle', group: 'Draft', scope: 'data', type: 'App::PropertyAngle', value: 7 },
|
|
{ name: 'Direction', label: 'Direction', group: 'Draft', scope: 'data', type: 'App::PropertyVector', value: { x: 1, y: 0, z: 0 } },
|
|
{ name: 'NeutralPlaneOrigin', label: 'Origin', group: 'Draft', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 0 } },
|
|
{ name: 'NeutralPlaneDirection', label: 'Normal', group: 'Draft', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 } },
|
|
] }
|
|
const thickness: DocumentObjectSnapshot = { id: 'thickness', typeId: 'PartDesign::Thickness', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Thickness', scope: 'data', type: 'App::PropertyLink', value: 'base' },
|
|
{ name: 'RemoveFaces', label: 'Remove faces', group: 'Thickness', scope: 'data', type: 'App::PropertyLinkSub', value: topoRef },
|
|
{ name: 'Value', label: 'Value', group: 'Thickness', scope: 'data', type: 'App::PropertyLength', value: 1.25 },
|
|
{ name: 'Join', label: 'Join', group: 'Thickness', scope: 'data', type: 'App::PropertyEnumeration', value: 'Arc', options: ['Arc', 'Intersection'] },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-dressup', version: 1, objects: [base, draft, thickness] }
|
|
const shapes = new Map<string, ShapeHandle>([['base', makeShape('base-shape')]])
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
|
assert.equal((await executor(draft, document, context)).status, 'success')
|
|
assert.equal(calls[0], 'draft:7:0:1,0,0')
|
|
assert.equal((await executor(thickness, document, context)).status, 'success')
|
|
assert.equal(calls[1], 'thickness:1.25')
|
|
const reversedThickness: DocumentObjectSnapshot = { ...thickness, id: 'thickness-reversed', properties: [...thickness.properties, { name: 'Reversed', label: 'Reversed', group: 'Thickness', scope: 'data', type: 'App::PropertyBool', value: true }] }
|
|
assert.equal((await executor(reversedThickness, document, context)).status, 'success')
|
|
assert.equal(calls[2], 'thickness:-1.25')
|
|
const missingFaces = { ...thickness, id: 'thickness-remove-missing', properties: thickness.properties.map((property) => property.name === 'RemoveFaces' ? { ...property, value: null } : property) }
|
|
const removeResult = await executor(missingFaces, document, context)
|
|
assert.equal(removeResult.errors?.[0].code, 'THICKNESS_FACE_SELECTION_MISSING')
|
|
const intersectionJoin = { ...thickness, id: 'thickness-intersection', properties: thickness.properties.map((property) => property.name === 'Join' ? { ...property, value: 'Tangent' } : property) }
|
|
const joinResult = await executor(intersectionJoin, document, context)
|
|
assert.equal(joinResult.errors?.[0].code, 'THICKNESS_JOIN_UNSUPPORTED')
|
|
const missingFace = { ...draft, id: 'draft-missing', properties: draft.properties.map((property) => property.name === 'Base' ? { ...property, value: { ...topoRef, persistentId: 'missing-face' } } : property) }
|
|
const missingResult = await executor(missingFace, document, context)
|
|
assert.equal(missingResult.errors?.[0].code, 'DRAFT_FACE_REFERENCE_MISSING')
|
|
})
|
|
|
|
test('Part Design Draft and Thickness commands expose stable task properties', async () => {
|
|
const facade = createMockFacade()
|
|
const document = facade.getState().document
|
|
const pad = document.objects.find((object) => object.id === 'pad')
|
|
assert.ok(pad)
|
|
const faceMesh = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const faceSource = createSubshapeRefs('pad-shape', 2, [faceMesh])
|
|
const face = { ...faceSource.refs[0], status: 'stable' as const }
|
|
pad.topology = { shapeId: 'pad-shape', documentVersion: document.version, generation: 2, entries: [{ ref: face, signature: faceSource.signatures[0] }], migration: { previousGeneration: 1, matches: [{ current: face, score: 1, status: 'stable' as const }] }, history: captureSignatureTopologyHistory('pad:2', [], [{ ref: face, signature: faceSource.signatures[0] }]) }
|
|
await facade.project.save(document)
|
|
await facade.app.document.load(document.id)
|
|
facade.gui.workbench.setActive('Part Design')
|
|
facade.selection.select('pad')
|
|
assert.equal(facade.gui.command.getState('draft').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('draft').reason || '', /stable face/)
|
|
assert.equal(facade.gui.command.getState('thickness').status, 'disabled')
|
|
facade.selection.selectSubshape({ objectId: 'pad', kind: 'face', persistentId: face.persistentId })
|
|
assert.equal(facade.gui.command.getState('thickness').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'thickness' })
|
|
facade.task.update({ value: 1.5 })
|
|
facade.task.apply()
|
|
const thickness = facade.app.document.getObject('thickness')
|
|
assert.equal(thickness?.typeId, 'PartDesign::Thickness')
|
|
assert.equal(thickness?.properties.find((property) => property.name === 'Value')?.value, 1.5)
|
|
assert.equal(thickness?.properties.find((property) => property.name === 'Base')?.value, 'pad')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'thickness')
|
|
})
|
|
|
|
test('PartDesign parametric lifecycle keeps Shape cache, Body Tip and Support coherent', async () => {
|
|
const facade = createMockFacade()
|
|
const geometryReady = (await facade.geometry.initialize()).status === 'ready'
|
|
const assertShapesPresent = () => { if (geometryReady) assert.ok(facade.geometry.capabilities().shapeCount > 0) }
|
|
const supportBefore = facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'Support')?.value
|
|
const initial = await facade.app.document.recomputeAsync()
|
|
assert.equal(initial.status, 'completed')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
assertShapesPresent()
|
|
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 48 })
|
|
const edited = await facade.app.document.recomputeAsync()
|
|
assert.equal(edited.status, 'completed')
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 48)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
assertShapesPresent()
|
|
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42)
|
|
if (geometryReady) assert.equal(facade.geometry.capabilities().shapeCount, 0)
|
|
const restored = await facade.app.document.recomputeAsync()
|
|
assert.equal(restored.status, 'completed')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
assert.deepEqual(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'Support')?.value, supportBefore)
|
|
|
|
await facade.project.save()
|
|
const persisted = await facade.project.load('doc-pump-housing')
|
|
assert.equal(persisted?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 42)
|
|
assert.equal(persisted?.objects.find((object) => object.id === 'body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
await facade.app.document.load('doc-pump-housing')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
assert.deepEqual(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'Support')?.value, supportBefore)
|
|
assertShapesPresent()
|
|
facade.geometry.dispose()
|
|
if (geometryReady) assert.equal(facade.geometry.capabilities().shapeCount, 0)
|
|
})
|
|
|
|
test('PartDesign structural lifecycle rejects invalid reorder and makes deletion undoable', async () => {
|
|
const facade = createMockFacade()
|
|
const initialChildren = facade.app.document.getActive().tree.find((item) => item.id === 'body')?.children
|
|
assert.throws(() => facade.app.document.reorderBodyFeature({ bodyId: 'body', objectId: 'pocket', beforeObjectId: 'pad' }), /dependency/)
|
|
assert.deepEqual(facade.app.document.getActive().tree.find((item) => item.id === 'body')?.children, initialChildren)
|
|
assert.throws(() => facade.app.document.removeObject({ objectId: 'pad' }), /dependent/)
|
|
assert.deepEqual(facade.app.document.removeObject({ objectId: 'fillet' }), ['fillet'])
|
|
assert.equal(facade.app.document.getObject('fillet'), null)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pocket')
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getObject('fillet')?.typeId, 'PartDesign::Fillet')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getObject('fillet'), null)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pocket')
|
|
})
|