feat: align FreeCAD property status semantics
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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 } : {}) }
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
93
src/facade/propertyStatus.ts
Normal file
93
src/facade/propertyStatus.ts
Normal 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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user