P4: extend sketch constraints and expression functions

This commit is contained in:
2026-08-02 17:35:56 -04:00
parent 9c76f33f3d
commit 171fd670af
6 changed files with 142 additions and 8 deletions

View File

@@ -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": [

View File

@@ -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`

View File

@@ -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 对标专项

View File

@@ -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<string>) => blocked.has(geometryId)
const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set<string>) => {
@@ -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)

View File

@@ -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<string>()
@@ -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)

View File

@@ -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<typeof result.snapshot.geometry[number], { type: 'circle' }>).radius, 2)
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).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