feat: persist stable sketch external geometry
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
"facadeCapabilities": {
|
||||
"geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "polar-pattern-whole-shape", "hole-basic-origin-normal", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
|
||||
"document": { "level": "experimental", "operations": ["typed-properties", "property-link-sub", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "versioned-toporef", "toporef-migration", "signature-topology-history-fallback", "undo-redo"] },
|
||||
"sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] },
|
||||
"sketcher": { "level": "experimental", "provider": "typescript-basic; planegcs-wasm unavailable", "operations": ["point-line-circle-arc-model", "ellipse-bspline-model-only", "construction-geometry", "stable-external-geometry-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "versioned-solver-protocol", "solver-capability-probe", "cancel-stale-isolation", "replay-contract", "persistence"] },
|
||||
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
|
||||
},
|
||||
"rules": [
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
| SK-05 | `PARTIAL`:点、线、圆、弧可由基础 solver 处理;椭圆、B-spline(次数、控制点、权重、节点、周期标志)已进入深拷贝/持久化领域模型 | SK-02 | 椭圆/B-spline 求解与编辑器仍依赖 SK-03/SK-10,基础 provider 对其返回明确 unsupported 诊断,不静默计算 |
|
||||
| SK-06 | 基础约束:Coincident、Horizontal、Vertical、Distance、Angle | SK-04/05 | 自由度和残差黄金一致 |
|
||||
| SK-07 | 高级约束:Tangent、Equal、Symmetric、Block、Diameter、Radius | SK-06 | 冲突/冗余分类一致 |
|
||||
| SK-08 | 外部几何、构造几何和投影 | TSN-09 | 选择引用可稳定重算 |
|
||||
| SK-08 | `PARTIAL`:构造几何标志、外部投影记录、版本化 TopoRef 和 `topo-ref` DAG 边已进入 Facade/持久化 | TSN-09 | 上游对象可触发草图分支重算;真实 OCCT 投影、引用迁移 UI 与黄金行为仍待完成 |
|
||||
| SK-09 | 拖拽、自动约束、约束编辑器和任务面板 | SK-06 | 鼠标/键盘回放与桌面行为一致 |
|
||||
| SK-10 | B-spline 编辑、节点/权重和约束限制 | SK-07 | 支持范围明确,无静默降级 |
|
||||
| SK-11 | Sketch 支持 Pad/Pocket/Revolution 和附着面 | TSN-07/DAG-07 | 轮廓修改触发正确重算 |
|
||||
|
||||
@@ -10,8 +10,8 @@ export { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef,
|
||||
export type { PersistedTopoRef, TopoRefResolution, TopologyMigration } from './topologyReferences'
|
||||
export { captureSignatureTopologyHistory } from './topologyHistory'
|
||||
export type { TopologyHistoryEntry, TopologyHistoryRelation, TopologyHistoryResult } from './topologyHistory'
|
||||
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
|
||||
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
|
||||
export { BasicSketchSolverAdapter, cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch } from './sketcher'
|
||||
export type { SketchConstraint, SketchDiagnostic, SketchExternalGeometry, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
|
||||
export { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay } from './sketchSolverProtocol'
|
||||
export type { SketchSolverCapabilities, SketchSolverCompatibility, SketchSolverExecution, SketchSolverProvider, SketchSolverReplayCase, SketchSolverReplayResult, SketchSolverRequest, SketchSolverResponse } from './sketchSolverProtocol'
|
||||
export { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
|
||||
|
||||
@@ -27,7 +27,7 @@ import { BitbybitGeometryRuntime } from './geometryRuntime'
|
||||
import { ThreeViewportAdapter } from './threeViewport'
|
||||
import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph'
|
||||
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
|
||||
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
|
||||
import { cloneSketch, cloneSketchConstraint, cloneSketchGeometry, createSketch, solveSketch, type SketchConstraint, type SketchExternalGeometry, type SketchGeometry, type SketchSnapshot } from './sketcher'
|
||||
import { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
|
||||
import { inspectFcstdArchive } from './fcstd'
|
||||
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
|
||||
@@ -168,6 +168,9 @@ const collectDependencyEdges = (document: Pick<DocumentSnapshot, 'objects'>): De
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const external of object.sketch?.externalGeometry ?? []) {
|
||||
if (objectIds.has(external.source.objectId)) edges.push({ sourceId: object.id, targetId: external.source.objectId, relation: 'topo-ref', propertyName: `ExternalGeometry:${external.id}`, reference: external.source.persistentId })
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
@@ -572,6 +575,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
document.objects[objectIndex].sketch = solved.snapshot
|
||||
const status = document.objects[objectIndex].properties.find((property) => property.name === 'ConstraintStatus')
|
||||
if (status) status.value = solved.status === 'solved' ? 'Fully constrained' : solved.status === 'under-constrained' ? `Under-constrained (${solved.degreesOfFreedom} DOF)` : solved.status === 'conflicting' ? 'Conflicting constraints' : 'Invalid constraints'
|
||||
document.dependencies = collectDependencyEdges(document)
|
||||
markDocumentTouched(document, [objectId])
|
||||
document.version += 1
|
||||
document.dirty = true
|
||||
@@ -580,11 +584,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
}
|
||||
const addSketchGeometry = (objectId: string, geometry: SketchGeometry) => updateSketch(objectId, (sketch) => {
|
||||
if (sketch.geometry.some((candidate) => candidate.id === geometry.id)) throw new RangeError(`Sketch geometry already exists: ${geometry.id}`)
|
||||
sketch.geometry.push({ ...geometry } as SketchGeometry)
|
||||
sketch.geometry.push(cloneSketchGeometry(geometry))
|
||||
})
|
||||
const addSketchExternalGeometry = (objectId: string, external: SketchExternalGeometry) => updateSketch(objectId, (sketch) => {
|
||||
if (!external.id.trim()) throw new RangeError('External geometry id is required.')
|
||||
if (sketch.externalGeometry.some((candidate) => candidate.id === external.id)) throw new RangeError(`External geometry already exists: ${external.id}`)
|
||||
if (external.source.objectId === objectId) throw new RangeError('A sketch cannot import external geometry from itself.')
|
||||
if (external.source.status === 'deleted') throw new RangeError('Deleted topology cannot be imported as external geometry.')
|
||||
sketch.externalGeometry.push({ ...external, source: { ...external.source, candidates: external.source.candidates ? [...external.source.candidates] : undefined }, projection: cloneSketchGeometry(external.projection), construction: true })
|
||||
})
|
||||
const addSketchConstraint = (objectId: string, constraint: SketchConstraint) => updateSketch(objectId, (sketch) => {
|
||||
if (sketch.constraints.some((candidate) => candidate.id === constraint.id)) throw new RangeError(`Sketch constraint already exists: ${constraint.id}`)
|
||||
sketch.constraints.push({ ...constraint } as SketchConstraint)
|
||||
sketch.constraints.push(cloneSketchConstraint(constraint))
|
||||
})
|
||||
const solveSketchObject = (objectId: string) => {
|
||||
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
|
||||
@@ -664,7 +675,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
}
|
||||
|
||||
const facade: BitBybitWebCadFacade = {
|
||||
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 } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, 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, 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, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, 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, 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)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TopoRefValue } from './types'
|
||||
|
||||
export type SketchPoint = { x: number; y: number }
|
||||
|
||||
export type SketchGeometry =
|
||||
@@ -10,6 +12,13 @@ export type SketchGeometry =
|
||||
|
||||
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' }
|
||||
|
||||
export type SketchExternalGeometry = {
|
||||
id: string
|
||||
source: TopoRefValue
|
||||
projection: SketchGeometry
|
||||
construction: true
|
||||
}
|
||||
|
||||
export type SketchConstraint =
|
||||
| { id: string; type: 'coincident'; first: SketchPointRef; second: SketchPointRef; driving?: boolean }
|
||||
| { id: string; type: 'horizontal' | 'vertical'; geometryId: string; driving?: boolean }
|
||||
@@ -34,6 +43,7 @@ export type SketchDiagnostic = {
|
||||
export type SketchSnapshot = {
|
||||
id: string
|
||||
geometry: SketchGeometry[]
|
||||
externalGeometry: SketchExternalGeometry[]
|
||||
constraints: SketchConstraint[]
|
||||
solver: {
|
||||
status: SketchSolverStatus
|
||||
@@ -58,14 +68,14 @@ export type SketchSolveResult = {
|
||||
diagnostics: SketchDiagnostic[]
|
||||
}
|
||||
|
||||
const cloneGeometry = (geometry: SketchGeometry): SketchGeometry => {
|
||||
export const cloneSketchGeometry = (geometry: SketchGeometry): SketchGeometry => {
|
||||
if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } }
|
||||
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') return { ...geometry, center: { ...geometry.center } }
|
||||
if (geometry.type === 'bspline') return { ...geometry, controlPoints: geometry.controlPoints.map((point) => ({ ...point })), weights: geometry.weights ? [...geometry.weights] : undefined, knots: geometry.knots ? [...geometry.knots] : undefined }
|
||||
return { ...geometry, position: { ...geometry.position } }
|
||||
}
|
||||
|
||||
const cloneConstraint = (constraint: SketchConstraint): SketchConstraint => {
|
||||
export const cloneSketchConstraint = (constraint: SketchConstraint): SketchConstraint => {
|
||||
if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second } }
|
||||
if (constraint.type === 'symmetric') return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second }, center: { ...constraint.center } }
|
||||
return { ...constraint }
|
||||
@@ -73,15 +83,17 @@ const cloneConstraint = (constraint: SketchConstraint): SketchConstraint => {
|
||||
|
||||
export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => ({
|
||||
...sketch,
|
||||
geometry: sketch.geometry.map(cloneGeometry),
|
||||
constraints: sketch.constraints.map(cloneConstraint),
|
||||
geometry: sketch.geometry.map(cloneSketchGeometry),
|
||||
externalGeometry: (sketch.externalGeometry ?? []).map((external) => ({ ...external, source: { ...external.source, candidates: external.source.candidates ? [...external.source.candidates] : undefined }, projection: cloneSketchGeometry(external.projection), construction: true })),
|
||||
constraints: sketch.constraints.map(cloneSketchConstraint),
|
||||
solver: { ...sketch.solver, diagnostics: sketch.solver.diagnostics.map((diagnostic) => ({ ...diagnostic })) },
|
||||
})
|
||||
|
||||
export const createSketch = (id: string, geometry: SketchGeometry[] = [], constraints: SketchConstraint[] = []): SketchSnapshot => ({
|
||||
id,
|
||||
geometry: geometry.map(cloneGeometry),
|
||||
constraints: constraints.map(cloneConstraint),
|
||||
geometry: geometry.map(cloneSketchGeometry),
|
||||
externalGeometry: [],
|
||||
constraints: constraints.map(cloneSketchConstraint),
|
||||
solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
|
||||
import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph'
|
||||
import type { Quantity, QuantityDimension } from './units'
|
||||
import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
|
||||
import type { SketchConstraint, SketchExternalGeometry, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
|
||||
import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine'
|
||||
import type { FcstdArchiveLimits, FcstdInspection } from './fcstd'
|
||||
|
||||
@@ -406,6 +406,7 @@ export interface BitBybitWebCadFacade {
|
||||
sketcher: {
|
||||
get(objectId: string): SketchSnapshot | null
|
||||
addGeometry(objectId: string, geometry: SketchGeometry): SketchSnapshot
|
||||
addExternalGeometry(objectId: string, geometry: SketchExternalGeometry): SketchSnapshot
|
||||
addConstraint(objectId: string, constraint: SketchConstraint): SketchSnapshot
|
||||
solve(objectId: string): SketchSolveResult
|
||||
}
|
||||
|
||||
@@ -336,6 +336,28 @@ test('sketcher operations are facade transactions and survive the project fallba
|
||||
assert.equal(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.constraints[0].id, 'horizontal-1')
|
||||
})
|
||||
|
||||
test('external sketch geometry persists stable topology references and dependency edges', async () => {
|
||||
const facade = createMockFacade()
|
||||
const reference = createPersistedTopoRef('pad', { shapeId: 'pad', kind: 'edge', persistentId: 'edge:profile:1', topologyVersion: 2, status: 'stable', signature: 'line:10' }, 4)
|
||||
facade.app.sketcher.addExternalGeometry('sketch', {
|
||||
id: 'external-1',
|
||||
source: reference,
|
||||
projection: { id: 'projected-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } },
|
||||
construction: true,
|
||||
})
|
||||
const sketch = facade.app.sketcher.get('sketch')
|
||||
assert.equal(sketch?.externalGeometry.length, 1)
|
||||
assert.deepEqual(facade.app.document.getDependencies().find((edge) => edge.propertyName === 'ExternalGeometry:external-1'), {
|
||||
sourceId: 'sketch', targetId: 'pad', relation: 'topo-ref', propertyName: 'ExternalGeometry:external-1', reference: 'edge:profile:1',
|
||||
})
|
||||
const persistence = createSqliteProjectPersistence()
|
||||
await persistence.save(facade.app.document.getActive())
|
||||
const loaded = await persistence.load('doc-pump-housing')
|
||||
assert.deepEqual(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.externalGeometry, sketch?.externalGeometry)
|
||||
assert.throws(() => facade.app.sketcher.addExternalGeometry('sketch', { id: 'self', source: { ...reference, objectId: 'sketch' }, projection: { id: 'self-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true }), /cannot import external geometry from itself/)
|
||||
await persistence.dispose()
|
||||
})
|
||||
|
||||
test('Sketcher solve command is selection-aware and emits the standard command lifecycle', () => {
|
||||
const facade = createMockFacade()
|
||||
facade.gui.workbench.setActive('Sketcher')
|
||||
|
||||
Reference in New Issue
Block a user