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

@@ -61,6 +61,7 @@ import {
import { menuDefinitions, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createLinuxcncIframeAdapter, createProductionFacade, createPumpHousingDemoDocument, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import type { LinkSubListValue, LinkSubValue, ShapeResourceValue, TopoRefValue } from './facade/types'
import { isFreecadPropertyHidden, isFreecadPropertyReadOnly } from './facade/propertyStatus'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId
@@ -968,7 +969,7 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) {
const object = facade.app.document.getObject(objectId)
if (!object) return <div className="properties-empty">No object selected</div>
const properties = object.properties.filter((property) => property.scope === scope && !property.hidden)
const properties = object.properties.filter((property) => property.scope === scope && !property.hidden && !isFreecadPropertyHidden(property.nativeStatus))
const groups = new Map<string, ObjectPropertySnapshot[]>()
properties.forEach((property) => groups.set(property.group, [...(groups.get(property.group) ?? []), property]))
return <div className="properties-scroll">{[...groups].map(([group, entries]) => <div className="property-group" key={group}><div className="property-group-title">{group}<ChevronDown size={14} /></div>{entries.map((property) => <PropertyEditor key={`${objectId}-${property.name}-${String(property.value)}`} facade={facade} objectId={objectId} property={property} showNotice={showNotice} />)}</div>)}</div>
@@ -986,7 +987,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
}
const formatted = `${String(property.value ?? '')}${property.unit ? ` ${property.unit}` : ''}`
let editor: ReactNode
if (property.readOnly) editor = <span className="property-readonly">{formatted}</span>
if (property.readOnly || isFreecadPropertyReadOnly(property.nativeStatus)) editor = <span className="property-readonly">{formatted}</span>
else if (property.type === 'App::PropertyBool') editor = <input className="property-checkbox" type="checkbox" checked={Boolean(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.checked)} />
else if (property.type === 'App::PropertyEnumeration') editor = <select className="property-control" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)}>{property.options?.map((option) => <option key={option}>{option}</option>)}</select>
else if (property.type === 'App::PropertyLink') {

View File

@@ -2,6 +2,8 @@ import { unzipSync, zipSync } from 'fflate'
import { XMLParser } from 'fast-xml-parser'
import { ATTACHMENT_MAP_MODES, validateAttachmentOffset, validateAttachmentSupport } from './attachment'
import { validateSketchSnapshot } from './sketcher'
import { decodeFreecadPropertyStatus, hasFreecadPropertyStatus, isFreecadPropertyNoPersist, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyTransient, normalizeFreecadPropertyStatusMask } from './propertyStatus'
import type { FreecadPropertyStatusName } from './propertyStatus'
import { migrateElementMap2Schema, parseElementMap2, writeElementMap2 } from './elementMap2'
import type { AnyElementMap2Document, ElementMap2Document } from './elementMap2'
import { migrateStringHasherSchema, parseStringHasherTable, validateElementMap2StringHasherEvidence, validateStringHasherTable, writeStringHasherTable } from './stringHasher'
@@ -36,6 +38,9 @@ export type FcstdPropertySummary = {
typeId: string
element: string
value: string
nativeStatus?: number
statusNames?: FreecadPropertyStatusName[]
transientMetadata?: boolean
subElements?: string[]
links?: string[]
linkSubs?: Array<{ objectId: string; subElement: string }>
@@ -936,7 +941,12 @@ const nativeLinkTopoRefName = (value: TopoRefValue, context: string, expectedObj
}
const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId?: string, topologyElementMap?: ElementMapSnapshot) => {
const attributes = native ? property.type === 'App::PropertyPlacement' ? ' status="8388608"' : '' : ` group="${xmlEscape(property.group ?? '')}" doc="${xmlEscape(property.label ?? property.name)}" attr="0" ro="${property.readOnly ? 1 : 0}" hide="${property.hidden ? 1 : 0}"`
if (!native && property.nativeStatus !== undefined && !hasFreecadPropertyStatus(property.nativeStatus, 'PropDynamic')) throw new Error(`FCStd dynamic property ${property.name} requires PropDynamic in its native Property status mask.`)
const defaultNativeStatus = property.type === 'Part::PropertyPartShape' ? 1 : property.type === 'App::PropertyPlacement' ? 8388608 : undefined
const nativeStatus = property.nativeStatus === undefined ? defaultNativeStatus : normalizeFreecadPropertyStatusMask(property.nativeStatus)
const statusAttribute = nativeStatus === undefined ? '' : ` status="${nativeStatus}"`
const attributes = native ? statusAttribute : ` group="${xmlEscape(property.group ?? '')}" doc="${xmlEscape(property.label ?? property.name)}" attr="0" ro="${property.readOnly ? 1 : 0}" hide="${property.hidden ? 1 : 0}"${statusAttribute}`
if (isFreecadPropertyTransient(nativeStatus)) return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}></Property>`
if (property.type === 'Path::PropertyPath') {
const value = pathPropertyValue(property.value, property.name)
const resourcePath = value.resourcePath ?? `${objectId ?? property.name}.nc`
@@ -1013,7 +1023,7 @@ const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId
if (!/\.Map\.txt$/i.test(mapResource)) throw new TypeError(`FCStd Shape property ${property.name} requires an ElementMap2 .Map.txt resource.`)
}
const elementMap2 = mapResource === undefined ? '' : `<ElementMap2 file="${xmlEscape(mapResource)}"/>`
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}" status="1"><Part${hasherIndexAttribute}${elementMap} file="${xmlEscape(property.value.path)}"/>${elementMapXml}${elementMap2}</Property>`
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Part${hasherIndexAttribute}${elementMap} file="${xmlEscape(property.value.path)}"/>${elementMapXml}${elementMap2}</Property>`
}
if (property.type === 'App::PropertyLinkSub') {
let objectId = ''
@@ -1081,6 +1091,7 @@ const expressionEngineXml = (object: DocumentObjectSnapshot) => {
}
const shapeResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (isFreecadPropertyPersistenceSuppressed(property.nativeStatus)) return []
if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('path' in property.value) || typeof property.value.path !== 'string') return []
return [property.value.path]
}))
@@ -1091,11 +1102,13 @@ const validateNativeShapeResourceName = (objectName: string, resourcePath: strin
}
const elementMapResourceReferences = (document: DocumentSnapshot): string[] => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (isFreecadPropertyPersistenceSuppressed(property.nativeStatus)) return []
if (property.type !== 'Part::PropertyPartShape' || !property.value || typeof property.value !== 'object' || Array.isArray(property.value) || !('elementMapResource' in property.value) || typeof property.value.elementMapResource !== 'string' || !property.value.elementMapResource.trim()) return []
return [property.value.elementMapResource]
}))
const pathResourceReferences = (document: DocumentSnapshot): Array<{ objectId: string; path: string; value: PathPropertyValue }> => document.objects.flatMap((object) => object.properties.flatMap((property) => {
if (isFreecadPropertyPersistenceSuppressed(property.nativeStatus)) return []
if (property.type !== 'Path::PropertyPath') return []
const value = pathPropertyValue(property.value, `${object.id}.${property.name}`)
const path = value.resourcePath ?? `${object.id}.nc`
@@ -1142,13 +1155,18 @@ export const serializeFcstdMetadataArchive = (document: DocumentSnapshot, option
const objectData = document.objects.map((object) => {
const sketchGeneratedPropertyNames = object.sketch ? new Set(['Geometry', 'Constraints', 'ExternalGeo', 'ExternalGeometry', 'ExternalTypes', 'WebGeometryIds', 'WebSyntheticConstraintIds', 'WebExternalIds', 'WebExternalProjectionIds', 'WebExternalSources']) : new Set<string>()
if (object.properties.some((property) => sketchGeneratedPropertyNames.has(property.name))) throw new Error(`FCStd native Sketch ${object.id} properties must come from object.sketch or its attachment codec, not duplicate property snapshots.`)
const properties = object.properties.filter((property) => property.scope !== 'view' && property.name !== 'ExpressionEngine' && !(object.sketch && sketchAttachmentPropertyNames.has(property.name)))
const persistedProperties = object.properties.filter((property) => property.scope !== 'view' && property.name !== 'ExpressionEngine' && !isFreecadPropertyNoPersist(property.nativeStatus) && !(object.sketch && sketchAttachmentPropertyNames.has(property.name)))
const transientProperties = persistedProperties.filter((property) => isFreecadPropertyTransient(property.nativeStatus) && isNativeDataProperty(object, property) && !hasFreecadPropertyStatus(property.nativeStatus, 'PropDynamic'))
const transientPropertyNames = new Set(transientProperties.map((property) => property.name))
const properties = persistedProperties.filter((property) => !transientPropertyNames.has(property.name))
const sketchProperties = sketchNativePropertiesXml(object, objectIdSet)
const expressionEngine = expressionEngineXml(object)
const hasShapeProperty = properties.some((property) => property.type === 'Part::PropertyPartShape')
const shapeMetadata = hasShapeProperty ? '<_Property name="_ElementMapVersion" type="App::PropertyString" status="234881025"/>' : ''
const transientMetadata = transientProperties.map((property) => `<_Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}" status="${normalizeFreecadPropertyStatusMask(property.nativeStatus as number)}"/>`).join('')
const propertyCount = properties.length + sketchProperties.length + (expressionEngine ? 1 : 0)
return `<Object name="${xmlEscape(object.id)}"><Properties Count="${propertyCount}" TransientCount="${hasShapeProperty ? 1 : 0}">${shapeMetadata}${properties.map((property) => propertyXml(property, isNativeDataProperty(object, property), object.id, object.topology?.elementMap)).join('')}${sketchProperties.join('')}${expressionEngine}</Properties></Object>`
const transientCount = transientProperties.length + (hasShapeProperty ? 1 : 0)
return `<Object name="${xmlEscape(object.id)}"><Properties Count="${propertyCount}" TransientCount="${transientCount}">${shapeMetadata}${transientMetadata}${properties.map((property) => propertyXml(property, isNativeDataProperty(object, property), object.id, object.topology?.elementMap)).join('')}${sketchProperties.join('')}${expressionEngine}</Properties></Object>`
}).join('')
const hasShapeResources = shapeResourceReferences(document).length > 0
const usesStringHasher = document.objects.some((object) => object.properties.some((property) => property.type === 'Part::PropertyPartShape' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'hasherIndex' in property.value && property.value.hasherIndex !== undefined))
@@ -1603,7 +1621,16 @@ const propertySummaries = (properties: Record<string, unknown>[]): FcstdProperty
if (!/^\d+$/.test(countText) || !Number.isSafeInteger(count) || count !== actual) throw new Error(`FCStd ${element} count does not match its child elements.`)
}
const value = structuredPropertyValue(element, elementValue, property) ?? propertyValue(property)
return { name: attribute(property, 'name') || '<unnamed>', typeId: attribute(property, 'type') || element || 'unknown', element, value, ...(subElements ? { subElements } : {}), ...(links ? { links } : {}), ...(linkSubs ? { linkSubs } : {}), ...(enumOptions ? { enumOptions } : {}), ...(shapeResource ? { shapeResource } : {}), ...(expression ? { expression } : {}) }
const statusText = attribute(property, 'status')
let nativeStatus: number | undefined
let statusNames: FreecadPropertyStatusName[] | undefined
if (statusText) {
if (!/^\d+$/.test(statusText)) throw new Error(`FCStd Property ${attribute(property, 'name') || '<unnamed>'} status must be an unsigned 32-bit integer.`)
const decoded = decodeFreecadPropertyStatus(Number(statusText))
nativeStatus = decoded.mask
statusNames = decoded.names
}
return { name: attribute(property, 'name') || '<unnamed>', typeId: attribute(property, 'type') || element || 'unknown', element, value, ...(nativeStatus === undefined ? {} : { nativeStatus, statusNames }), ...(subElements ? { subElements } : {}), ...(links ? { links } : {}), ...(linkSubs ? { linkSubs } : {}), ...(enumOptions ? { enumOptions } : {}), ...(shapeResource ? { shapeResource } : {}), ...(expression ? { expression } : {}) }
})
const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertySummary[]): SketchSnapshot | undefined => {
@@ -2061,7 +2088,10 @@ const parseDocumentXml = (bytes: Uint8Array, limits: FcstdArchiveLimits) => {
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
const extensions = [...new Set([...collectXmlNodes(declaration, 'Extension'), ...collectXmlNodes(data, 'Extension')].map((extension) => attribute(extension, 'type') || attribute(extension, 'name')).filter(Boolean))]
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
const summaries = propertySummaries(properties).map((property) => expressions.has(property.name) ? { ...property, expression: expressions.get(property.name) } : property)
const summaries = [
...propertySummaries(properties).map((property) => expressions.has(property.name) ? { ...property, expression: expressions.get(property.name) } : property),
...propertySummaries(transientProperties).map((property) => ({ ...property, transientMetadata: true as const })),
]
const sketch = typeId === 'Sketcher::SketchObject' ? sketchFromPropertySummaries(name, summaries) : undefined
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, ...(nativeObjectTag === undefined ? {} : { nativeObjectTag }), propertyCount: properties.length, properties: summaries, support, extensions, ...(sketch ? { sketch } : {}) }
})

View File

@@ -14,6 +14,8 @@ export type { ProjectMigrationTransaction, ProjectSchemaMigration } from './proj
export type { CreateEllipsoidInput, CreateHelixInput, CreatePrismInput, CreateWedgeInput, ExtrudeInput } from './types'
export type { DraftInput, ThicknessInput } from './types'
export type { LinkSubValue } from './types'
export { decodeFreecadPropertyStatus, encodeFreecadPropertyStatus, freecadPropertyRecomputeEffect, hasFreecadPropertyStatus, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyDynamicMutationLocked, isFreecadPropertyHidden, isFreecadPropertyNoPersist, isFreecadPropertyOutput, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyReadOnly, isFreecadPropertyRecomputeSuppressed, isFreecadPropertyTransient, normalizeFreecadPropertyStatusMask } from './propertyStatus'
export type { FreecadPropertyRecomputeEffect, FreecadPropertyStatus, FreecadPropertyStatusName } from './propertyStatus'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'
export type { ApplyPlacementInput, AttachmentSupportValue, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, CreateTorusInput, Diagnostic, DiagnosticRepairAction, DiagnosticRepairResult, DiagnosticTreeNode, DocumentObjectSnapshot, DocumentSnapshot, ElementMapEntry, ElementMapSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, GeometryFileImport, GrooveInput, LinearFeatureParameters, LoftInput, MeshAsset, MirrorInput, ModelTreeItem, MultiTransformStep, MultiTransformValue, ObjectPropertySnapshot, ObjectTopologySnapshot, PadInput, PathCommandValue, PathPropertyValue, PersistenceCapabilities, PipeInput, Placement, PlacementValue, PlanarProfile, PocketInput, Point3, ProfileClassification, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RemoveObjectInput, ReorderBodyFeatureInput, ResolveTopologyReferenceInput, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, ShapeMassProperties, ShapeQualityReport, SubshapeRef, SubshapeSelection, SubshapeSignature, SubshapeTopology, TaskSnapshot, TopoRefValue, TopologyAdjacency, TopologyMigrationMatch, TopologySnapshotEntry, VectorValue, ViewportInteractionHandlers, ViewportMeshAsset } from './types'

View File

@@ -47,6 +47,7 @@ import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTo
import { createCamJob } from './cam'
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
import { assertPartDesignParameterSet, parameterValuesForObject, validatePartDesignParameterSet } from './partDesignParameters'
import { freecadPropertyRecomputeEffect, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyReadOnly } from './propertyStatus'
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
@@ -532,7 +533,7 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
}
const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPropertySnapshot, value: PropertyValue) => {
if (property.readOnly) throw new Error(`${property.label} is read-only.`)
if (property.readOnly || isFreecadPropertyReadOnly(property.nativeStatus)) throw new Error(`${property.label} is read-only.`)
if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`)
if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`)
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyQuantityConstraint' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger' || property.type === 'App::PropertyIntegerConstraint') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
@@ -689,15 +690,17 @@ const expectedExpressionValue = (property: ObjectPropertySnapshot, expression: s
return { value, references: result.references }
}
const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<string>) => {
const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<string>, includeRoots = true) => {
const roots = new Set(objectIds)
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
const plan = graph.plan(objectIds)
const plan = graph.plan(roots)
const affected = includeRoots ? plan.affected : plan.affected.filter((objectId) => !roots.has(objectId))
const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id))
const objectStates = { ...recompute.objectStates }
for (const objectId of plan.affected) objectStates[objectId] = 'touched'
for (const objectId of affected) objectStates[objectId] = 'touched'
const dirtyObjects = Object.entries(objectStates).filter(([, status]) => status === 'touched' || status === 'error' || status === 'upstream-failed').map(([objectId]) => objectId)
document.recompute = { ...recompute, status: 'idle', objectStates, dirtyObjects, order: [], errors: [] }
for (const objectId of plan.affected) {
for (const objectId of affected) {
const item = document.tree.find((candidate) => candidate.id === objectId)
if (item && item.state !== 'active' && item.state !== 'readonly') item.state = 'dirty'
const object = document.objects.find((candidate) => candidate.id === objectId)
@@ -1072,9 +1075,10 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const treeItem = document.tree.find((item) => item.id === objectId)
if (propertyName === 'Label' && treeItem) treeItem.label = String(value)
document.dependencies = collectDependencyEdges(document)
if (sourceProperty.recompute) markDocumentTouched(document, [objectId])
const recomputeEffect = freecadPropertyRecomputeEffect(sourceProperty.nativeStatus)
if (sourceProperty.recompute && recomputeEffect !== 'none') markDocumentTouched(document, [objectId], recomputeEffect === 'owner')
document.version += 1
document.dirty = true
document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
commit({ ...state, document })
notify(`${sourceProperty.label} updated`)
}
@@ -1160,7 +1164,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName)
if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`)
const sourceProperty = sourceObject.properties[propertyIndex]
if (sourceProperty.readOnly) throw new Error(`${sourceProperty.label} is read-only.`)
if (sourceProperty.readOnly || isFreecadPropertyReadOnly(sourceProperty.nativeStatus)) throw new Error(`${sourceProperty.label} is read-only.`)
if (!['App::PropertyLength', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyPercent'].includes(sourceProperty.type)) throw new TypeError(`${sourceProperty.label} does not accept expressions.`)
const evaluated = expectedExpressionValue(sourceProperty, expression, state.document)
const references = expressionReferences(expression)
@@ -1172,9 +1176,10 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
property.expression = expression.trim()
property.expressionError = undefined
document.dependencies = collectDependencyEdges(document)
if (sourceProperty.recompute) markDocumentTouched(document, [objectId])
const recomputeEffect = freecadPropertyRecomputeEffect(sourceProperty.nativeStatus)
if (sourceProperty.recompute && recomputeEffect !== 'none') markDocumentTouched(document, [objectId], recomputeEffect === 'owner')
document.version += 1
document.dirty = true
document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
commit({ ...state, document })
notify(`${sourceProperty.label} expression updated`)
}

View File

@@ -0,0 +1,93 @@
export const FREECAD_PROPERTY_STATUS_POSITIONS = {
Touched: 0,
Immutable: 1,
ReadOnly: 2,
Hidden: 3,
Transient: 4,
MaterialEdit: 5,
NoMaterialListEdit: 6,
Output: 7,
LockDynamic: 8,
NoModify: 9,
PartialTrigger: 10,
NoRecompute: 11,
Single: 12,
Ordered: 13,
EvalOnRestore: 14,
Busy: 15,
CopyOnChange: 16,
UserEdit: 17,
DisableNotify: 18,
PropDynamic: 21,
PropNoPersist: 22,
PropNoRecompute: 23,
PropReadOnly: 24,
PropTransient: 25,
PropHidden: 26,
PropOutput: 27,
User1: 28,
User2: 29,
User3: 30,
User4: 31,
} as const
export type FreecadPropertyStatusName = keyof typeof FREECAD_PROPERTY_STATUS_POSITIONS
export type FreecadPropertyStatus = {
mask: number
names: FreecadPropertyStatusName[]
unknownBits: number[]
}
const namesByPosition = new Map<number, FreecadPropertyStatusName>(
Object.entries(FREECAD_PROPERTY_STATUS_POSITIONS).map(([name, position]) => [position, name as FreecadPropertyStatusName]),
)
export const normalizeFreecadPropertyStatusMask = (value: number): number => {
if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) throw new RangeError('FreeCAD Property status mask must be an unsigned 32-bit integer.')
return value >>> 0
}
export const decodeFreecadPropertyStatus = (value: number): FreecadPropertyStatus => {
const mask = normalizeFreecadPropertyStatusMask(value)
const names: FreecadPropertyStatusName[] = []
const unknownBits: number[] = []
for (let position = 0; position < 32; position += 1) {
if ((mask & (1 << position)) === 0) continue
const name = namesByPosition.get(position)
if (name) names.push(name)
else unknownBits.push(position)
}
return { mask, names, unknownBits }
}
export const encodeFreecadPropertyStatus = (names: readonly FreecadPropertyStatusName[]): number => {
let mask = 0
for (const name of new Set(names)) mask = (mask | (1 << FREECAD_PROPERTY_STATUS_POSITIONS[name])) >>> 0
return mask
}
export const hasFreecadPropertyStatus = (mask: number | undefined, ...names: FreecadPropertyStatusName[]): boolean => {
if (mask === undefined) return false
const normalized = normalizeFreecadPropertyStatusMask(mask)
return names.some((name) => (normalized & (1 << FREECAD_PROPERTY_STATUS_POSITIONS[name])) !== 0)
}
export const isFreecadPropertyReadOnly = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Immutable', 'ReadOnly', 'PropReadOnly')
export const isFreecadPropertyHidden = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Hidden', 'PropHidden')
export const isFreecadPropertyOutput = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Output', 'PropOutput')
export const isFreecadPropertyRecomputeSuppressed = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Output', 'PropOutput', 'PropNoRecompute')
export const isFreecadPropertyNoPersist = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'PropNoPersist')
export const isFreecadPropertyTransient = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Transient', 'PropTransient')
export const isFreecadPropertyPersistenceSuppressed = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'Transient', 'PropNoPersist', 'PropTransient')
export const isFreecadPropertyDocumentModifiedSuppressed = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'NoModify')
export const isFreecadPropertyDynamicMutationLocked = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'LockDynamic')
export type FreecadPropertyRecomputeEffect = 'none' | 'dependents' | 'owner'
/** Mirrors App::DocumentObject::onChanged for the locked FreeCAD runtime. */
export const freecadPropertyRecomputeEffect = (mask: number | undefined): FreecadPropertyRecomputeEffect => {
if (isFreecadPropertyOutput(mask)) return 'none'
if (hasFreecadPropertyStatus(mask, 'PropNoRecompute')) return 'dependents'
return 'owner'
}

View File

@@ -97,6 +97,8 @@ export type ObjectPropertySnapshot = {
type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyDistance' | 'App::PropertyAngle' | 'App::PropertyQuantityConstraint' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyLinkSub' | 'App::PropertyLinkSubList' | 'App::PropertyLinkList' | 'App::PropertyStringList' | 'App::PropertyFloatList' | 'App::PropertyIntegerList' | 'App::PropertyVector' | 'App::PropertyPlacement' | 'App::PropertyMultiTransform' | 'Path::PropertyPath' | 'Part::PropertyPartShape' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger' | 'App::PropertyIntegerConstraint'
value: PropertyValue
unit?: string
/** Raw App::Property::Status bitmask from the locked FreeCAD runtime. */
nativeStatus?: number
readOnly?: boolean
hidden?: boolean
recompute?: boolean