diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 640f264..4ee23dc 100644 --- a/config/compatibility-matrix.json +++ b/config/compatibility-matrix.json @@ -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": [ diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 3adfb0d..735973c 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -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 | 轮廓修改触发正确重算 | diff --git a/src/facade/index.ts b/src/facade/index.ts index 25b0665..154a88f 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -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' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 2e73181..f38cacd 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -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): 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('') }, diff --git a/src/facade/sketcher.ts b/src/facade/sketcher.ts index 52bce0f..512c8f9 100644 --- a/src/facade/sketcher.ts +++ b/src/facade/sketcher.ts @@ -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: [] }, }) diff --git a/src/facade/types.ts b/src/facade/types.ts index 670da21..a11e531 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -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 } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 2b69cbf..27d8c6d 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -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')