diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 86491ae..640f264 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", "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", "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 4d0089c..3adfb0d 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -107,7 +107,7 @@ | SK-02 | 定义 Sketch 文档对象、几何索引和构造标志 | P1 | SQLite/Facade 可往返 | | SK-03 | 编译 planegcs 求解器 WASM POC | P0-RT | 直线/圆/弧约束结果与 FreeCAD 一致 | | SK-04 | 确定性求解协议和 Worker 生命周期 | SK-03 | 取消/过期版本不会提交 | -| SK-05 | 基础几何 API:点、线、圆、弧、椭圆、B-spline | SK-02 | 编辑/撤销/重做完整 | +| 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 | 选择引用可稳定重算 | diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index fbb673e..fa9fee9 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1725,7 +1725,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 尚未完成的 TSN 工作:OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。 -Sketcher 当前边界:基础适配器支持点/线/圆/弧、Coincident/Horizontal/Vertical/Distance/Radius/Diameter/Angle/Equal/Symmetric/Tangent/Block 的确定性约束处理;SK-04 已增加版本化 provider 协议、能力探测、取消/过期结果隔离和回放合同。仓库没有 FreeCAD 源码或 planegcs WASM 产物,因此对应 provider 明确返回 unavailable;完整 FreeCAD `planegcs` 数值求解、冗余约束分类、B-spline、外部几何、自动约束和拖拽交互仍需 SK-03、SK-07、SK-09、SK-10 的 WASM/浏览器回放门禁。 +Sketcher 当前边界:领域模型可往返保存点、线、圆、弧、椭圆和 B-spline(次数、控制点、权重、节点、周期标志);基础适配器只对前四类及 Coincident/Horizontal/Vertical/Distance/Radius/Diameter/Angle/Equal/Symmetric/Tangent/Block 做确定性约束处理,对椭圆/B-spline 返回明确 unsupported 诊断。SK-04 已增加版本化 provider 协议、能力探测、取消/过期结果隔离和回放合同。仓库没有 FreeCAD 源码或 planegcs WASM 产物,因此对应 provider 明确返回 unavailable;完整 FreeCAD `planegcs` 数值求解、冗余约束分类、B-spline 求解、外部几何、自动约束和拖拽交互仍需 SK-03、SK-07、SK-09、SK-10 的 WASM/浏览器回放门禁。 ### 16.16 引擎版本修复与完整 FreeCAD 对标专项 diff --git a/src/facade/sketcher.ts b/src/facade/sketcher.ts index 27c8322..52bce0f 100644 --- a/src/facade/sketcher.ts +++ b/src/facade/sketcher.ts @@ -5,6 +5,8 @@ export type SketchGeometry = | { id: string; type: 'line'; start: SketchPoint; end: SketchPoint; construction?: boolean } | { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean } | { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean } + | { id: string; type: 'ellipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; construction?: boolean } + | { id: string; type: 'bspline'; degree: number; controlPoints: SketchPoint[]; weights?: number[]; knots?: number[]; periodic?: boolean; construction?: boolean } export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' } @@ -23,7 +25,8 @@ export type SketchConstraint = export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid' export type SketchDiagnostic = { - code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED' + code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'UNSUPPORTED_GEOMETRY' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED' + geometryId?: string constraintId?: string message: string } @@ -57,22 +60,28 @@ export type SketchSolveResult = { const cloneGeometry = (geometry: SketchGeometry): SketchGeometry => { if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } } - if (geometry.type === 'circle') return { ...geometry, center: { ...geometry.center } } - if (geometry.type === 'arc') return { ...geometry, center: { ...geometry.center } } + 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 => { + 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 } +} + export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => ({ ...sketch, geometry: sketch.geometry.map(cloneGeometry), - constraints: sketch.constraints.map((constraint) => ({ ...constraint })), + constraints: sketch.constraints.map(cloneConstraint), 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((constraint) => ({ ...constraint })), + constraints: constraints.map(cloneConstraint), solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] }, }) @@ -85,7 +94,7 @@ const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: stri const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => { if (geometry.type === 'point' && point === 'position') return geometry.position if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end - if ((geometry.type === 'circle' || geometry.type === 'arc') && point === 'center') return geometry.center + if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') && point === 'center') return geometry.center diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` }) return null } @@ -109,7 +118,7 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n if (isBlocked(geometry.id, blocked)) return if (geometry.type === 'point') { geometry.position = { ...next }; return } if (geometry.type === 'line') { if (point === 'start') geometry.start = { ...next }; else if (point === 'end') geometry.end = { ...next }; return } - if (point === 'center') geometry.center = { ...next } + if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse')) geometry.center = { ...next } } const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => { @@ -178,6 +187,9 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = const maxIterations = options.maxIterations ?? 64 const snapshot = cloneSketch(input) const diagnostics: SketchDiagnostic[] = [] + for (const geometry of snapshot.geometry) { + if (geometry.type === 'ellipse' || geometry.type === 'bspline') diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` }) + } const geometryById = new Map(snapshot.geometry.map((geometry) => [geometry.id, geometry])) const blocked = new Set(snapshot.constraints.filter((constraint) => constraint.type === 'block').map((constraint) => constraint.geometryId)) snapshot.constraints.forEach((constraint) => validateConstraintValues(constraint, diagnostics)) @@ -275,7 +287,13 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = } residual = Math.max(0, ...snapshot.constraints.map((constraint) => residualFor(constraint, snapshot.geometry, diagnostics))) } - const variableCount = snapshot.geometry.reduce((count, geometry) => count + (geometry.type === 'point' ? 2 : geometry.type === 'line' ? 4 : geometry.type === 'circle' ? 3 : 5), 0) + const variableCount = snapshot.geometry.reduce((count, geometry) => { + if (geometry.type === 'point') return count + 2 + if (geometry.type === 'line') return count + 4 + if (geometry.type === 'circle') return count + 3 + if (geometry.type === 'arc' || geometry.type === 'ellipse') return count + 5 + return count + geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0) + }, 0) const rank = Math.min(variableCount, snapshot.constraints.filter((constraint) => constraint.type !== 'block' || !isBlocked(constraint.geometryId, blocked)).length + blocked.size * 2) const degreesOfFreedom = Math.max(0, variableCount - rank) const status: SketchSolverStatus = diagnostics.length > 0 ? 'invalid' : residual <= tolerance ? degreesOfFreedom === 0 ? 'solved' : 'under-constrained' : 'conflicting' diff --git a/tests/facade.test.ts b/tests/facade.test.ts index b3b3c62..2b69cbf 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -9,7 +9,7 @@ import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/un import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming' import { createPersistedTopoRef, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences' import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory' -import { createSketch, solveSketch } from '../src/facade/sketcher' +import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher' import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol' import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine' import { inspectFcstdArchive } from '../src/facade/fcstd' @@ -255,6 +255,25 @@ test('Sketcher solver supports diameter, symmetric and tangent constraints at th assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract).center.y, 2) }) +test('Sketcher model round-trips ellipse and B-spline data without claiming solver support', () => { + const source = createSketch('advanced-curves', [ + { id: 'ellipse-1', type: 'ellipse', center: { x: 1, y: 2 }, majorRadius: 8, minorRadius: 3, rotation: 0.25 }, + { id: 'bspline-1', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 4 }, { x: 6, y: 2 }, { x: 8, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1] }, + ], [{ id: 'block-ellipse', type: 'block', geometryId: 'ellipse-1' }]) + const cloned = cloneSketch(source) + const ellipse = cloned.geometry[0] + const spline = cloned.geometry[1] + assert.equal(ellipse.type, 'ellipse') + assert.equal(spline.type, 'bspline') + if (ellipse.type === 'ellipse') ellipse.center.x = 99 + if (spline.type === 'bspline') { spline.controlPoints[0].x = 99; spline.weights![0] = 2; spline.knots![0] = 3 } + assert.deepEqual(source.geometry[0], { id: 'ellipse-1', type: 'ellipse', center: { x: 1, y: 2 }, majorRadius: 8, minorRadius: 3, rotation: 0.25 }) + assert.deepEqual(source.geometry[1], { id: 'bspline-1', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 4 }, { x: 6, y: 2 }, { x: 8, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1] }) + const result = solveSketch(source) + assert.equal(result.status, 'invalid') + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ['UNSUPPORTED_GEOMETRY', 'UNSUPPORTED_GEOMETRY']) +}) + test('sketch solver protocol exposes honest capabilities and deterministic replay', async () => { const provider = new BasicSketchSolverProvider() assert.deepEqual(provider.capabilities().engine, 'typescript-basic')