From 64b7369918d07ac335dbd3aad022bb11ab206d6b Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 10:29:30 -0400 Subject: [PATCH] P5: expose Sketcher solve command and OCCT dress-up features --- docs/freecad-full-parity-plan.zh-CN.md | 2 ++ docs/web-cad-implementation-plan.zh-CN.md | 1 + src/facade/geometryRuntime.ts | 28 ++++++++++++++++++++++- src/facade/index.ts | 2 +- src/facade/mockFacade.ts | 5 ++-- src/facade/types.ts | 14 ++++++++++++ src/freecadManifest.ts | 2 +- tests/facade.test.ts | 17 +++++++++++++- 8 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 8d9479d..5220f28 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -311,3 +311,5 @@ TSN 已完成面级量化签名、唯一/重复候选判定和匹配 API,并挂接到 Bitbybit `MeshAsset`。签名刻意不使用 transient `faceIndex`;但是它还没有 OCCT 历史 `Generated/Modified/Deleted` 信息,也没有边/顶点和跨特征布尔历史映射。TSN-03、TSN-05、TSN-07、TSN-08 仍是完成 FreeCAD 稳定子形状命名的必要任务。 Sketcher 已有 Facade 事务模型和基础求解器适配器:Sketch 几何/约束快照进入 `DocumentObjectSnapshot`,新增几何、约束和求解会更新 `ConstraintStatus`、历史、自动保存与 SQLite schema v4。该适配器的职责是验证数据模型和提供可替换协议;它不宣称已经等价于 FreeCAD `planegcs`。SK-03 的 planegcs WASM、SK-07 的完整约束分类、SK-09 的交互回放和 SK-10 的 B-spline 仍是兼容门禁。 + +Bitbybit 的 OCCT `filletEdges` 与 `chamferEdges` 已进入 Geometry Facade,支持全边或显式索引、参数验证、句柄版本和 Worker 隔离。由于 OCCT 瞬时边索引不是持久命名,当前接口仍不能作为 FreeCAD PartDesign 的长期选边引用;必须等 TSN 的边级 `TopoRef`、历史映射和参数化重算完成后,才能标记为 `compatible`。 diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 5742a6b..85c7049 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1711,6 +1711,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 | SQLite schema | `PASS` | schema v1→v2 增加 `recompute_json`,v2→v3 增加 dependency property/reference 字段 | | 子形状签名 | `PASS` | 面签名不使用 transient `faceIndex`;重复签名标记 `ambiguous`,唯一候选可匹配 | | Sketcher 领域模型 | `PASS` | Sketch 几何/约束快照、基础求解器、DOF/冲突诊断和 Facade 事务已接入,SQLite schema v4 保存 `sketch_json` | +| OCCT Fillet/Chamfer | `PASS` | Facade 调用 Bitbybit `filletEdges`/`chamferEdges`,半径/距离和边索引在边界校验;真实稳定 TopoRef 选边仍待 TSN | 尚未完成的 TSN 工作:OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。 diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index f7d2dce..aeb1609 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -1,6 +1,6 @@ import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import type { Inputs } from '@bitbybit-dev/occt' -import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef } from './types' +import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, FilletInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle, SubshapeRef } from './types' import { createSubshapeRefs } from './topologyNaming' type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer @@ -102,6 +102,16 @@ export const validateBooleanCutInput = (input: BooleanCutInput) => { export const validateBooleanIntersectionInput = (input: BooleanIntersectionInput) => validateBooleanShapes(input, input.shapes, 2) +const validateEdgeFeature = (input: GeometryDocumentContext & { base: ShapeHandle; indexes?: number[]; radius?: number; distance?: number }, value: number, name: string) => { + validateDocumentContext(input) + validateShapeContext(input, input.base) + finitePositive(value, name) + if (input.indexes?.some((index) => !Number.isSafeInteger(index) || index < 0)) throw new RangeError('Edge indexes must be non-negative safe integers.') +} + +export const validateFilletInput = (input: FilletInput) => validateEdgeFeature(input, input.radius, 'radius') +export const validateChamferInput = (input: ChamferInput) => validateEdgeFeature(input, input.distance, 'distance') + const samePoint = (left: Point3, right: Point3, tolerance = 1e-9) => left.every((coordinate, axis) => Math.abs(coordinate - right[axis]) <= tolerance) const subtract = (left: Point3, right: Point3): Point3 => [left[0] - right[0], left[1] - right[1], left[2] - right[2]] const cross = (left: Point3, right: Point3): Point3 => [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]] @@ -354,6 +364,22 @@ export class BitbybitGeometryRuntime { return this.registerShape(kernelShape, input.documentId, input.documentVersion) } + async fillet(input: FilletInput): Promise { + validateFilletInput(input) + const base = this.resolveShape(input.base).reference + const client = await this.readyClient() + const kernelShape = await client.occt.fillets.filletEdges({ shape: base, radius: input.radius, indexes: input.indexes }) + return this.registerShape(kernelShape, input.documentId, input.documentVersion) + } + + async chamfer(input: ChamferInput): Promise { + validateChamferInput(input) + const base = this.resolveShape(input.base).reference + const client = await this.readyClient() + const kernelShape = await client.occt.fillets.chamferEdges({ shape: base, distance: input.distance, indexes: input.indexes }) + return this.registerShape(kernelShape, input.documentId, input.documentVersion) + } + async pad(input: PadInput): Promise { validatePadInput(input) const client = await this.readyClient() diff --git a/src/facade/index.ts b/src/facade/index.ts index f0bc5ef..2eb3e60 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -3,7 +3,7 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS export { ThreeViewportAdapter } from './threeViewport' export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' -export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' +export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming' export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index d5f2020..d1a1fbe 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -128,7 +128,7 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => { return document } -const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area']) +const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch']) const systemCommands = new Set(['new-document', 'save', 'select-object']) const featureCommands: Record = { 'create-body': { label: 'Body', detail: 'Part Design body' }, @@ -411,6 +411,7 @@ export function createMockFacade(): BitBybitWebCadFacade { }) } else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId) + else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') } else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null }) emit({ type: 'command.completed', commandId, context }); emitState(); return requestId } @@ -422,7 +423,7 @@ export function createMockFacade(): BitBybitWebCadFacade { selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource }, - geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, + geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/src/facade/types.ts b/src/facade/types.ts index 3dc6b6e..f67169d 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -193,6 +193,18 @@ export type BooleanIntersectionInput = GeometryDocumentContext & { keepEdges?: boolean } +export type FilletInput = GeometryDocumentContext & { + base: ShapeHandle + radius: number + indexes?: number[] +} + +export type ChamferInput = GeometryDocumentContext & { + base: ShapeHandle + distance: number + indexes?: number[] +} + export type Point3 = [number, number, number] export type PlanarProfile = { @@ -362,6 +374,8 @@ export interface BitBybitWebCadFacade { union(input: BooleanUnionInput): Promise cut(input: BooleanCutInput): Promise intersection(input: BooleanIntersectionInput): Promise + fillet(input: FilletInput): Promise + chamfer(input: ChamferInput): Promise pad(input: PadInput): Promise pocket(input: PocketInput): Promise revolution(input: RevolutionInput): Promise diff --git a/src/freecadManifest.ts b/src/freecadManifest.ts index 7480431..68952ce 100644 --- a/src/freecadManifest.ts +++ b/src/freecadManifest.ts @@ -60,7 +60,7 @@ export const workbenchDefinitions: Record = { { label: 'Sketch', commands: [command('new-sketch', 'Create sketch', 'file-plus', 'create', 'G, N'), command('edit-sketch-mode', 'Edit sketch', 'pencil', 'edit'), command('close-sketch', 'Close sketch', 'x', 'edit')] }, { label: 'Geometry', commands: [command('line', 'Create polyline', 'minus', 'create', 'G, M'), command('arc', 'Create arc', 'circle', 'create'), command('circle', 'Create circle', 'circle-dot', 'create'), command('rectangle', 'Create rectangle', 'square', 'create'), command('trim', 'Trim geometry', 'scissors', 'edit')] }, { label: 'Constraints', commands: [command('constrain-horizontal', 'Horizontal', 'move-horizontal', 'create'), command('constrain-vertical', 'Vertical', 'move-vertical', 'create'), command('constrain-coincident', 'Coincident', 'circle-dot', 'create'), command('constrain-dimension', 'Constrain dimension', 'ruler', 'create')] }, - { label: 'Solver', commands: [command('toggle-auto-constraints', 'Auto constraints', 'wand-sparkles', 'view'), command('toggle-degrees', 'Show degrees of freedom', 'activity', 'view')] }, + { label: 'Solver', commands: [command('solve-sketch', 'Solve sketch', 'circle-check', 'inspect'), command('toggle-auto-constraints', 'Auto constraints', 'wand-sparkles', 'view'), command('toggle-degrees', 'Show degrees of freedom', 'activity', 'view')] }, ], }, Draft: { diff --git a/tests/facade.test.ts b/tests/facade.test.ts index b7395d4..005eb8d 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -7,7 +7,7 @@ import { DependencyGraph } from '../src/facade/dependencyGraph' import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units' import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming' import { createSketch, solveSketch } from '../src/facade/sketcher' -import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' +import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime' import type { ShapeHandle } from '../src/facade/types' test('facade exposes a stable initial document projection', () => { @@ -45,6 +45,8 @@ test('boolean geometry rejects cross-document and future-version operands', () = assert.throws(() => validateBooleanUnionInput({ shapes: [first, { ...second, documentId: 'doc-two' }], documentId: 'doc-one', documentVersion: 3 }), /another document/) assert.throws(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 2 }), /newer/) assert.throws(() => validateBooleanUnionInput({ shapes: [first], documentId: 'doc-one', documentVersion: 2 }), /at least 2/) + assert.throws(() => validateFilletInput({ base: first, radius: 0, documentId: 'doc-one', documentVersion: 2 }), /radius/) + assert.throws(() => validateChamferInput({ base: first, distance: 1, indexes: [-1], documentId: 'doc-one', documentVersion: 2 }), /indexes/) }) test('feature profiles reject degenerate and non-planar geometry', () => { @@ -145,6 +147,19 @@ 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('Sketcher solve command is selection-aware and emits the standard command lifecycle', () => { + const facade = createMockFacade() + facade.gui.workbench.setActive('Sketcher') + facade.selection.select('sketch') + const events: string[] = [] + facade.subscribe((event) => { if (event.type.startsWith('command.')) events.push(event.type) }) + facade.gui.command.execute({ commandId: 'solve-sketch' }) + assert.deepEqual(events, ['command.started', 'command.completed']) + assert.equal(facade.app.document.getObject('sketch')?.sketch?.solver.status, 'solved') + facade.selection.clear() + assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled') +}) + test('dependency graph propagates dirty state and orders dependencies', () => { const graph = new DependencyGraph([ { sourceId: 'pocket', targetId: 'pad', relation: 'link' },