diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json index 6d952df..89e07e2 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", "pad", "pocket", "revolution", "fillet", "chamfer", "step-export", "stl-export"] }, "document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "sync-recompute", "async-generation-recompute", "undo-redo"] }, - "sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "basic-solver", "persistence"] }, + "sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "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 8c8787f..130f288 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -310,7 +310,7 @@ 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 仍是兼容门禁。 +Sketcher 已有 Facade 事务模型和基础求解器适配器:Sketch 几何/约束快照进入 `DocumentObjectSnapshot`,新增几何、约束和求解会更新 `ConstraintStatus`、历史、自动保存与 SQLite schema v4;基础约束现覆盖 Diameter、Symmetric、Tangent。该适配器的职责是验证数据模型和提供可替换协议;它不宣称已经等价于 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 4355b04..efc07d3 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1590,7 +1590,7 @@ P0 基线/治理 | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | | P1-06 Facade-only 依赖守卫 | `DONE` | `scripts/check-facade-boundary.mjs`,禁止 UI 绕过入口导入 Three.js/SQLite/OPFS/Worker | | P4-01/P4-04 Document/Object 与事务最小切片 | `IN PROGRESS` | Pad/Pocket/Fillet/Chamfer 等已通过 Task 确认追加到 Body;文档版本、dirty、Undo/Redo、异步重算 generation 和过期结果拒绝已接通;真实 Shape 事务、容器规则和原子崩溃恢复仍待实现 | -| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 已落地,函数库、locale、多选 mixed 和持久引用迁移仍待实现 | +| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;基础表达式/单位/Link DAG 和常用数学函数已落地,locale、多选 mixed 和持久引用迁移仍待实现 | | P2-01/P2-02 SQLite schema 与 Persistence Worker | `IN PROGRESS` | `src/facade/projectSchema.ts` 固定 schema v4;`src/facade/persistenceWorker.ts` 独占 SQLite 连接并提供 OPFS/内存模式;`PersistenceWriteQueue` 已保证单写者顺序和失败后续写,1000 次压力、迁移回滚、恢复演练和跨标签写者仍待补齐 | | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 | @@ -1698,7 +1698,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 持久化复用 schema v1 已有 `object_properties` 表:`value_json` 保存完整 Property snapshot,`property_type` 保留可查询类型;删除/重写对象时依赖外键级联清理旧属性。内存降级、autosave、Undo/Redo 和 Facade state 均使用深拷贝,避免 options/property 数组共享引用。 -当前限制:Expression/Quantity 已完成基础词法、四则运算、长度/角度/百分比换算、对象属性引用和维度校验;函数库、locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG,支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。 +当前限制:Expression/Quantity 已完成基础词法、四则运算、长度/角度/百分比换算、对象属性引用、维度校验,以及 `abs/sin/cos/tan/atan2/min/max/clamp/round/pow` 函数的基础维度规则;locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG,支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。 ### 16.17 EXU/DAG/TSN 基础切片验证记录 @@ -1716,7 +1716,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 尚未完成的 TSN 工作:OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。 -Sketcher 当前边界:基础适配器支持点/线/圆/弧、Coincident/Horizontal/Vertical/Distance/Radius/Angle/Equal/Block 的确定性约束处理;完整 FreeCAD `planegcs` 数值求解、冗余约束分类、B-spline、外部几何、自动约束和拖拽交互仍需 SK-03、SK-07、SK-09、SK-10 的 WASM/浏览器回放门禁。 +Sketcher 当前边界:基础适配器支持点/线/圆/弧、Coincident/Horizontal/Vertical/Distance/Radius/Diameter/Angle/Equal/Symmetric/Tangent/Block 的确定性约束处理;完整 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 93df10d..27c8322 100644 --- a/src/facade/sketcher.ts +++ b/src/facade/sketcher.ts @@ -6,15 +6,18 @@ export type SketchGeometry = | { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean } | { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean } -export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' } +export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' } export type SketchConstraint = | { id: string; type: 'coincident'; first: SketchPointRef; second: SketchPointRef; driving?: boolean } | { id: string; type: 'horizontal' | 'vertical'; geometryId: string; driving?: boolean } | { id: string; type: 'distance' | 'distanceX' | 'distanceY'; first: SketchPointRef; second: SketchPointRef; value: number; driving?: boolean } | { id: string; type: 'radius'; geometryId: string; value: number; driving?: boolean } + | { id: string; type: 'diameter'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'angle'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'equal'; firstGeometryId: string; secondGeometryId: string; driving?: boolean } + | { id: string; type: 'symmetric'; first: SketchPointRef; second: SketchPointRef; center: SketchPointRef; driving?: boolean } + | { id: string; type: 'tangent'; firstGeometryId: string; secondGeometryId: string; driving?: boolean } | { id: string; type: 'block'; geometryId: string; driving?: boolean } export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid' @@ -80,7 +83,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') return geometry.position + 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 diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` }) @@ -91,6 +94,15 @@ const pointKey = (ref: SketchPointRef) => `${ref.geometryId}.${ref.point}` const distance = (left: SketchPoint, right: SketchPoint) => Math.hypot(left.x - right.x, left.y - right.y) const lineLength = (geometry: SketchGeometry) => geometry.type === 'line' ? distance(geometry.start, geometry.end) : geometry.type === 'circle' || geometry.type === 'arc' ? geometry.radius : 0 +const lineCircleTangentResidual = (line: SketchGeometry, circle: SketchGeometry) => { + if (line.type !== 'line' || (circle.type !== 'circle' && circle.type !== 'arc')) return Infinity + const dx = line.end.x - line.start.x + const dy = line.end.y - line.start.y + const length = Math.hypot(dx, dy) + if (length === 0) return Infinity + return Math.abs(((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length) - circle.radius +} + const isBlocked = (geometryId: string, blocked: Set) => blocked.has(geometryId) const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set) => { @@ -114,6 +126,10 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius - constraint.value) : Infinity } + if (constraint.type === 'diameter') { + const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) + return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius * 2 - constraint.value) : Infinity + } if (constraint.type === 'angle') { const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) if (!candidate || candidate.type !== 'line') return Infinity @@ -124,6 +140,25 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics) return first && second ? Math.abs(lineLength(first) - lineLength(second)) : Infinity } + if (constraint.type === 'symmetric') { + const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics) + const secondGeometry = findGeometry(geometry, constraint.second.geometryId, constraint.id, diagnostics) + const centerGeometry = findGeometry(geometry, constraint.center.geometryId, constraint.id, diagnostics) + if (!firstGeometry || !secondGeometry || !centerGeometry) return Infinity + const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics) + const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics) + const center = pointFor(centerGeometry, constraint.center.point, constraint.id, diagnostics) + return first && second && center ? Math.hypot((first.x + second.x) / 2 - center.x, (first.y + second.y) / 2 - center.y) : Infinity + } + if (constraint.type === 'tangent') { + const first = findGeometry(geometry, constraint.firstGeometryId, constraint.id, diagnostics) + const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics) + if (!first || !second) return Infinity + if (first.type === 'line' && (second.type === 'circle' || second.type === 'arc')) return Math.abs(lineCircleTangentResidual(first, second)) + if (second.type === 'line' && (first.type === 'circle' || first.type === 'arc')) return Math.abs(lineCircleTangentResidual(second, first)) + if ((first.type === 'circle' || first.type === 'arc') && (second.type === 'circle' || second.type === 'arc')) return Math.abs(distance(first.center, second.center) - first.radius - second.radius) + return Infinity + } if (constraint.type === 'block') return 0 if (constraint.type !== 'coincident' && constraint.type !== 'distance' && constraint.type !== 'distanceX' && constraint.type !== 'distanceY') return Infinity const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics) @@ -162,6 +197,10 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = const candidate = geometryById.get(constraint.geometryId) if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value + } else if (constraint.type === 'diameter') { + const candidate = geometryById.get(constraint.geometryId) + if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } + if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value / 2 } else if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') { const firstGeometry = geometryById.get(constraint.first.geometryId) const secondGeometry = geometryById.get(constraint.second.geometryId) @@ -188,6 +227,38 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = else adjustPoint(secondGeometry, constraint.second.point, { x: second.x, y: first.y + (dy < 0 ? -constraint.value : constraint.value) }, blocked) } } + } else if (constraint.type === 'symmetric') { + const firstGeometry = geometryById.get(constraint.first.geometryId) + const secondGeometry = geometryById.get(constraint.second.geometryId) + const centerGeometry = geometryById.get(constraint.center.geometryId) + if (!firstGeometry || !secondGeometry || !centerGeometry) { findGeometry(snapshot.geometry, !firstGeometry ? constraint.first.geometryId : !secondGeometry ? constraint.second.geometryId : constraint.center.geometryId, constraint.id, diagnostics); continue } + const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics) + const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics) + const center = pointFor(centerGeometry, constraint.center.point, constraint.id, diagnostics) + if (!first || !second || !center) continue + const reflected = { x: 2 * center.x - first.x, y: 2 * center.y - first.y } + if (!isBlocked(secondGeometry.id, blocked)) adjustPoint(secondGeometry, constraint.second.point, reflected, blocked) + } else if (constraint.type === 'tangent') { + const first = geometryById.get(constraint.firstGeometryId) + const second = geometryById.get(constraint.secondGeometryId) + if (!first || !second) { findGeometry(snapshot.geometry, !first ? constraint.firstGeometryId : constraint.secondGeometryId, constraint.id, diagnostics); continue } + const line = first.type === 'line' ? first : second.type === 'line' ? second : null + const circle = first.type === 'line' ? second : second.type === 'line' ? first : null + if (line && circle && (circle.type === 'circle' || circle.type === 'arc') && !isBlocked(circle.id, blocked)) { + const dx = line.end.x - line.start.x + const dy = line.end.y - line.start.y + const length = Math.hypot(dx, dy) || 1 + const signed = ((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length + const delta = circle.radius - Math.abs(signed) + const sign = signed < 0 ? -1 : 1 + circle.center = { x: circle.center.x + dy / length * delta * sign, y: circle.center.y - dx / length * delta * sign } + } else if (first && second && (first.type === 'circle' || first.type === 'arc') && (second.type === 'circle' || second.type === 'arc') && !isBlocked(second.id, blocked)) { + const dx = second.center.x - first.center.x + const dy = second.center.y - first.center.y + const length = Math.hypot(dx, dy) || 1 + const target = first.radius + second.radius + second.center = { x: first.center.x + dx / length * target, y: first.center.y + dy / length * target } + } } else if (constraint.type === 'equal') { const first = geometryById.get(constraint.firstGeometryId) const second = geometryById.get(constraint.secondGeometryId) diff --git a/src/facade/units.ts b/src/facade/units.ts index fd5a291..2cf1627 100644 --- a/src/facade/units.ts +++ b/src/facade/units.ts @@ -63,7 +63,7 @@ const tokenize = (source: string): Token[] => { while (index < source.length) { const character = source[index] if (/\s/.test(character)) { index += 1; continue } - if ('+-*/()'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue } + if ('+-*/(),'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue } if (character === '%') { tokens.push({ kind: 'identifier', text: character, position: index }); index += 1; continue } const number = source.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/) if (number) { tokens.push({ kind: 'number', text: number[0], position: index }); index += number[0].length; continue } @@ -95,6 +95,37 @@ const divide = (left: Quantity, right: Quantity): Quantity => { throw new TypeError(`Cannot divide ${left.dimension} by ${right.dimension}.`) } +const evaluateFunction = (name: string, args: Quantity[]): Quantity => { + const normalized = name.toLowerCase() + if (normalized === 'abs' || normalized === 'round') { + if (args.length !== 1) throw new TypeError(`${name} requires one argument.`) + return { value: normalized === 'abs' ? Math.abs(args[0].value) : Math.round(args[0].value), dimension: args[0].dimension } + } + if (normalized === 'sin' || normalized === 'cos' || normalized === 'tan') { + if (args.length !== 1 || (args[0].dimension !== 'angle' && args[0].dimension !== 'dimensionless')) throw new TypeError(`${name} requires an angle or dimensionless argument.`) + const radians = args[0].dimension === 'angle' ? args[0].value * Math.PI / 180 : args[0].value + return { value: Math[normalized](radians), dimension: 'dimensionless' } + } + if (normalized === 'atan2') { + if (args.length !== 2 || !sameDimension(args[0], args[1])) throw new TypeError('atan2 requires two values with the same dimension.') + return { value: Math.atan2(args[0].value, args[1].value) * 180 / Math.PI, dimension: 'angle' } + } + if (normalized === 'min' || normalized === 'max') { + if (args.length < 1 || args.some((argument) => !sameDimension(argument, args[0]))) throw new TypeError(`${name} requires at least one set of same-dimension values.`) + const values = args.map((argument) => argument.value) + return { value: normalized === 'min' ? Math.min(...values) : Math.max(...values), dimension: args[0].dimension } + } + if (normalized === 'clamp') { + if (args.length !== 3 || !sameDimension(args[0], args[1]) || !sameDimension(args[0], args[2])) throw new TypeError('clamp requires three values with the same dimension.') + return { value: Math.min(Math.max(args[0].value, args[1].value), args[2].value), dimension: args[0].dimension } + } + if (normalized === 'pow') { + if (args.length !== 2 || args[0].dimension !== 'dimensionless' || args[1].dimension !== 'dimensionless') throw new TypeError('pow requires two dimensionless values.') + return { value: Math.pow(args[0].value, args[1].value), dimension: 'dimensionless' } + } + throw new ReferenceError(`Unknown expression function: ${name}.`) +} + class QuantityParser { private index = 0 readonly references = new Set() @@ -157,6 +188,14 @@ class QuantityParser { } if (token.kind === 'identifier') { if (token.text.toLowerCase() === 'pi') return quantityFromNumber(Math.PI) + if (this.match('(')) { + const args: Quantity[] = [] + if (!this.match(')')) { + do args.push(this.parseAddSub()); while (this.match(',')) + if (!this.match(')')) throw new SyntaxError(`Missing ')' at position ${this.peek().position}.`) + } + return evaluateFunction(token.text, args) + } const value = this.variables.get(token.text) if (!value) throw new ReferenceError(`Unknown expression reference: ${token.text}.`) this.references.add(token.text) diff --git a/tests/facade.test.ts b/tests/facade.test.ts index bec5e6b..6843f72 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -125,6 +125,12 @@ test('quantity expressions convert units and reject incompatible dimensions', () assert.equal(evaluateQuantityExpression('2 + 3').value.value, 5) assert.equal(quantityFromNumber(7).dimension, 'dimensionless') assert.equal(evaluateQuantityExpression('50%').value.dimension, 'percent') + assert.ok(Math.abs(evaluateQuantityExpression('sin(90 deg)').value.value - 1) < 1e-12) + assert.equal(evaluateQuantityExpression('max(2 mm, 1 cm)').value.value, 10) + assert.equal(evaluateQuantityExpression('clamp(15, 0, 10)').value.value, 10) + assert.equal(evaluateQuantityExpression('round(2.6)').value.value, 3) + assert.throws(() => evaluateQuantityExpression('sin(2 mm)'), /angle or dimensionless/) + assert.throws(() => evaluateQuantityExpression('unknown(1)'), /Unknown expression function/) }) test('topology signatures are independent of transient face indexes and flag ambiguity', () => { @@ -174,6 +180,24 @@ test('basic sketch solver applies geometric constraints and reports remaining de assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED')) }) +test('Sketcher solver supports diameter, symmetric and tangent constraints at the model boundary', () => { + const sketch = createSketch('advanced-constraints', [ + { id: 'circle-1', type: 'circle', center: { x: 0, y: 1 }, radius: 1 }, + { id: 'line-1', type: 'line', start: { x: -5, y: 0 }, end: { x: 5, y: 0 } }, + { id: 'point-a', type: 'point', position: { x: -2, y: 2 } }, + { id: 'point-b', type: 'point', position: { x: 4, y: 0 } }, + { id: 'center', type: 'point', position: { x: 1, y: 1 } }, + ], [ + { id: 'diameter-1', type: 'diameter', geometryId: 'circle-1', value: 4 }, + { id: 'tangent-1', type: 'tangent', firstGeometryId: 'line-1', secondGeometryId: 'circle-1' }, + { id: 'symmetric-1', type: 'symmetric', first: { geometryId: 'point-a', point: 'position' }, second: { geometryId: 'point-b', point: 'position' }, center: { geometryId: 'center', point: 'position' } }, + ]) + const result = solveSketch(sketch) + assert.ok(result.residual <= 1e-7) + assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract).radius, 2) + assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract).center.y, 2) +}) + test('sketcher operations are facade transactions and survive the project fallback store', async () => { const facade = createMockFacade() const before = facade.app.document.getActive().version