P5: add facade sketch model solver and persistence
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"frontendFramework": "React 18 + Vite 6 + TypeScript 5",
|
"frontendFramework": "React 18 + Vite 6 + TypeScript 5",
|
||||||
"geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "core-feature-integration" },
|
"geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "core-feature-integration" },
|
||||||
"viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "occt-mesh-integration" },
|
"viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "occt-mesh-integration" },
|
||||||
"persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v3-resource-autosave-properties-dependencies" },
|
"persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v4-resource-autosave-properties-dependencies-sketch" },
|
||||||
"targetBrowsers": "Capability matrix to be locked in P0-02",
|
"targetBrowsers": "Capability matrix to be locked in P0-02",
|
||||||
"status": "expression-dag-and-subshape-signature-integration"
|
"status": "expression-dag-and-subshape-signature-integration"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,3 +309,5 @@
|
|||||||
本轮已完成 `EXU-02..EXU-06` 的基础版本:Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本:Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker,因此当前是可验证的业务调度层,不是完整几何重算。
|
本轮已完成 `EXU-02..EXU-06` 的基础版本:Quantity 以 mm/deg 等规范单位保存,表达式支持四则运算、对象属性引用和维度错误;已完成 `DAG-01..DAG-06` 的领域版本:Link/Expression 边、下游传播、SCC 循环检测、稳定拓扑计划、generation 和 recompute 状态已进入 Facade 与 SQLite schema v3。还未把每个 DAG 节点接入真实 OCCT/Sketcher 计算 Worker,因此当前是可验证的业务调度层,不是完整几何重算。
|
||||||
|
|
||||||
TSN 已完成面级量化签名、唯一/重复候选判定和匹配 API,并挂接到 Bitbybit `MeshAsset`。签名刻意不使用 transient `faceIndex`;但是它还没有 OCCT 历史 `Generated/Modified/Deleted` 信息,也没有边/顶点和跨特征布尔历史映射。TSN-03、TSN-05、TSN-07、TSN-08 仍是完成 FreeCAD 稳定子形状命名的必要任务。
|
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 仍是兼容门禁。
|
||||||
|
|||||||
@@ -1710,9 +1710,12 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值
|
|||||||
| 循环检测 | `PASS` | `pad.Length ↔ pocket.Length` 返回 `DEPENDENCY_CYCLE`,不提交伪造重算结果 |
|
| 循环检测 | `PASS` | `pad.Length ↔ pocket.Length` 返回 `DEPENDENCY_CYCLE`,不提交伪造重算结果 |
|
||||||
| SQLite schema | `PASS` | schema v1→v2 增加 `recompute_json`,v2→v3 增加 dependency property/reference 字段 |
|
| SQLite schema | `PASS` | schema v1→v2 增加 `recompute_json`,v2→v3 增加 dependency property/reference 字段 |
|
||||||
| 子形状签名 | `PASS` | 面签名不使用 transient `faceIndex`;重复签名标记 `ambiguous`,唯一候选可匹配 |
|
| 子形状签名 | `PASS` | 面签名不使用 transient `faceIndex`;重复签名标记 `ambiguous`,唯一候选可匹配 |
|
||||||
|
| Sketcher 领域模型 | `PASS` | Sketch 几何/约束快照、基础求解器、DOF/冲突诊断和 Facade 事务已接入,SQLite schema v4 保存 `sketch_json` |
|
||||||
|
|
||||||
尚未完成的 TSN 工作:OCCT `Generated/Modified/Deleted` 历史捕获、跨布尔/特征的真实拓扑映射、面/边/顶点统一命名、附着和 `TopoRef` 文件迁移。当前签名是可审计的基础候选层,不能单独宣称 FreeCAD 稳定拓扑命名已完成。
|
尚未完成的 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/浏览器回放门禁。
|
||||||
|
|
||||||
### 16.16 引擎版本修复与完整 FreeCAD 对标专项
|
### 16.16 引擎版本修复与完整 FreeCAD 对标专项
|
||||||
|
|
||||||
本项目原先以系统 Node `v20.19.2` 执行 npm,而依赖要求 Node 22,因而出现 `EBADENGINE`。现已增加项目内、SHA-256 校验的 Node `22.23.2`/npm `10.9.8` 运行时;日常命令统一使用 `./npmw` 或 `./nodew`,系统解释器不被修改。详见 [项目运行时基线](project-runtime.zh-CN.md)。
|
本项目原先以系统 Node `v20.19.2` 执行 npm,而依赖要求 Node 22,因而出现 `EBADENGINE`。现已增加项目内、SHA-256 校验的 Node `22.23.2`/npm `10.9.8` 运行时;日常命令统一使用 `./npmw` 或 `./nodew`,系统解释器不被修改。详见 [项目运行时基线](project-runtime.zh-CN.md)。
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitM
|
|||||||
export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema'
|
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, 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 { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming'
|
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'
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { BitbybitGeometryRuntime } from './geometryRuntime'
|
|||||||
import { ThreeViewportAdapter } from './threeViewport'
|
import { ThreeViewportAdapter } from './threeViewport'
|
||||||
import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph'
|
import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph'
|
||||||
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
|
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
|
||||||
|
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
|
||||||
|
|
||||||
const initialTree: ModelTreeItem[] = [
|
const initialTree: ModelTreeItem[] = [
|
||||||
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
|
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
|
||||||
@@ -81,12 +82,12 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()] })
|
const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined })
|
||||||
|
|
||||||
const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({
|
const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({
|
||||||
...document,
|
...document,
|
||||||
tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })),
|
tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })),
|
||||||
objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) })),
|
objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })),
|
||||||
dependencies: document.dependencies?.map((edge) => ({ ...edge })),
|
dependencies: document.dependencies?.map((edge) => ({ ...edge })),
|
||||||
recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined,
|
recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined,
|
||||||
})
|
})
|
||||||
@@ -242,7 +243,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
|||||||
if (body) body.children = [...(body.children || []), objectId]
|
if (body) body.children = [...(body.children || []), objectId]
|
||||||
tree.push(item)
|
tree.push(item)
|
||||||
}
|
}
|
||||||
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })) })), createObjectSnapshot(item)]
|
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), createObjectSnapshot(item)]
|
||||||
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
|
const nextDocument: DocumentSnapshot = { ...document, version: document.version + 1, dirty: true, tree, objects }
|
||||||
nextDocument.dependencies = collectDependencyEdges(nextDocument)
|
nextDocument.dependencies = collectDependencyEdges(nextDocument)
|
||||||
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
|
nextDocument.recompute = createRecomputeSnapshot(objects.map((object) => object.id), document.recompute?.generation ?? 0)
|
||||||
@@ -331,6 +332,52 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
|||||||
emitState()
|
emitState()
|
||||||
return { ...plan, generation, status: nextRecompute.status, errors }
|
return { ...plan, generation, status: nextRecompute.status, errors }
|
||||||
}
|
}
|
||||||
|
const getSketch = (objectId: string) => {
|
||||||
|
const object = state.document.objects.find((candidate) => candidate.id === objectId)
|
||||||
|
return object?.sketch ? cloneSketch(object.sketch) : null
|
||||||
|
}
|
||||||
|
const updateSketch = (objectId: string, update: (sketch: SketchSnapshot) => void) => {
|
||||||
|
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
|
||||||
|
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
|
||||||
|
const source = state.document.objects[objectIndex]
|
||||||
|
if (!source.sketch) throw new TypeError(`${objectId} is not a Sketcher object.`)
|
||||||
|
const document = cloneDocumentSnapshot(state.document)
|
||||||
|
const sketch = document.objects[objectIndex].sketch as SketchSnapshot
|
||||||
|
update(sketch)
|
||||||
|
const solved = solveSketch(sketch)
|
||||||
|
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'
|
||||||
|
markDocumentTouched(document, [objectId])
|
||||||
|
document.version += 1
|
||||||
|
document.dirty = true
|
||||||
|
commit({ ...state, document })
|
||||||
|
return cloneSketch(solved.snapshot)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
const solveSketchObject = (objectId: string) => {
|
||||||
|
const objectIndex = state.document.objects.findIndex((object) => object.id === objectId)
|
||||||
|
if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`)
|
||||||
|
const source = state.document.objects[objectIndex]
|
||||||
|
if (!source.sketch) throw new TypeError(`${objectId} is not a Sketcher object.`)
|
||||||
|
const result = solveSketch(source.sketch)
|
||||||
|
const document = cloneDocumentSnapshot(state.document)
|
||||||
|
document.objects[objectIndex].sketch = result.snapshot
|
||||||
|
const status = document.objects[objectIndex].properties.find((property) => property.name === 'ConstraintStatus')
|
||||||
|
if (status) status.value = result.status === 'solved' ? 'Fully constrained' : result.status === 'under-constrained' ? `Under-constrained (${result.degreesOfFreedom} DOF)` : result.status === 'conflicting' ? 'Conflicting constraints' : 'Invalid constraints'
|
||||||
|
markDocumentTouched(document, [objectId])
|
||||||
|
document.version += 1
|
||||||
|
document.dirty = true
|
||||||
|
commit({ ...state, document })
|
||||||
|
return result
|
||||||
|
}
|
||||||
const applyTask = () => {
|
const applyTask = () => {
|
||||||
const task = state.task
|
const task = state.task
|
||||||
if (!task || task.status !== 'preview') return
|
if (!task || task.status !== 'preview') return
|
||||||
@@ -369,7 +416,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const facade: 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 })) } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit } },
|
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) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, 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 } },
|
||||||
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
|
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; 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), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const saveDocument = (document: DocumentSnapshot) => {
|
|||||||
database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] })
|
database.exec({ sql: 'DELETE FROM objects WHERE document_id = ?', bind: [document.id] })
|
||||||
const parentByChild = new Map<string, string>()
|
const parentByChild = new Map<string, string>()
|
||||||
for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id)
|
for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id)
|
||||||
document.tree.forEach((item, ordinal) => database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal] }))
|
document.tree.forEach((item, ordinal) => { const object = document.objects.find((candidate) => candidate.id === item.id); database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal, sketch_json) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal, object?.sketch ? JSON.stringify(object.sketch) : null] }) })
|
||||||
for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] })
|
for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] })
|
||||||
database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] })
|
database.exec({ sql: 'DELETE FROM dependencies WHERE document_id = ?', bind: [document.id] })
|
||||||
for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] })
|
for (const edge of document.dependencies ?? []) database.exec({ sql: 'INSERT INTO dependencies(document_id, source_id, target_id, relation, property_name, reference) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, edge.sourceId, edge.targetId, edge.relation, edge.propertyName ?? null, edge.reference ?? null] })
|
||||||
@@ -103,7 +103,7 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
|
|||||||
const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
|
const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
|
||||||
const row = documents[0]
|
const row = documents[0]
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
|
const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json, sketch_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | number | null>>
|
||||||
const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
|
const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string>>
|
||||||
const dependencyRows = database.exec({ sql: 'SELECT source_id, target_id, relation, property_name, reference FROM dependencies WHERE document_id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | null>>
|
const dependencyRows = database.exec({ sql: 'SELECT source_id, target_id, relation, property_name, reference FROM dependencies WHERE document_id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array<Record<string, string | null>>
|
||||||
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
|
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
|
||||||
@@ -116,7 +116,8 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => {
|
|||||||
const objectSnapshots: DocumentObjectSnapshot[] = tree.map((item) => {
|
const objectSnapshots: DocumentObjectSnapshot[] = tree.map((item) => {
|
||||||
const properties = propertiesByObject.get(item.id) ?? []
|
const properties = propertiesByObject.get(item.id) ?? []
|
||||||
const typeId = properties.find((property) => property.name === 'TypeId')?.value
|
const typeId = properties.find((property) => property.name === 'TypeId')?.value
|
||||||
return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties }
|
const row = objects.find((candidate) => String(candidate.id) === item.id)
|
||||||
|
return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties, sketch: row?.sketch_json ? JSON.parse(String(row.sketch_json)) : undefined }
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const PROJECT_SCHEMA_VERSION = 3
|
export const PROJECT_SCHEMA_VERSION = 4
|
||||||
|
|
||||||
export const PROJECT_SCHEMA_SQL = `
|
export const PROJECT_SCHEMA_SQL = `
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -88,4 +88,5 @@ export const PROJECT_SCHEMA_MIGRATIONS = [
|
|||||||
{ version: 1, sql: PROJECT_SCHEMA_SQL },
|
{ version: 1, sql: PROJECT_SCHEMA_SQL },
|
||||||
{ version: 2, sql: `ALTER TABLE documents ADD COLUMN recompute_json TEXT NOT NULL DEFAULT '{"generation":0,"status":"idle","objectStates":{},"dirtyObjects":[],"order":[],"errors":[]}';` },
|
{ version: 2, sql: `ALTER TABLE documents ADD COLUMN recompute_json TEXT NOT NULL DEFAULT '{"generation":0,"status":"idle","objectStates":{},"dirtyObjects":[],"order":[],"errors":[]}';` },
|
||||||
{ version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' },
|
{ version: 3, sql: 'ALTER TABLE dependencies ADD COLUMN property_name TEXT; ALTER TABLE dependencies ADD COLUMN reference TEXT;' },
|
||||||
|
{ version: 4, sql: 'ALTER TABLE objects ADD COLUMN sketch_json TEXT;' },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types'
|
import type { DocumentSnapshot, PersistenceCapabilities, ProjectResource, ProjectSaveResult } from './types'
|
||||||
|
import { cloneSketch } from './sketcher'
|
||||||
|
|
||||||
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
|
type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
|
||||||
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
|
type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
|
||||||
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
|
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }
|
||||||
|
|
||||||
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
|
const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
|
||||||
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
|
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })
|
||||||
|
|
||||||
export interface ProjectPersistenceClient {
|
export interface ProjectPersistenceClient {
|
||||||
initialize(): Promise<PersistenceCapabilities>
|
initialize(): Promise<PersistenceCapabilities>
|
||||||
|
|||||||
222
src/facade/sketcher.ts
Normal file
222
src/facade/sketcher.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
export type SketchPoint = { x: number; y: number }
|
||||||
|
|
||||||
|
export type SketchGeometry =
|
||||||
|
| { id: string; type: 'point'; position: SketchPoint; construction?: boolean }
|
||||||
|
| { 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 }
|
||||||
|
|
||||||
|
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' }
|
||||||
|
|
||||||
|
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: 'angle'; geometryId: string; value: number; driving?: boolean }
|
||||||
|
| { id: string; type: 'equal'; firstGeometryId: string; secondGeometryId: string; driving?: boolean }
|
||||||
|
| { id: string; type: 'block'; geometryId: string; driving?: boolean }
|
||||||
|
|
||||||
|
export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid'
|
||||||
|
|
||||||
|
export type SketchDiagnostic = {
|
||||||
|
code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED'
|
||||||
|
constraintId?: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SketchSnapshot = {
|
||||||
|
id: string
|
||||||
|
geometry: SketchGeometry[]
|
||||||
|
constraints: SketchConstraint[]
|
||||||
|
solver: {
|
||||||
|
status: SketchSolverStatus
|
||||||
|
degreesOfFreedom: number
|
||||||
|
residual: number
|
||||||
|
iterations: number
|
||||||
|
diagnostics: SketchDiagnostic[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SketchSolveOptions = {
|
||||||
|
tolerance?: number
|
||||||
|
maxIterations?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SketchSolveResult = {
|
||||||
|
snapshot: SketchSnapshot
|
||||||
|
status: SketchSolverStatus
|
||||||
|
degreesOfFreedom: number
|
||||||
|
residual: number
|
||||||
|
iterations: number
|
||||||
|
diagnostics: SketchDiagnostic[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } }
|
||||||
|
return { ...geometry, position: { ...geometry.position } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => ({
|
||||||
|
...sketch,
|
||||||
|
geometry: sketch.geometry.map(cloneGeometry),
|
||||||
|
constraints: sketch.constraints.map((constraint) => ({ ...constraint })),
|
||||||
|
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 })),
|
||||||
|
solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: string, diagnostics: SketchDiagnostic[]) => {
|
||||||
|
const result = geometry.find((candidate) => candidate.id === id)
|
||||||
|
if (!result) diagnostics.push({ code: 'UNKNOWN_GEOMETRY', constraintId, message: `Sketch geometry '${id}' does not exist.` })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => {
|
||||||
|
if (geometry.type === 'point') 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}'.` })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isBlocked = (geometryId: string, blocked: Set<string>) => blocked.has(geometryId)
|
||||||
|
|
||||||
|
const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set<string>) => {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => {
|
||||||
|
if ('value' in constraint && (!Number.isFinite(constraint.value) || constraint.value < 0)) diagnostics.push({ code: 'INVALID_VALUE', constraintId: constraint.id, message: `Constraint '${constraint.id}' requires a finite non-negative value.` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], diagnostics: SketchDiagnostic[]): number => {
|
||||||
|
if (constraint.type === 'horizontal' || constraint.type === 'vertical') {
|
||||||
|
const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
|
||||||
|
if (!candidate || candidate.type !== 'line') return Infinity
|
||||||
|
return constraint.type === 'horizontal' ? Math.abs(candidate.start.y - candidate.end.y) : Math.abs(candidate.start.x - candidate.end.x)
|
||||||
|
}
|
||||||
|
if (constraint.type === 'radius') {
|
||||||
|
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 === 'angle') {
|
||||||
|
const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics)
|
||||||
|
if (!candidate || candidate.type !== 'line') return Infinity
|
||||||
|
return Math.abs(Math.atan2(candidate.end.y - candidate.start.y, candidate.end.x - candidate.start.x) - constraint.value)
|
||||||
|
}
|
||||||
|
if (constraint.type === 'equal') {
|
||||||
|
const first = findGeometry(geometry, constraint.firstGeometryId, constraint.id, diagnostics)
|
||||||
|
const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics)
|
||||||
|
return first && second ? Math.abs(lineLength(first) - lineLength(second)) : 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)
|
||||||
|
const secondGeometry = findGeometry(geometry, constraint.second.geometryId, constraint.id, diagnostics)
|
||||||
|
if (!firstGeometry || !secondGeometry) return Infinity
|
||||||
|
const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics)
|
||||||
|
const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics)
|
||||||
|
if (!first || !second) return Infinity
|
||||||
|
if (constraint.type === 'coincident') return distance(first, second)
|
||||||
|
if (constraint.type === 'distance') return Math.abs(distance(first, second) - constraint.value)
|
||||||
|
if (constraint.type === 'distanceX') return Math.abs(Math.abs(second.x - first.x) - constraint.value)
|
||||||
|
return Math.abs(Math.abs(second.y - first.y) - constraint.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = {}): SketchSolveResult => {
|
||||||
|
const tolerance = options.tolerance ?? 1e-7
|
||||||
|
const maxIterations = options.maxIterations ?? 64
|
||||||
|
const snapshot = cloneSketch(input)
|
||||||
|
const diagnostics: SketchDiagnostic[] = []
|
||||||
|
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))
|
||||||
|
let residual = Infinity
|
||||||
|
let iterations = 0
|
||||||
|
for (; iterations < maxIterations && residual > tolerance && diagnostics.length === 0; iterations += 1) {
|
||||||
|
for (const constraint of snapshot.constraints) {
|
||||||
|
if (constraint.type === 'block') continue
|
||||||
|
if (constraint.type === 'horizontal' || constraint.type === 'vertical') {
|
||||||
|
const candidate = geometryById.get(constraint.geometryId)
|
||||||
|
if (!candidate || candidate.type !== 'line') { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue }
|
||||||
|
if (!isBlocked(candidate.id, blocked)) {
|
||||||
|
if (constraint.type === 'horizontal') candidate.end = { ...candidate.end, y: candidate.start.y }
|
||||||
|
else candidate.end = { ...candidate.end, x: candidate.start.x }
|
||||||
|
}
|
||||||
|
} else if (constraint.type === 'radius') {
|
||||||
|
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 === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') {
|
||||||
|
const firstGeometry = geometryById.get(constraint.first.geometryId)
|
||||||
|
const secondGeometry = geometryById.get(constraint.second.geometryId)
|
||||||
|
if (!firstGeometry || !secondGeometry) { findGeometry(snapshot.geometry, !firstGeometry ? constraint.first.geometryId : constraint.second.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)
|
||||||
|
if (!first || !second) continue
|
||||||
|
const firstBlocked = isBlocked(firstGeometry.id, blocked)
|
||||||
|
const secondBlocked = isBlocked(secondGeometry.id, blocked)
|
||||||
|
if (constraint.type === 'coincident') {
|
||||||
|
if (!firstBlocked && !secondBlocked) { const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; adjustPoint(firstGeometry, constraint.first.point, midpoint, blocked); adjustPoint(secondGeometry, constraint.second.point, midpoint, blocked) }
|
||||||
|
else if (!firstBlocked) adjustPoint(firstGeometry, constraint.first.point, second, blocked)
|
||||||
|
else if (!secondBlocked) adjustPoint(secondGeometry, constraint.second.point, first, blocked)
|
||||||
|
} else {
|
||||||
|
const dx = second.x - first.x
|
||||||
|
const dy = second.y - first.y
|
||||||
|
const current = constraint.type === 'distance' ? Math.hypot(dx, dy) : constraint.type === 'distanceX' ? Math.abs(dx) : Math.abs(dy)
|
||||||
|
const delta = constraint.value - current
|
||||||
|
if (Math.abs(delta) > tolerance && !secondBlocked) {
|
||||||
|
if (constraint.type === 'distance') {
|
||||||
|
const length = Math.hypot(dx, dy) || 1
|
||||||
|
adjustPoint(secondGeometry, constraint.second.point, { x: second.x + dx / length * delta, y: second.y + dy / length * delta }, blocked)
|
||||||
|
} else if (constraint.type === 'distanceX') adjustPoint(secondGeometry, constraint.second.point, { x: first.x + (dx < 0 ? -constraint.value : constraint.value), y: second.y }, blocked)
|
||||||
|
else adjustPoint(secondGeometry, constraint.second.point, { x: second.x, y: first.y + (dy < 0 ? -constraint.value : constraint.value) }, blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (constraint.type === 'equal') {
|
||||||
|
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 }
|
||||||
|
if (!isBlocked(second.id, blocked)) {
|
||||||
|
if ((second.type === 'circle' || second.type === 'arc') && (first.type === 'circle' || first.type === 'arc')) second.radius = first.radius
|
||||||
|
else if (second.type === 'line' && first.type === 'line') { const current = lineLength(second) || 1; const target = lineLength(first); const scale = target / current; second.end = { x: second.start.x + (second.end.x - second.start.x) * scale, y: second.start.y + (second.end.y - second.start.y) * scale } }
|
||||||
|
}
|
||||||
|
} else if (constraint.type === 'angle') {
|
||||||
|
const candidate = geometryById.get(constraint.geometryId)
|
||||||
|
if (!candidate || candidate.type !== 'line') { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue }
|
||||||
|
if (!isBlocked(candidate.id, blocked)) { const length = lineLength(candidate); candidate.end = { x: candidate.start.x + Math.cos(constraint.value) * length, y: candidate.start.y + Math.sin(constraint.value) * length } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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'
|
||||||
|
if (status === 'conflicting') diagnostics.push({ code: 'SOLVER_NOT_CONVERGED', message: `Sketch solver residual ${residual} exceeded tolerance ${tolerance}.` })
|
||||||
|
snapshot.solver = { status, degreesOfFreedom, residual, iterations, diagnostics }
|
||||||
|
return { snapshot, status, degreesOfFreedom, residual, iterations, diagnostics }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SketchSolverAdapter {
|
||||||
|
solve(snapshot: SketchSnapshot, options?: SketchSolveOptions): Promise<SketchSolveResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BasicSketchSolverAdapter implements SketchSolverAdapter {
|
||||||
|
solve(snapshot: SketchSnapshot, options?: SketchSolveOptions) { return Promise.resolve(solveSketch(snapshot, options)) }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
|
import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
|
||||||
import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph'
|
import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph'
|
||||||
import type { Quantity, QuantityDimension } from './units'
|
import type { Quantity, QuantityDimension } from './units'
|
||||||
|
import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
|
||||||
|
|
||||||
export type ModelTreeItem = {
|
export type ModelTreeItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,6 +34,7 @@ export type DocumentObjectSnapshot = {
|
|||||||
id: string
|
id: string
|
||||||
typeId: string
|
typeId: string
|
||||||
properties: ObjectPropertySnapshot[]
|
properties: ObjectPropertySnapshot[]
|
||||||
|
sketch?: SketchSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentSnapshot = {
|
export type DocumentSnapshot = {
|
||||||
@@ -302,6 +304,12 @@ export interface BitBybitWebCadFacade {
|
|||||||
evaluate(expression: string, variables?: Record<string, Quantity>): { value: Quantity; references: string[] }
|
evaluate(expression: string, variables?: Record<string, Quantity>): { value: Quantity; references: string[] }
|
||||||
dimensionForUnit(unit?: string): QuantityDimension
|
dimensionForUnit(unit?: string): QuantityDimension
|
||||||
}
|
}
|
||||||
|
sketcher: {
|
||||||
|
get(objectId: string): SketchSnapshot | null
|
||||||
|
addGeometry(objectId: string, geometry: SketchGeometry): SketchSnapshot
|
||||||
|
addConstraint(objectId: string, constraint: SketchConstraint): SketchSnapshot
|
||||||
|
solve(objectId: string): SketchSolveResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
readonly history: {
|
readonly history: {
|
||||||
canUndo(): boolean
|
canUndo(): boolean
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/p
|
|||||||
import { DependencyGraph } from '../src/facade/dependencyGraph'
|
import { DependencyGraph } from '../src/facade/dependencyGraph'
|
||||||
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
||||||
import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming'
|
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, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
|
||||||
import type { ShapeHandle } from '../src/facade/types'
|
import type { ShapeHandle } from '../src/facade/types'
|
||||||
|
|
||||||
@@ -74,8 +75,8 @@ test('Bitbybit face meshes are normalized into a facade-owned indexed asset', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('project schema is versioned and covers the FreeCAD document graph', () => {
|
test('project schema is versioned and covers the FreeCAD document graph', () => {
|
||||||
assert.equal(PROJECT_SCHEMA_VERSION, 3)
|
assert.equal(PROJECT_SCHEMA_VERSION, 4)
|
||||||
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3])
|
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4])
|
||||||
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
||||||
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
|
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
|
||||||
})
|
})
|
||||||
@@ -110,6 +111,40 @@ test('topology signatures are independent of transient face indexes and flag amb
|
|||||||
assert.equal(matches[0].previousId, first.refs[0].persistentId)
|
assert.equal(matches[0].previousId, first.refs[0].persistentId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('basic sketch solver applies geometric constraints and reports remaining degrees of freedom', () => {
|
||||||
|
const sketch = createSketch('sketch-test', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [
|
||||||
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
||||||
|
{ id: 'length-1', type: 'distance', first: { geometryId: 'line-1', point: 'start' }, second: { geometryId: 'line-1', point: 'end' }, value: 5 },
|
||||||
|
])
|
||||||
|
const result = solveSketch(sketch)
|
||||||
|
assert.equal(result.status, 'under-constrained')
|
||||||
|
assert.ok(result.residual <= 1e-7)
|
||||||
|
assert.equal(result.snapshot.geometry[0].type, 'line')
|
||||||
|
assert.equal((result.snapshot.geometry[0] as Extract<typeof result.snapshot.geometry[number], { type: 'line' }>).end.x, 5)
|
||||||
|
|
||||||
|
const conflict = solveSketch(createSketch('conflict', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }], [
|
||||||
|
{ id: 'block-1', type: 'block', geometryId: 'line-1' },
|
||||||
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
||||||
|
]))
|
||||||
|
assert.equal(conflict.status, 'conflicting')
|
||||||
|
assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sketcher operations are facade transactions and survive the project fallback store', async () => {
|
||||||
|
const facade = createMockFacade()
|
||||||
|
const before = facade.app.document.getActive().version
|
||||||
|
const added = facade.app.sketcher.addGeometry('sketch', { id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } })
|
||||||
|
assert.equal(added.geometry.length, 1)
|
||||||
|
facade.app.sketcher.addConstraint('sketch', { id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' })
|
||||||
|
const solved = facade.app.sketcher.solve('sketch')
|
||||||
|
assert.equal(solved.status, 'under-constrained')
|
||||||
|
assert.ok(facade.app.document.getActive().version >= before + 3)
|
||||||
|
assert.match(String(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'ConstraintStatus')?.value), /Under-constrained/)
|
||||||
|
await facade.project.save()
|
||||||
|
const loaded = await facade.project.load('doc-pump-housing')
|
||||||
|
assert.equal(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.constraints[0].id, 'horizontal-1')
|
||||||
|
})
|
||||||
|
|
||||||
test('dependency graph propagates dirty state and orders dependencies', () => {
|
test('dependency graph propagates dirty state and orders dependencies', () => {
|
||||||
const graph = new DependencyGraph([
|
const graph = new DependencyGraph([
|
||||||
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
|
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
|
||||||
|
|||||||
Reference in New Issue
Block a user