feat: establish production facade composition root

This commit is contained in:
2026-08-10 16:28:12 -04:00
parent b962a5c3b5
commit 7f10bba38f
11 changed files with 285 additions and 20 deletions

58
.github/workflows/real-verification.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: real-verification
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# These jobs deliberately use the CAD self-hosted runner. The runner image must
# contain Chrome, CMake/Ninja, Emscripten, and the pinned CAMotics/OpenCAMLib/
# LinuxCNC worktrees described in docs/ci-real-verification.zh-CN.md.
jobs:
chrome:
runs-on: [self-hosted, linux, cad]
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.23.2
cache: npm
- run: ./npmw ci --ignore-scripts
- run: ./npmw run ci:real -- --lane=chrome
env:
CHROME_BIN: ${{ vars.CHROME_BIN }}
freecad-oracle:
runs-on: [self-hosted, linux, cad]
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.23.2
cache: npm
- run: ./npmw ci --ignore-scripts
- run: ./npmw run ci:real -- --lane=oracle
env:
FREECAD_ORACLE_PROFILE: headless
wasm:
runs-on: [self-hosted, linux, cad]
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.23.2
cache: npm
- run: ./npmw ci --ignore-scripts
- run: ./npmw run ci:real -- --lane=wasm
env:
CHROME_BIN: ${{ vars.CHROME_BIN }}
EMSDK: ${{ vars.EMSDK }}
OCCT_SOURCE_DIR: ${{ vars.OCCT_SOURCE_DIR }}

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ CAMotics/
OpenCAMLib/
cnc_wams_gpt6/
working_ocl/
# Ephemeral output from real CI lanes.
.cache/ci/
native/occt-history/dist/
public/native/occt-history/
__pycache__/

View File

@@ -0,0 +1,23 @@
# 真实验证 CI
`npm run verify` 负责检查仓库中已生成的证据和静态门禁它不应被误解为重新启动浏览器、FreeCAD 或 WASM。真实验证入口是
```bash
./npmw run ci:real -- --lane=chrome
./npmw run ci:real -- --lane=oracle
./npmw run ci:real -- --lane=wasm
```
`--lane=all` 会按 oracle、WASM、Chrome 顺序执行全部 lane。任一命令退出非零都会使整个 lane 失败;运行摘要写入 `.cache/ci/real-verification.json`,该文件不作为仓库基线证据提交。
## Runner 要求
工作流使用标签为 `self-hosted, linux, cad` 的 runner因为 FreeCAD 原生编译和外围 CAM harness 需要较大的本地工具链。runner 必须提供:
- Node `22.23.2`、CMake、Ninja、C/C++ 编译器和 Python
- Chrome/Chromium通过 `CHROME_BIN` 指定)以及可用的无头模式;
- Emscripten通过 `EMSDK` 指定)和 OCCT 源码目录(通过 `OCCT_SOURCE_DIR` 指定);
- `CAMotics/``OpenCAMLib/``cnc_wams_gpt6/``working_ocl/` 外部工作树。它们不进入 Git版本由 `config/` 中的锁定清单和 drift check 校验;
- FreeCAD 构建所需的系统开发包。`ci:real -- --lane=oracle` 会重新抓取锁定的 FreeCAD 源码、配置、编译、安装并执行 oracle不接受旧报告代替。
工作流中的 `actions/checkout` 不会恢复被 `.gitignore` 忽略的外部工作树runner 镜像或预热步骤必须提前准备它们。缺少任一依赖时harness 应失败并暴露缺失项,而不是降级为读取历史 JSON。

View File

@@ -251,6 +251,7 @@
"test:golden:freecad": "node scripts/run-freecad-golden.mjs",
"test:golden:freecad:families": "node scripts/run-freecad-golden.mjs --manifest=fixtures/freecad-golden/feature-families/manifest.json",
"probe:freecad-reference": "node scripts/run-freecad-reference-probe.mjs",
"ci:real": "node scripts/run-real-verification.mjs",
"check:runtime": "node scripts/check-runtime.mjs",
"verify": "npm run check:runtime && npm run check:baseline && npm run check:bitbybit-history && npm run check:execution-plan && npm run check:freecad-sketcher-constraints && npm run check:freecad-inventory && npm run check:freecad-type-properties && npm run check:freecad-gui-commands && npm run check:freecad-entrypoints && npm run check:freecad-golden-fixtures && npm run check:freecad-semantic-comparator && npm run check:freecad-fcstd-roundtrip && npm run check:freecad-composite-history-elementmap && npm run check:freecad-exact-history-elementmap-gate && npm run check:freecad-native-naming-evidence && npm run check:fcstd-hardening && npm run check:occt-history-artifact && npm run check:planegcs-artifact && npm run check:browser-occt && npm run check:chrome-topology && npm run check:chrome-planegcs && npm run check:chrome-fault-injection && npm run check:chrome-opfs-migration && npm run check:chrome-persistence && npm run check:chrome-app-e2e && npm run check:chrome-partdesign-lifecycle && npm run check:pwa && npm run check:chrome-offline && npm run check:chrome-geometry-features && npm run check:chrome-native-history && npm run check:chrome-native-pad-history && npm run check:chrome-native-pocket-history && npm run check:chrome-native-loft-history && npm run check:chrome-native-pipe-history && npm run check:chrome-native-revolution-history && npm run check:chrome-native-groove-history && npm run check:chrome-native-fillet-history && npm run check:chrome-native-chamfer-history && npm run check:chrome-native-hole-history && npm run check:chrome-native-draft-history && npm run check:chrome-native-thickness-history && npm run check:chrome-native-linear-pattern-history && npm run check:chrome-native-polar-pattern-history && npm run check:chrome-native-mirrored-history && npm run check:chrome-native-multi-transform-history && npm run check:chrome-sketcher-diagnostics && npm run check:chrome-profile-validation && npm run check:chrome-sketcher-editor && npm run check:chrome-sketcher-bspline && npm run check:chrome-sketcher-stress && npm run check:chrome-part-primitives && npm run check:chrome-partdesign-transform && npm run check:chrome-partdesign-loft && npm run check:partdesign-closure && npm run check:chrome-fcstd-semantic && npm run check:chrome-fcstd-roundtrip && npm run check:fcstd-closure && npm run check:chrome-spreadsheet && npm run check:chrome-plot && npm run check:chrome-draft && npm run check:chrome-techdraw && npm run check:chrome-assembly && npm run check:chrome-mesh && npm run check:chrome-inspection && npm run check:chrome-bim && npm run check:chrome-cam && npm run check:cam-parity && npm run check:cam-pipeline && npm run check:opencamlib-wasm && npm run check:camotics-native && npm run check:camotics-gui-tpl && npm run check:camotics-wasm && npm run check:chrome-camotics-wasm && npm run check:chrome-cam-native-simulation && npm run check:chrome-cam-linuxcnc-machine && npm run check:chrome-fem && npm run check:chrome-robot && npm run check:chrome-data && npm run check:chrome-addon && npm run check:chrome-script && npm run check:chrome-surface && npm run check:chrome-qa08 && npm run check:chrome-secondary-formats && npm run check:chrome-performance && npm run check:chrome-production-document && npm run check:document-closure && npm run check:chrome-engineering && npm run check:chrome-security && npm run check:chrome-fcstd-golden && npm run check:sbom && npm run check:project-migration && npm run check:security-baseline && npm run check:dependency-audit && npm run check:freecad:threeway && npm run check:occt-upstream-drift && npm run check:facade-boundary && npm run test:golden && npm run test:spreadsheet && npm run test:plot && npm run test:draft && npm run test:techdraw && npm run test:assembly && npm run test:mesh && npm run test:inspection && npm run test:bim && npm run test:cam && npm run test:camotics-wasm && npm run test:fem && npm run test:robot && npm run test:data && npm run test:addon && npm run test:script && npm run test:surface && npm run test:locale && npm run test:secondary-formats && npm run test:performance && npm run test:production-document && npm run test:engineering && npm run test:security-preflight && npm run test:fcstd-roundtrip && npm run test:facade && npm run test:fcstd-fuzz && npm run test:geometry-input-fuzz && npm run test:sketch-solver-fuzz && npm run check:quality-closure && npm run test:planegcs && npm run test:project-migration && npm run test:sketcher-stress && npm run test:topology-replay && npm run build && npm run check:built-pwa && npm run generate:release-artifacts && npm run check:release-artifacts && npm run check:release-closure && npm run test:release-signature",
"test:chrome-native-thickness-history": "node scripts/run-chrome-native-thickness-history.mjs",

View File

@@ -0,0 +1,101 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const root = resolve(import.meta.dirname, '..')
const npmw = resolve(root, 'npmw')
const packageJson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
const scripts = packageJson.scripts ?? {}
const laneArg = process.argv.find((arg) => arg.startsWith('--lane='))
const requestedLane = laneArg ? laneArg.slice('--lane='.length) : 'all'
const validLanes = new Set(['all', 'chrome', 'oracle', 'wasm'])
if (!validLanes.has(requestedLane)) {
throw new Error(`Unknown lane "${requestedLane}". Expected one of: ${[...validLanes].join(', ')}`)
}
const chromeTests = Object.keys(scripts)
.filter((name) => name.startsWith('test:chrome-'))
.sort()
const lanes = {
chrome: [
'build',
...chromeTests,
...chromeTests
.map((name) => `check:${name.slice('test:'.length)}`)
.filter((name) => scripts[name]),
],
oracle: [
'fetch:freecad-source',
'configure:freecad-native',
'build:freecad-native',
'probe:freecad-reference',
'test:golden:freecad',
'test:golden:freecad:families',
'test:golden:freecad:failures',
'test:golden:freecad:family-failures',
'probe:freecad-sketcher-constraints',
'probe:freecad-partdesign-profiles',
'probe:freecad-partdesign-base',
'probe:freecad-partdesign-loft',
'probe:freecad-partdesign-dressup',
'probe:freecad-partdesign-transform',
'probe:freecad-partdesign-failures',
'probe:freecad-partdesign-revolution-groove',
'probe:freecad-part-builders',
'probe:freecad-composite-history-elementmap',
'check:freecad-oracle-coverage',
'check:freecad-golden-coverage',
'check:freecad-sketcher-constraints',
'check:freecad-fcstd-roundtrip',
'check:freecad-composite-history-elementmap',
'check:freecad-exact-history-elementmap-gate',
'check:freecad-native-naming-evidence',
],
wasm: [
'check:occt-history-artifact',
'test:browser-occt',
'check:browser-occt',
'test:planegcs',
'check:planegcs-artifact',
'test:chrome-planegcs',
'test:chrome-native-history',
'test:chrome-camotics-wasm',
'test:chrome-cam-native-simulation',
'check:chrome-planegcs',
'check:chrome-native-history',
'check:chrome-camotics-wasm',
'check:chrome-cam-native-simulation',
],
}
const selectedLanes = requestedLane === 'all' ? ['oracle', 'wasm', 'chrome'] : [requestedLane]
const results = []
function runScript(name) {
if (!scripts[name]) throw new Error(`Required npm script is missing: ${name}`)
const startedAt = Date.now()
console.log(`\n[real-verification] ${name}`)
const result = spawnSync(npmw, ['run', name], {
cwd: root,
env: { ...process.env, CI_REAL_VERIFICATION: '1' },
stdio: 'inherit',
})
const record = { script: name, status: result.status ?? 1, durationMs: Date.now() - startedAt }
results.push(record)
if (record.status !== 0) throw new Error(`Real verification failed in ${name} (exit ${record.status})`)
}
try {
for (const lane of selectedLanes) {
console.log(`\n[real-verification] lane=${lane}`)
for (const name of lanes[lane]) runScript(name)
}
} finally {
const outputDir = resolve(root, '.cache', 'ci')
mkdirSync(outputDir, { recursive: true })
writeFileSync(resolve(outputDir, 'real-verification.json'), `${JSON.stringify({ generatedAt: new Date().toISOString(), lanes: selectedLanes, results }, null, 2)}\n`)
}
console.log(`\n[real-verification] completed ${results.length} commands across ${selectedLanes.join(', ')}.`)

View File

@@ -59,7 +59,7 @@ import {
ZoomOut,
} from 'lucide-react'
import { menuDefinitions, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createLinuxcncIframeAdapter, createMockFacade, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import { createLinuxcncIframeAdapter, createProductionFacade, createPumpHousingDemoDocument, NativeOcctHistoryWorkerProvider, prepareCamPipeline, submitCamPipelineToLinuxcnc, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type CamAxisConfiguration, type CamCoolantMode, type CamCutDirection, type CamDressupKind, type CamKinematicOptions, type CamOperationKind, type CamPostprocessor, type CamPropertyValue, type CamRotaryAxis, type CamSnapshot, type CamStockMode, type DiagnosticTreeNode, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type MultiTransformStep, type MultiTransformValue, type ObjectPropertySnapshot, type PlacementValue, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade'
import type { LinkSubListValue, LinkSubValue, ShapeResourceValue, TopoRefValue } from './facade/types'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
@@ -133,7 +133,7 @@ function App() {
const [rightTab, setRightTab] = useState<'data' | 'view'>('data')
const [bottomOpen, setBottomOpen] = useState(true)
const [notice, setNotice] = useState('')
const facade = useMemo(() => createMockFacade(), [])
const facade = useMemo(() => createProductionFacade({ initialDocument: createPumpHousingDemoDocument(), initialSelectedObjectIds: ['pad'] }), [])
const nativeHistory = useMemo(() => new NativeOcctHistoryWorkerProvider(), [])
const [facadeState, setFacadeState] = useState(() => facade.getState())
const [diagnosticFlash, setDiagnosticFlash] = useState<{ objectId: string; token: number } | null>(null)

View File

@@ -0,0 +1,31 @@
import type { ModelTreeItem } from './types'
export type DocumentTemplate = {
id: string
label: string
version: number
dirty: boolean
readOnly: boolean
units: string
tree: ModelTreeItem[]
selectedObjectIds: string[]
}
export const PUMP_HOUSING_DEMO_TEMPLATE: DocumentTemplate = {
id: 'doc-pump-housing',
label: 'Pump Housing',
version: 18,
dirty: true,
readOnly: false,
units: 'mm',
selectedObjectIds: ['pad'],
tree: [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
],
}

View File

@@ -1,4 +1,7 @@
export { createMockFacade } from './mockFacade'
export { createBlankDocument, createMockFacade, createPumpHousingDemoDocument, createWebCadFacade } from './mockFacade'
export type { WebCadFacadeOptions } from './mockFacade'
export { createProductionFacade } from './productionFacade'
export type { ProductionFacadeOptions } from './productionFacade'
export { buildDiagnosticTree, buildRecomputeDiagnostics } from './diagnostics'
export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore'
export type { SqliteProjectPersistenceOptions } from './projectStore'

View File

@@ -44,16 +44,7 @@ import { decodeFcstdPathProperty, extractFcstdShapeResources, inspectFcstdArchiv
import { buildDiagnosticTree, buildRecomputeDiagnostics, cloneDiagnostic, replaceRecomputeDiagnostics } from './diagnostics'
import { cloneObjectTopologySnapshot, migrateDocumentTopologyReferences, parseTopoRef, resolveDocumentTopologyReference } from './topologyReferences'
import { createCamJob } from './cam'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
]
import { PUMP_HOUSING_DEMO_TEMPLATE, type DocumentTemplate } from './documentTemplates'
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('box') ? 'Part::Box' : item.id.startsWith('cylinder') ? 'Part::Cylinder' : item.id.startsWith('sphere') ? 'Part::Sphere' : item.id.startsWith('ellipsoid') ? 'Part::Ellipsoid' : item.id.startsWith('cone') ? 'Part::Cone' : item.id.startsWith('torus') ? 'Part::Torus' : item.id.startsWith('helix') ? 'Part::Helix' : item.id.startsWith('prism') ? 'Part::Prism' : item.id.startsWith('wedge') ? 'Part::Wedge' : item.id.startsWith('union') ? 'Part::Fuse' : item.id.startsWith('cut') ? 'Part::Cut' : item.id.startsWith('intersection') ? 'Part::Common' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('revolution') ? 'PartDesign::Revolution' : item.id.startsWith('groove') ? 'PartDesign::Groove' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('mirrored') ? 'PartDesign::Mirrored' : item.id.startsWith('multi-transform') ? 'PartDesign::MultiTransform' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.id.startsWith('polar-pattern') ? 'PartDesign::PolarPattern' : item.id.startsWith('hole') ? 'PartDesign::Hole' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
@@ -367,14 +358,36 @@ const expressionReferences = (expression: string): string[] => {
}
}
const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
const objects = initialTree.map(createObjectSnapshot)
const document: DocumentSnapshot = { id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects }
const createDocumentFromTemplate = (template: DocumentTemplate): DocumentSnapshot => {
const tree = template.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined }))
const objects = tree.map(createObjectSnapshot)
const document: DocumentSnapshot = { id: template.id, label: template.label, version: template.version, dirty: template.dirty, readOnly: template.readOnly, units: template.units, tree, objects }
document.dependencies = collectDependencyEdges(document)
document.recompute = createRecomputeSnapshot(objects.map((object) => object.id))
return document
}
export const createBlankDocument = (label = 'Untitled document', id = 'doc-untitled'): DocumentSnapshot => ({
id,
label,
version: 0,
dirty: true,
readOnly: false,
units: 'mm',
tree: [],
objects: [],
dependencies: [],
recompute: createRecomputeSnapshot([]),
})
export const createPumpHousingDemoDocument = (): DocumentSnapshot => createDocumentFromTemplate(PUMP_HOUSING_DEMO_TEMPLATE)
export type WebCadFacadeOptions = {
initialDocument?: DocumentSnapshot
initialWorkbench?: WorkbenchId
initialSelectedObjectIds?: string[]
}
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'groove', 'additive-loft', 'subtractive-loft', 'additive-pipe', 'subtractive-pipe', 'fillet', 'chamfer', 'draft', 'thickness', 'mirrored', 'multi-transform', 'extrude-part', 'revolution-part', 'loft-part', 'sweep-part', 'union', 'cut', 'intersection', 'check-shape', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
const systemCommands = new Set(['new-document', 'save', 'select-object'])
const camCommandIds = new Set(workbenchDefinitions.CAM.groups.flatMap((group) => group.commands.map((entry) => entry.id)))
@@ -661,13 +674,18 @@ const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<str
return plan
}
export function createMockFacade(): BitBybitWebCadFacade {
export function createWebCadFacade(options: WebCadFacadeOptions = {}): BitBybitWebCadFacade {
const projectPersistence = createSqliteProjectPersistence()
const geometryRuntime = new BitbybitGeometryRuntime()
const camJob = createCamJob('Job', 'CAM Job', { min: [0, 0, 0], max: [10, 10, 10], mode: 'from-base-bound-box' })
const featureShapes = new Map<string, ShapeHandle>()
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document))
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', selectedObjectIds: ['pad'], document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const initialDocument = cloneDocumentSnapshot(options.initialDocument ?? createBlankDocument())
const availableObjectIds = new Set(initialDocument.objects.map((object) => object.id))
const initialSelectedObjectIds = [...new Set(options.initialSelectedObjectIds ?? [])].filter((objectId) => availableObjectIds.has(objectId))
let documentSequence = 0
const nextBlankDocument = (label = 'Untitled document') => createBlankDocument(label, `doc-untitled-${++documentSequence}`)
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: options.initialWorkbench ?? 'Part Design', selectedObjectId: initialSelectedObjectIds[0] ?? '', selectedObjectIds: initialSelectedObjectIds, document: initialDocument, persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
const recomputeCoordinator = new RecomputeCoordinator(
createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes),
(documentId) => state.document.id === documentId ? state.document.version : null,
@@ -1320,7 +1338,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
}
emit({ type: 'command.started', commandId, context })
if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '', selectedObjectIds: [] }) }
if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: nextBlankDocument(), selectedObjectId: '', selectedObjectIds: [] }) }
else if (commandId === 'save') {
const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 }
commit({ ...state, document: savedDocument })
@@ -1386,7 +1404,7 @@ export function createMockFacade(): 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 })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: createDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, reorderBodyFeature, removeObject, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), 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, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
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, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); clearFeatureShapes(); commit({ ...state, document: nextBlankDocument(label), selectedObjectId: '', selectedObjectIds: [] }); return getState().document }, load: loadDocument, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, reorderBodyFeature, removeObject, resolveTopologyReference, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), 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, projectGeometry: projectSketch, carbonCopy: carbonCopySketch, addExternalGeometry: addSketchExternalGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); 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, state.document.objects.find((object) => object.id === state.selectedObjectId), state.selectedSubshape), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, getObjectIds: () => [...(state.selectedObjectIds ?? (state.selectedObjectId ? [state.selectedObjectId] : []))], getSubshape: () => cloneSelection(state.selectedSubshape) ?? null, getPreselection: () => cloneSelection(state.preselectedSubshape) ?? null, select, selectObjects, selectSubshape, preselectSubshape, clear: () => select('') },
@@ -1452,3 +1470,10 @@ export function createMockFacade(): BitBybitWebCadFacade {
void projectPersistence.initialize().then((nextCapabilities) => { state = { ...state, persistence: nextCapabilities }; emitState() }).catch((error: unknown) => { const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, source: 'persistence', severity: 'warning', code: 'PERSISTENCE_INIT_FAILED', message: error instanceof Error ? error.message : String(error), documentId: state.document.id, documentVersion: state.document.version }; state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }; emit({ type: 'diagnostic.added', diagnostic, context: { apiVersion: state.apiVersion, requestId: `req-${++requestSequence}`, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench } }) })
return facade
}
export function createMockFacade(): BitBybitWebCadFacade {
return createWebCadFacade({
initialDocument: createPumpHousingDemoDocument(),
initialSelectedObjectIds: PUMP_HOUSING_DEMO_TEMPLATE.selectedObjectIds,
})
}

View File

@@ -0,0 +1,8 @@
import { createWebCadFacade, type WebCadFacadeOptions } from './mockFacade'
import type { BitBybitWebCadFacade } from './types'
export type ProductionFacadeOptions = WebCadFacadeOptions
export function createProductionFacade(options: ProductionFacadeOptions = {}): BitBybitWebCadFacade {
return createWebCadFacade(options)
}

View File

@@ -2,6 +2,7 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { strToU8, unzipSync, zipSync } from 'fflate'
import { createMockFacade } from '../src/facade/mockFacade'
import { createProductionFacade } from '../src/facade/productionFacade'
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
import { DependencyGraph } from '../src/facade/dependencyGraph'
@@ -68,6 +69,17 @@ test('facade exposes a stable initial document projection', () => {
assert.deepEqual({ shapeCount: facade.geometry.capabilities().shapeCount, kernelReferenceCount: facade.geometry.capabilities().kernelReferenceCount, releasedShapeCount: facade.geometry.capabilities().releasedShapeCount }, { shapeCount: 0, kernelReferenceCount: 0, releasedShapeCount: 0 })
})
test('production facade starts from an empty document unless an application bootstrap is supplied', () => {
const facade = createProductionFacade()
const state = facade.getState()
assert.equal(state.document.id, 'doc-untitled')
assert.equal(state.document.label, 'Untitled document')
assert.deepEqual(state.document.tree, [])
assert.deepEqual(state.document.objects, [])
assert.deepEqual(state.selectedObjectIds, [])
facade.geometry.dispose()
})
test('geometry boundary validates dimensions before invoking OCCT', () => {
assert.throws(() => validateBoxInput({ width: 0, length: 2, height: 3, documentId: 'doc', documentVersion: 1 }), /width/)
assert.throws(() => validateBoxInput({ width: 1, length: 2, height: 3, center: [0, Number.NaN, 0], documentId: 'doc', documentVersion: 1 }), /center/)