feat: advance FreeCAD exact parity evidence
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 22:39:16 -04:00
parent e3373c9d6c
commit 5bbd7b9d4f
64 changed files with 113069 additions and 21074 deletions

View File

@@ -7,7 +7,7 @@ type MachineReport = {
stages: string[]
openCamLib?: { backend: string; sourceRevision: string; artifactSha256: string; triangleCount: number; inputPoints: number; outputPoints: number; sampling: number }
gcode?: { bytes: number; hasM428: boolean; hasM429: boolean; hasG93: boolean; hasG94: boolean; hasB: boolean; hasC359: boolean; hasC361: boolean }
machine?: { backend: string; status: string; dryRunStatus?: string; parserAuthority?: string; lines?: number; blocks?: number; trajectorySegments?: number; message?: string }
machine?: { backend: string; status: string; dryRunStatus?: string; parserAuthority?: string; machineCase?: string; dryRunMachineCase?: string; lines?: number; blocks?: number; trajectorySegments?: number; message?: string }
error?: string
}
@@ -50,9 +50,10 @@ const run = async (): Promise<MachineReport> => {
name: 'cad-ocl-camotics-xyzbc.ngc',
timeoutMs: 180_000,
}))
const trace = result.linuxcnc?.trace as { lines?: number; blocks?: number; trajectorySegments?: number; parserAuthority?: string } | undefined
report.machine = { backend: result.linuxcnc?.backend || '', status: result.linuxcnc?.status || '', dryRunStatus: dryRun.linuxcnc?.status || '', parserAuthority: trace?.parserAuthority, lines: trace?.lines, blocks: trace?.blocks, trajectorySegments: trace?.trajectorySegments, message: result.linuxcnc?.message }
report.status = report.openCamLib.backend === 'upstream-opencamlib-wasm' && report.openCamLib.triangleCount >= 2 && report.openCamLib.outputPoints >= report.openCamLib.inputPoints && Object.values(report.gcode).every(Boolean) && report.machine.backend === 'linuxcnc-wasm' && report.machine.status === 'accepted' && report.machine.dryRunStatus === 'dry-run' && report.machine.parserAuthority === 'linuxcnc-wasm' && Number(report.machine.lines) >= 10 && Number(report.machine.lines) < 1000 && Number(report.machine.blocks) >= 2 && Number(report.machine.blocks) < 1000 && Number(report.machine.trajectorySegments) >= 2 && Number(report.machine.trajectorySegments) < 1000 ? 'pass' : 'failed'
const trace = result.linuxcnc?.trace
const dryRunTrace = dryRun.linuxcnc?.trace
report.machine = { backend: result.linuxcnc?.backend || '', status: result.linuxcnc?.status || '', dryRunStatus: dryRun.linuxcnc?.status || '', parserAuthority: trace?.parserAuthority, machineCase: trace?.machineCase, dryRunMachineCase: dryRunTrace?.machineCase, lines: trace?.lines, blocks: trace?.blocks, trajectorySegments: trace?.trajectorySegments, message: result.linuxcnc?.message }
report.status = report.openCamLib.backend === 'upstream-opencamlib-wasm' && report.openCamLib.triangleCount >= 2 && report.openCamLib.outputPoints >= report.openCamLib.inputPoints && Object.values(report.gcode).every(Boolean) && report.machine.backend === 'linuxcnc-wasm' && report.machine.status === 'accepted' && report.machine.dryRunStatus === 'dry-run' && report.machine.parserAuthority === 'linuxcnc-wasm' && report.machine.machineCase === 'axis-vismach-5axis-table-rotary-tilting-xyzbc-trt' && report.machine.dryRunMachineCase === report.machine.machineCase && Number(report.machine.lines) >= 10 && Number(report.machine.lines) < 1000 && Number(report.machine.blocks) >= 2 && Number(report.machine.blocks) < 1000 && Number(report.machine.trajectorySegments) >= 2 && Number(report.machine.trajectorySegments) < 1000 ? 'pass' : 'failed'
} catch (error) {
report.error = error instanceof Error ? error.stack || error.message : String(error)
}

View File

@@ -39,7 +39,13 @@ export type LinuxcncWasmSubmission = {
status: 'accepted' | 'rejected' | 'dry-run' | 'unavailable'
programSha256?: string
message?: string
trace?: unknown
trace?: {
lines?: number
blocks?: number
trajectorySegments?: number
parserAuthority?: string
machineCase?: string
}
}
export type LinuxcncWasmAdapter = {
@@ -215,12 +221,13 @@ export const createLinuxcncIframeAdapter = (options: LinuxcncIframeAdapterOption
if (event.source !== frame.contentWindow || data?.type !== 'bitbybit-linuxcnc-result' || data.requestId !== requestId) return
window.clearTimeout(timer)
window.removeEventListener('message', onMessage)
const machineCase = frame.contentDocument?.querySelector<HTMLElement>('[data-case].active')?.dataset.case
resolve({
backend: 'linuxcnc-wasm',
status: data.status,
programSha256: data.programSha256,
message: data.message,
trace: { lines: data.lines, blocks: data.blocks, trajectorySegments: data.trajectorySegments, parserAuthority: data.parserAuthority },
trace: { lines: data.lines, blocks: data.blocks, trajectorySegments: data.trajectorySegments, parserAuthority: data.parserAuthority, machineCase },
})
}
window.addEventListener('message', onMessage)

View File

@@ -985,7 +985,7 @@ const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId
const quaternion = [axis[0] * sine, axis[1] * sine, axis[2] * sine, Math.cos(angle / 2)]
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><PropertyPlacement Px="${px}" Py="${py}" Pz="${pz}" Q0="${quaternion[0]}" Q1="${quaternion[1]}" Q2="${quaternion[2]}" Q3="${quaternion[3]}" A="${angle}" Ox="${axis[0]}" Oy="${axis[1]}" Oz="${axis[2]}"/></Property>`
}
if (property.type === 'App::PropertyLink') {
if (property.type === 'App::PropertyLink' || property.type === 'App::PropertyLinkHidden') {
if (property.value !== null && typeof property.value !== 'string') throw new TypeError(`FCStd Link property ${property.name} requires an object name.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Link value="${xmlEscape(property.value ?? '')}"/></Property>`
}
@@ -1062,7 +1062,7 @@ const propertyXml = (property: ObjectPropertySnapshot, native: boolean, objectId
if (typeof property.value !== 'boolean') throw new TypeError(`FCStd Bool property ${property.name} requires a boolean.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Bool value="${property.value ? 'true' : 'false'}"/></Property>`
}
if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyFloat') {
if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyDistance' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyQuantityConstraint' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyFloatConstraint' || property.type === 'App::PropertyPrecision') {
if (typeof property.value !== 'number' || !Number.isFinite(property.value)) throw new TypeError(`FCStd numeric property ${property.name} requires a finite number.`)
return `<Property name="${xmlEscape(property.name)}" type="${xmlEscape(property.type)}"${attributes}><Float value="${property.value}"/></Property>`
}
@@ -1893,7 +1893,7 @@ const sketchFromPropertySummaries = (objectId: string, summaries: FcstdPropertyS
}
export const decodeFcstdPropertyValue = (property: FcstdPropertySummary): FcstdDecodedPropertyValue => {
const numericTypes = new Set(['App::PropertyLength', 'App::PropertyDistance', 'App::PropertyAngle', 'App::PropertyFloat', 'App::PropertyFloatConstraint', 'App::PropertyInteger', 'App::PropertyIntegerConstraint', 'App::PropertyPercent'])
const numericTypes = new Set(['App::PropertyLength', 'App::PropertyDistance', 'App::PropertyAngle', 'App::PropertyQuantityConstraint', 'App::PropertyFloat', 'App::PropertyFloatConstraint', 'App::PropertyPrecision', 'App::PropertyInteger', 'App::PropertyIntegerConstraint', 'App::PropertyPercent'])
if (numericTypes.has(property.typeId)) {
const value = Number(property.value)
return Number.isFinite(value) ? { value, decoded: true } : { value: property.value, decoded: false, error: `Property ${property.name} is not a finite number.` }
@@ -1915,7 +1915,7 @@ export const decodeFcstdPropertyValue = (property: FcstdPropertySummary): FcstdD
value: { schemaVersion: 1, objectId: property.value, subElements: [...property.subElements] },
decoded: true,
}
if (property.typeId === 'App::PropertyLink') return { value: property.value || null, decoded: true }
if (property.typeId === 'App::PropertyLink' || property.typeId === 'App::PropertyLinkHidden') return { value: property.value || null, decoded: true }
if (property.typeId === 'App::PropertyLinkList' && property.links) return { value: [...property.links], decoded: true }
if (property.typeId === 'App::PropertyLinkSubList' && property.linkSubs) return { value: { schemaVersion: 1, entries: property.linkSubs.map((entry) => ({ ...entry })) }, decoded: true }
if (property.typeId === 'Part::PropertyPartShape' && property.shapeResource) return { value: { ...property.shapeResource, format: 'brep' }, decoded: true }

View File

@@ -14,7 +14,7 @@ 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 { decodeFreecadPropertyStatus, encodeFreecadPropertyStatus, freecadPropertyRecomputeEffect, hasFreecadPropertyStatus, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyDynamicMutationLocked, isFreecadPropertyHidden, isFreecadPropertyNoPersist, isFreecadPropertyOutput, isFreecadPropertyPartialDocumentTrigger, isFreecadPropertyPersistenceSuppressed, isFreecadPropertyReadOnly, isFreecadPropertyRecomputeSuppressed, isFreecadPropertyTransient, normalizeFreecadPropertyStatusMask, withFreecadPropertyStatus } from './propertyStatus'
export type { FreecadPropertyRecomputeEffect, FreecadPropertyStatus, FreecadPropertyStatusName } from './propertyStatus'
export { assessResourceQuota, planResourceSweep } from './resourcePolicy'
export type { ResourceQuotaAssessment, ResourceSweepPlan, ResourceSweepRecord } from './resourcePolicy'

View File

@@ -1,6 +1,7 @@
import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest'
import type {
BitBybitWebCadFacade,
AddDynamicPropertyInput,
CommandState,
DocumentSnapshot,
Diagnostic,
@@ -20,6 +21,8 @@ import type {
PlacementValue,
ReorderBodyFeatureInput,
RemoveObjectInput,
RemoveDynamicPropertyInput,
RenameDynamicPropertyInput,
SetPropertyInput,
SetExpressionInput,
RecomputeResult,
@@ -47,7 +50,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'
import { freecadPropertyRecomputeEffect, hasFreecadPropertyStatus, isFreecadPropertyDocumentModifiedSuppressed, isFreecadPropertyDynamicMutationLocked, isFreecadPropertyPartialDocumentTrigger, isFreecadPropertyReadOnly, withFreecadPropertyStatus } 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'
@@ -536,7 +539,7 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
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.`)
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::PropertyFloatConstraint' || property.type === 'App::PropertyPrecision' || property.type === 'App::PropertyInteger' || property.type === 'App::PropertyIntegerConstraint') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
if ((property.type === 'App::PropertyInteger' || property.type === 'App::PropertyIntegerConstraint') && !Number.isSafeInteger(value)) throw new TypeError(`${property.label} requires an integer value.`)
if (property.name === 'Occurrences' && ((value as number) < 2 || (value as number) > 100)) throw new RangeError('Occurrences must be between 2 and 100.')
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyQuantityConstraint') && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`)
@@ -576,7 +579,7 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
if (!/\.Map\.txt$/i.test(value.elementMapResource)) throw new TypeError(`${property.label} requires an ElementMap2 .Map.txt resource.`)
}
}
if (property.type === 'App::PropertyLink') {
if (property.type === 'App::PropertyLink' || property.type === 'App::PropertyLinkHidden') {
if (property.name === 'Support' && value && typeof value === 'object' && !Array.isArray(value)) {
validateAttachmentSupport(value)
const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))
@@ -740,13 +743,14 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const listeners = new Set<FacadeListener>()
const undoStack: FacadeState[] = []
const redoStack: FacadeState[] = []
const partialMutationWarnings = new Set<string>()
let requestSequence = 0
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
const emitState = () => emit({ type: 'state.changed', state: getState() })
const cloneSelection = (selection: SubshapeSelection | null | undefined): SubshapeSelection | null | undefined => selection ? { objectId: selection.objectId, ref: { ...selection.ref, candidates: selection.ref.candidates ? [...selection.ref.candidates] : undefined } } : selection
const getState = () => ({ ...state, selectedObjectIds: [...(state.selectedObjectIds ?? (state.selectedObjectId ? [state.selectedObjectId] : []))], selectedSubshape: cloneSelection(state.selectedSubshape), preselectedSubshape: cloneSelection(state.preselectedSubshape), diagnostics: state.diagnostics.map(cloneDiagnostic), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: Object.fromEntries(Object.entries(state.task.draft).map(([key, value]) => [key, key === 'transformations' ? clonePropertyValue(value as MultiTransformValue) : Array.isArray(value) ? [...value] : value])) } : null })
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() }
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty && !next.document.partial) autosave.schedule(next.document); emitState() }
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
const selectObjects = (objectIds: string[]) => {
@@ -1067,6 +1071,12 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
validatePropertyValue(state.document, sourceProperty, value)
if (Object.is(sourceProperty.value, value)) return
const beforeVersion = state.document.version
const transactionId = `txn-${++requestSequence}`
const partialDocument = state.document.partial === true
const partialTrigger = isFreecadPropertyPartialDocumentTrigger(sourceProperty.nativeStatus)
emit({ type: 'property.before-change', transactionId, documentId: state.document.id, documentVersion: beforeVersion, objectId, propertyName, partialDocument, partialTrigger })
const document = cloneDocumentSnapshot(state.document)
const object = document.objects[objectIndex]
object.properties[propertyIndex] = { ...object.properties[propertyIndex], value: clonePropertyValue(value), expression: undefined, expressionError: undefined }
@@ -1080,8 +1090,103 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
document.version += 1
document.dirty = isFreecadPropertyDocumentModifiedSuppressed(sourceProperty.nativeStatus) ? state.document.dirty : true
commit({ ...state, document })
emit({ type: 'property.changed', transactionId, documentId: document.id, documentVersion: document.version, objectId, propertyName, partialDocument, partialTrigger })
emit({ type: 'transaction.committed', transactionId, operation: 'property.set', documentId: document.id, beforeVersion, afterVersion: document.version })
if (partialDocument && !partialTrigger && !partialMutationWarnings.has(document.id)) {
partialMutationWarnings.add(document.id)
const diagnostic: Diagnostic = {
id: `diag-${++requestSequence}`,
source: 'command',
severity: 'warning',
code: 'PARTIAL_DOCUMENT_MUTATION_NOT_PERSISTED',
message: `Changes to partial loaded document will not be saved: ${objectId}.${propertyName}`,
objectId,
documentId: document.id,
documentVersion: document.version,
}
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: transactionId, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench } })
}
notify(`${sourceProperty.label} updated`)
}
const addDynamicProperty = ({ objectId, property: inputProperty }: AddDynamicPropertyInput): ObjectPropertySnapshot => {
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(inputProperty.name)) throw new RangeError(`Invalid property name '${inputProperty.name}'.`)
if (state.document.objects[objectIndex].properties.some((property) => property.name === inputProperty.name)) throw new RangeError(`Property ${objectId}.${inputProperty.name} already exists.`)
const property: ObjectPropertySnapshot = {
...inputProperty,
value: clonePropertyValue(inputProperty.value),
options: inputProperty.options ? [...inputProperty.options] : undefined,
nativeStatus: withFreecadPropertyStatus(inputProperty.nativeStatus, 'PropDynamic'),
}
validatePropertyValue(state.document, { ...property, readOnly: false, nativeStatus: undefined }, property.value)
const beforeVersion = state.document.version
const transactionId = `txn-${++requestSequence}`
const document = cloneDocumentSnapshot(state.document)
document.objects[objectIndex].properties.push(property)
document.dependencies = collectDependencyEdges(document)
document.version += 1
document.dirty = true
commit({ ...state, document })
const partialDocument = document.partial === true
const partialTrigger = isFreecadPropertyPartialDocumentTrigger(property.nativeStatus)
emit({ type: 'property.added', transactionId, documentId: document.id, documentVersion: document.version, objectId, propertyName: property.name, partialDocument, partialTrigger })
emit({ type: 'transaction.committed', transactionId, operation: 'property.add', documentId: document.id, beforeVersion, afterVersion: document.version })
notify(`${property.label} property added`)
return { ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined }
}
const removeDynamicProperty = ({ objectId, propertyName }: RemoveDynamicPropertyInput): boolean => {
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
const propertyIndex = state.document.objects[objectIndex].properties.findIndex((property) => property.name === propertyName)
if (propertyIndex < 0) return false
const property = state.document.objects[objectIndex].properties[propertyIndex]
if (!hasFreecadPropertyStatus(property.nativeStatus, 'PropDynamic') || isFreecadPropertyDynamicMutationLocked(property.nativeStatus)) return false
const beforeVersion = state.document.version
const transactionId = `txn-${++requestSequence}`
const document = cloneDocumentSnapshot(state.document)
document.objects[objectIndex].properties.splice(propertyIndex, 1)
document.dependencies = collectDependencyEdges(document)
document.version += 1
document.dirty = true
commit({ ...state, document })
const partialDocument = document.partial === true
const partialTrigger = isFreecadPropertyPartialDocumentTrigger(property.nativeStatus)
emit({ type: 'property.removed', transactionId, documentId: document.id, documentVersion: document.version, objectId, propertyName, partialDocument, partialTrigger })
emit({ type: 'transaction.committed', transactionId, operation: 'property.remove', documentId: document.id, beforeVersion, afterVersion: document.version })
notify(`${property.label} property removed`)
return true
}
const renameDynamicProperty = ({ objectId, propertyName, newPropertyName }: RenameDynamicPropertyInput): boolean => {
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
const sourceObject = state.document.objects[objectIndex]
const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName)
if (propertyIndex < 0 || !hasFreecadPropertyStatus(sourceObject.properties[propertyIndex].nativeStatus, 'PropDynamic')) throw new Error(`Property container has no dynamic property '${propertyName}'.`)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(newPropertyName)) throw new RangeError(`Invalid property name '${newPropertyName}'.`)
if (sourceObject.properties.some((property) => property.name === newPropertyName)) throw new RangeError(`Property ${objectId}.${newPropertyName} already exists.`)
const sourceProperty = sourceObject.properties[propertyIndex]
if (isFreecadPropertyDynamicMutationLocked(sourceProperty.nativeStatus)) throw new Error(`Property ${propertyName} is locked`)
if (propertyName === newPropertyName) return true
const beforeVersion = state.document.version
const transactionId = `txn-${++requestSequence}`
const document = cloneDocumentSnapshot(state.document)
document.objects[objectIndex].properties[propertyIndex].name = newPropertyName
document.dependencies = collectDependencyEdges(document)
document.version += 1
document.dirty = true
commit({ ...state, document })
const partialDocument = document.partial === true
const partialTrigger = isFreecadPropertyPartialDocumentTrigger(sourceProperty.nativeStatus)
emit({ type: 'property.renamed', transactionId, documentId: document.id, documentVersion: document.version, objectId, propertyName: newPropertyName, previousPropertyName: propertyName, partialDocument, partialTrigger })
emit({ type: 'transaction.committed', transactionId, operation: 'property.rename', documentId: document.id, beforeVersion, afterVersion: document.version })
notify(`${sourceProperty.label} property renamed`)
return true
}
const reorderBodyFeature = ({ bodyId, objectId, beforeObjectId }: ReorderBodyFeatureInput) => {
const document = cloneDocumentSnapshot(state.document)
const body = document.tree.find((item) => item.id === bodyId && item.type === 'body')
@@ -1184,6 +1289,10 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
notify(`${sourceProperty.label} expression updated`)
}
const recomputeDocument = (): RecomputeResult => {
if (state.document.partial) {
notify(`Partial document ${state.document.label} must be fully loaded before recomputation`)
return { affected: [], order: [], levels: [], cycles: [], generation: state.document.recompute?.generation ?? 0, status: 'completed', errors: [] }
}
const document = cloneDocumentSnapshot(state.document)
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
const recompute = document.recompute ?? createRecomputeSnapshot(document.objects.map((object) => object.id))
@@ -1221,12 +1330,23 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
state = { ...state, document, diagnostics }
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench }
for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context })
if (document.dirty) autosave.schedule(document)
if (document.dirty && !document.partial) autosave.schedule(document)
emitState()
return { ...plan, generation, status: nextRecompute.status, errors }
}
const recomputeDocumentAsync = async (options: RecomputeExecutionOptions = {}) => {
const source = cloneDocumentSnapshot(state.document)
if (source.partial) {
notify(`Partial document ${source.label} must be fully loaded before recomputation`)
const recompute = source.recompute ?? createRecomputeSnapshot(source.objects.map((object) => object.id))
return {
generation: recompute.generation,
documentVersion: source.version,
status: 'completed' as const,
affected: [], order: [], levels: [], completed: [], suppressed: [], failed: [], skipped: [],
dirtyObjects: [...recompute.dirtyObjects], objectStates: { ...recompute.objectStates }, objectUpdates: [], errors: [],
}
}
const result = await recomputeCoordinator.run(source, options)
if ((result.status !== 'completed' && result.status !== 'failed') || state.document.id !== source.id || state.document.version !== source.version) return result
@@ -1293,7 +1413,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
state = { ...state, document, diagnostics }
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId: `recompute-${result.generation}`, documentId: document.id, documentVersion: document.version, workbench: state.activeWorkbench }
for (const diagnostic of nextDiagnostics) emit({ type: 'diagnostic.added', diagnostic, context })
if (document.dirty) autosave.schedule(document)
if (document.dirty && !document.partial) autosave.schedule(document)
emitState()
return result
}
@@ -1492,7 +1612,7 @@ export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitW
const facade: BitBybitWebCadFacade = {
runtime,
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: nextBlankDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, reorderBodyFeature, removeObject, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: nextBlankDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, addDynamicProperty, removeDynamicProperty, renameDynamicProperty, reorderBodyFeature, removeObject, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId), state.selectedSubshape), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, getObjectIds: () => [...(state.selectedObjectIds ?? (state.selectedObjectId ? [state.selectedObjectId] : []))], getSubshape: () => cloneSelection(state.selectedSubshape) ?? null, getPreselection: () => cloneSelection(state.preselectedSubshape) ?? null, select, selectObjects, selectSubshape, preselectSubshape, clear: () => select('') },

View File

@@ -82,6 +82,12 @@ export const isFreecadPropertyTransient = (mask: number | undefined): boolean =>
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 const isFreecadPropertyPartialDocumentTrigger = (mask: number | undefined): boolean => hasFreecadPropertyStatus(mask, 'PartialTrigger')
export const withFreecadPropertyStatus = (mask: number | undefined, ...names: FreecadPropertyStatusName[]): number => {
const normalized = mask === undefined ? 0 : normalizeFreecadPropertyStatusMask(mask)
return (normalized | encodeFreecadPropertyStatus(names)) >>> 0
}
export type FreecadPropertyRecomputeEffect = 'none' | 'dependents' | 'owner'

View File

@@ -89,12 +89,23 @@ export type ShapeResourceValue = {
export type PropertyValue = string | number | boolean | string[] | number[] | VectorValue | PlacementValue | AttachmentSupportValue | MultiTransformValue | PathPropertyValue | ShapeResourceValue | TopoRefValue | LinkSubValue | LinkSubListValue | null
export type NativeEditablePropertyType =
| 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyDistance' | 'App::PropertyAngle'
| 'App::PropertyQuantityConstraint' | 'App::PropertyBool' | 'App::PropertyEnumeration'
| 'App::PropertyLink' | 'App::PropertyLinkHidden' | '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::PropertyFloatConstraint' | 'App::PropertyPrecision' | 'App::PropertyInteger'
| 'App::PropertyIntegerConstraint'
export type ObjectPropertySnapshot = {
name: string
label: string
group: string
scope: 'data' | 'view'
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'
type: NativeEditablePropertyType
value: PropertyValue
unit?: string
/** Raw App::Property::Status bitmask from the locked FreeCAD runtime. */
@@ -123,6 +134,8 @@ export type DocumentSnapshot = {
version: number
dirty: boolean
readOnly: boolean
/** Native PartialDoc state. Partial documents can be inspected but cannot be saved or recomputed. */
partial?: boolean
units: string
tree: ModelTreeItem[]
objects: DocumentObjectSnapshot[]
@@ -148,6 +161,20 @@ export type SetPropertyInput = {
value: PropertyValue
}
export type AddDynamicPropertyInput = {
objectId: string
property: ObjectPropertySnapshot
}
export type RemoveDynamicPropertyInput = {
objectId: string
propertyName: string
}
export type RenameDynamicPropertyInput = RemoveDynamicPropertyInput & {
newPropertyName: string
}
export type ReorderBodyFeatureInput = {
bodyId: string
objectId: string
@@ -816,12 +843,25 @@ export type FacadeState = {
diagnostics: Diagnostic[]
}
export type FacadePropertyMutation = {
transactionId: string
documentId: string
documentVersion: number
objectId: string
propertyName: string
previousPropertyName?: string
partialDocument: boolean
partialTrigger: boolean
}
export type FacadeEvent =
| { type: 'state.changed'; state: FacadeState }
| { type: 'notice'; message: string }
| { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; context: FacadeRequestContext; message?: string }
| { type: 'diagnostic.focused'; diagnosticId: string; objectId: string }
| { type: 'diagnostic.added'; diagnostic: Diagnostic; context: FacadeRequestContext }
| ({ type: 'property.before-change' | 'property.changed' | 'property.added' | 'property.removed' | 'property.renamed' } & FacadePropertyMutation)
| { type: 'transaction.committed'; transactionId: string; operation: 'property.set' | 'property.add' | 'property.remove' | 'property.rename'; documentId: string; beforeVersion: number; afterVersion: number }
export type FacadeListener = (event: FacadeEvent) => void
export type Unsubscribe = () => void
@@ -867,6 +907,9 @@ export interface BitBybitWebCadFacade {
load(documentId: string): Promise<DocumentSnapshot | null>
markDirty(): void
setProperty(input: SetPropertyInput): void
addDynamicProperty(input: AddDynamicPropertyInput): ObjectPropertySnapshot
removeDynamicProperty(input: RemoveDynamicPropertyInput): boolean
renameDynamicProperty(input: RenameDynamicPropertyInput): boolean
reorderBodyFeature(input: ReorderBodyFeatureInput): DocumentSnapshot
removeObject(input: RemoveObjectInput): string[]
resolveTopologyReference(input: ResolveTopologyReferenceInput): TopoRefValue