feat: align FreeCAD property status semantics
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled

This commit is contained in:
2026-08-14 17:56:41 -04:00
parent f64b78865c
commit e3373c9d6c
28 changed files with 811 additions and 61 deletions

View File

@@ -1,7 +1,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { strToU8, unzipSync, zipSync } from 'fflate'
import { createMockFacade } from '../src/facade/mockFacade'
import { createMockFacade, createWebCadFacade } 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'
@@ -30,6 +30,7 @@ import { resolveMeshSubshape, resolveScreenBoxSelection } from '../src/facade/th
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 { decodeFreecadPropertyStatus, encodeFreecadPropertyStatus, freecadPropertyRecomputeEffect, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyHidden, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyReadOnly, isFreecadPropertyRecomputeSuppressed, normalizeFreecadPropertyStatusMask } from '../src/facade/propertyStatus'
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'
@@ -4496,6 +4497,83 @@ test('FCStd metadata writer round-trips Document and GuiDocument while preservin
assert.throws(() => serializeFcstdMetadataArchive(document, { opaqueEntries: { 'Document.xml': new Uint8Array() } }), /reserved path/)
})
test('FreeCAD Property status masks preserve all source bits and enforce native touch behavior', () => {
const mask = encodeFreecadPropertyStatus(['Immutable', 'Hidden', 'Output', 'NoModify', 'NoRecompute', 'Ordered', 'PropNoPersist', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'User4'])
assert.deepEqual(decodeFreecadPropertyStatus(mask), {
mask,
names: ['Immutable', 'Hidden', 'Output', 'NoModify', 'NoRecompute', 'Ordered', 'PropNoPersist', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'User4'],
unknownBits: [],
})
assert.equal(isFreecadPropertyReadOnly(mask), true)
assert.equal(isFreecadPropertyHidden(mask), true)
assert.equal(isFreecadPropertyRecomputeSuppressed(mask), true)
assert.equal(isFreecadPropertyPersistenceSuppressed(mask), true)
assert.equal(isFreecadPropertyDocumentModifiedSuppressed(mask), true)
assert.equal(freecadPropertyRecomputeEffect(encodeFreecadPropertyStatus(['NoRecompute'])), 'owner')
assert.equal(freecadPropertyRecomputeEffect(encodeFreecadPropertyStatus(['PropNoRecompute'])), 'dependents')
assert.equal(freecadPropertyRecomputeEffect(encodeFreecadPropertyStatus(['Output'])), 'none')
assert.throws(() => normalizeFreecadPropertyStatusMask(-1), /unsigned 32-bit/)
assert.throws(() => normalizeFreecadPropertyStatusMask(0x1_0000_0000), /unsigned 32-bit/)
const document = recomputeDocumentFixture()
document.dirty = false
document.tree = [{ id: 'Box', label: 'Box', type: 'feature', state: 'up-to-date' }]
document.objects = [{ id: 'Box', typeId: 'Part::Box', properties: [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, recompute: true, nativeStatus: encodeFreecadPropertyStatus(['NoModify', 'Output']) },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, recompute: true, nativeStatus: encodeFreecadPropertyStatus(['PropReadOnly']) },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, recompute: true, nativeStatus: encodeFreecadPropertyStatus(['PropNoRecompute']) },
] }, { id: 'Follower', typeId: 'Part::Feature', properties: [
{ name: 'Base', label: 'Base', group: 'Base', scope: 'data', type: 'App::PropertyLink', value: 'Box', recompute: true },
] }]
document.tree.push({ id: 'Follower', label: 'Follower', type: 'feature', state: 'up-to-date' })
document.dependencies = [{ sourceId: 'Follower', targetId: 'Box', relation: 'link', propertyName: 'Base' }]
document.recompute = { generation: 0, status: 'idle', objectStates: { Box: 'up-to-date', Follower: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] }
const facade = createWebCadFacade({ runtimeMode: 'mock', initialDocument: document })
facade.app.document.setProperty({ objectId: 'Box', propertyName: 'Length', value: 12 })
assert.equal(facade.app.document.getObject('Box')?.properties[0].value, 12)
assert.equal(facade.app.document.getActive().dirty, false)
assert.deepEqual(facade.app.document.getActive().recompute?.dirtyObjects, [])
assert.throws(() => facade.app.document.setProperty({ objectId: 'Box', propertyName: 'Width', value: 12 }), /read-only/)
facade.app.document.setProperty({ objectId: 'Box', propertyName: 'Height', value: 12 })
assert.deepEqual(facade.app.document.getActive().recompute?.dirtyObjects, ['Follower'])
facade.geometry.dispose()
})
test('FCStd Property status preserves transient definitions while NoPersist stays out of the archive', () => {
const nativeStatus = encodeFreecadPropertyStatus(['Ordered', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'PropOutput'])
const document = recomputeDocumentFixture()
document.tree = [{ id: 'Box', label: 'Box', type: 'feature' }]
document.objects = [{ id: 'Box', typeId: 'Part::Box', properties: [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 12.5, nativeStatus },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 3, nativeStatus: encodeFreecadPropertyStatus(['PropNoPersist']) },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 4, nativeStatus: encodeFreecadPropertyStatus(['Transient']) },
{ name: 'WebScratch', label: 'Web scratch', group: 'Web', scope: 'data', type: 'App::PropertyInteger', value: 9, nativeStatus: encodeFreecadPropertyStatus(['PropDynamic', 'PropTransient']) },
] }]
document.dependencies = []
const archive = serializeFcstdMetadataArchive(document)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, new RegExp(`<Property name="Length" type="App::PropertyLength" status="${nativeStatus}">`))
assert.doesNotMatch(documentXml, /<Property name="Width"/)
assert.match(documentXml, new RegExp(`<_Property name="Height" type="App::PropertyLength" status="${encodeFreecadPropertyStatus(['Transient'])}"\/>`))
assert.match(documentXml, new RegExp(`<Property name="WebScratch" type="App::PropertyInteger"[^>]+status="${encodeFreecadPropertyStatus(['PropDynamic', 'PropTransient'])}"><\/Property>`))
assert.doesNotMatch(documentXml, /<Integer value="9"/)
const properties = inspectFcstdArchive(archive).objects[0].properties
assert.deepEqual(properties.find((property) => property.name === 'Length'), {
name: 'Length', typeId: 'App::PropertyLength', element: 'Float', value: '12.5', nativeStatus,
statusNames: ['Ordered', 'PropNoRecompute', 'PropReadOnly', 'PropHidden', 'PropOutput'],
})
assert.equal(properties.some((property) => property.name === 'Width'), false)
assert.deepEqual(properties.find((property) => property.name === 'Height'), {
name: 'Height', typeId: 'App::PropertyLength', element: '', value: '', nativeStatus: encodeFreecadPropertyStatus(['Transient']), statusNames: ['Transient'], transientMetadata: true,
})
assert.deepEqual(properties.find((property) => property.name === 'WebScratch'), {
name: 'WebScratch', typeId: 'App::PropertyInteger', element: '', value: '', nativeStatus: encodeFreecadPropertyStatus(['PropDynamic', 'PropTransient']), statusNames: ['PropDynamic', 'PropTransient'],
})
const invalidStatus = '<Document><Objects Count="1"><Object name="Box" type="Part::Box"/></Objects><ObjectData Count="1"><Object name="Box"><Properties Count="1"><Property name="Length" type="App::PropertyLength" status="4294967296"><Float value="1"/></Property></Properties></Object></ObjectData></Document>'
assert.throws(() => inspectFcstdArchive(zipSync({ 'Document.xml': strToU8(invalidStatus) })), /unsigned 32-bit/)
})
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 })