From 7f10bba38fdff07c1c5cf42ce8836c04cbd6ccbb Mon Sep 17 00:00:00 2001 From: wangdequan Date: Mon, 10 Aug 2026 16:28:12 -0400 Subject: [PATCH] feat: establish production facade composition root --- .github/workflows/real-verification.yml | 58 ++++++++++++++ .gitignore | 3 + docs/ci-real-verification.zh-CN.md | 23 ++++++ package.json | 1 + scripts/run-real-verification.mjs | 101 ++++++++++++++++++++++++ src/App.tsx | 4 +- src/facade/documentTemplates.ts | 31 ++++++++ src/facade/index.ts | 5 +- src/facade/mockFacade.ts | 59 ++++++++++---- src/facade/productionFacade.ts | 8 ++ tests/facade.test.ts | 12 +++ 11 files changed, 285 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/real-verification.yml create mode 100644 docs/ci-real-verification.zh-CN.md create mode 100644 scripts/run-real-verification.mjs create mode 100644 src/facade/documentTemplates.ts create mode 100644 src/facade/productionFacade.ts diff --git a/.github/workflows/real-verification.yml b/.github/workflows/real-verification.yml new file mode 100644 index 0000000..c293007 --- /dev/null +++ b/.github/workflows/real-verification.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index e68686c..aec9bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/docs/ci-real-verification.zh-CN.md b/docs/ci-real-verification.zh-CN.md new file mode 100644 index 0000000..e2b0c5a --- /dev/null +++ b/docs/ci-real-verification.zh-CN.md @@ -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。 diff --git a/package.json b/package.json index 1d7e9ef..237773a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-real-verification.mjs b/scripts/run-real-verification.mjs new file mode 100644 index 0000000..f35dfb8 --- /dev/null +++ b/scripts/run-real-verification.mjs @@ -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(', ')}.`) diff --git a/src/App.tsx b/src/App.tsx index bf0a8fc..181bb44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) diff --git a/src/facade/documentTemplates.ts b/src/facade/documentTemplates.ts new file mode 100644 index 0000000..73fcb3f --- /dev/null +++ b/src/facade/documentTemplates.ts @@ -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'] }, + ], +} diff --git a/src/facade/index.ts b/src/facade/index.ts index 78a156b..2611676 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -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' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 5f85934..a7872b6 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -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() 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, + }) +} diff --git a/src/facade/productionFacade.ts b/src/facade/productionFacade.ts new file mode 100644 index 0000000..2687d84 --- /dev/null +++ b/src/facade/productionFacade.ts @@ -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) +} diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 174dd0c..3a14199 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -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/)