From 8d8d1b7c26e416da7d2187903c5505ffe2c6af61 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 07:30:29 -0400 Subject: [PATCH] P3-04: add OCCT pad pocket and revolution --- config/runtime-baseline.json | 2 +- docs/web-cad-implementation-plan.zh-CN.md | 16 ++++ src/facade/geometryRuntime.ts | 110 +++++++++++++++++++++- src/facade/geometryWorker.ts | 19 ++++ src/facade/index.ts | 4 +- src/facade/mockFacade.ts | 2 +- src/facade/types.ts | 31 ++++++ tests/facade.test.ts | 11 ++- 8 files changed, 189 insertions(+), 6 deletions(-) diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json index b8032ff..4c6e775 100644 --- a/config/runtime-baseline.json +++ b/config/runtime-baseline.json @@ -2,7 +2,7 @@ "baselineId": "bitbybit-web-cad-runtime-0.1", "entrypoint": "BitBybitWebCadFacade", "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": "primitive-placement-and-boolean-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": "adapter-skeleton" }, "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": "worker-skeleton" }, "targetBrowsers": "Capability matrix to be locked in P0-02", diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 3879e03..06ac388 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1597,6 +1597,7 @@ P0 基线/治理 | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | | P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;稳定子形状命名尚未实现 | | P3-03 基本体和变换 | `IN PROGRESS` | 真实 OCCT Box/Cylinder/Sphere/Cone 和 FreeCAD 风格 Placement 已通过浏览器矩阵;0.05 mm 网格精度下曲面包围盒最大离散误差约 0.023 mm;自动化浏览器黄金测试尚未进入 CI | +| P3-04 Boolean/Pad/Pocket/Revolution | `IN PROGRESS` | 文档作用域 Union/Cut/Intersection 与 PlanarProfile 驱动的 Pad/Pocket/Revolution 已通过真实 OCCT 浏览器矩阵;取消、进度、Through all/Up to face 和文档重计算接线尚未完成 | | P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;对象缓存与文档增量同步尚未实现 | | P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖初始化、schema、串行持久化队列、自动保存合并、资源引用、事件、任务生命周期、禁用命令、请求上下文、特征提交和历史;E2E/黄金几何待补齐 | @@ -1664,3 +1665,18 @@ Bitbybit OCCT Worker 1.1.1 的内置 Intersection 包装在调用 `BRepAlgoAPI_C | 跨文档/未来版本/输入不足 | `PASS` | Facade 同步拒绝,不触发 OCCT 运算 | OCCT Boolean 结果可能与源形状共享底层拓扑。1.1.1 的 `deleteShape` 会执行全量几何清理,逐个删除结果可能破坏仍存活源形状。因此当前 Facade 在句柄释放时更新内部引用计数,并在活动句柄归零后统一 `cleanAllCache()`;Worker 终止时也会释放全部 WASM 内存。P3-06 需实现依赖感知的细粒度安全回收和长会话内存基准。 + +### 16.14 P3-04 Pad/Pocket/Revolution 验证记录 + +`PlanarProfile` 由一个外环和零到多个孔环构成。Facade 接受首尾重复或不重复的闭合表示,拒绝少于三个有效点、连续重复点、共线环、非有限坐标和不共面环;进入 OCCT 前会自动把孔环绕向调整为外环的反向。自相交、孔嵌套和轮廓相交仍需 Sketcher/ShapeFix 黄金验证,当前不能静默宣称完整草图兼容。 + +| 算例 | 结果 | 包围盒 | +|---|---|---| +| XZ 平面 `4×2` 轮廓 Pad,长度 3 | 24 顶点、12 三角形 | X `[-2,2]`、Y `[0,3]`、Z `[-1,1]` | +| 同轮廓 symmetricToPlane Pad,长度 4 | 24 顶点、12 三角形 | X `[-2,2]`、Y `[-2,2]`、Z `[-1,1]` | +| `6×6` 外环、`2×2` 孔环 Pad,长度 2 | 48 顶点、32 三角形 | X/Z `[-3,3]`、Y `[0,2]`,孔壁已生成 | +| `6×4×6` Box 上的 `2×2` 贯穿 Pocket | 48 顶点、32 三角形 | 外包围盒保持 X/Z `[-3,3]`、Y `[-2,2]` | +| X `[1,2]`、Y `[-1,1]` 面绕 Y 轴 360° Revolution | 212 顶点、208 三角形 | X `[-2,2]`、Y `[-1,1]`、Z 约 `[-1.9854,1.9854]` | +| 释放全部特征结果后访问 Pocket 基体 | `PASS` | 基体仍为 24 顶点、12 三角形,包围盒不变 | + +Pad/Pocket 的方向向量在 Facade 内归一化,`length` 单独控制尺寸;`reversed` 改变方向,`symmetricToPlane` 将起始面移动到长度中点。Revolution Worker 插件支持任意轴原点和方向,弥补 Bitbybit 1.1.1 高层 `revolve` 固定绕原点轴的限制。当前 Pocket 是长度模式;`Through all`、`Up to face`、双向长度和轮廓支持面映射继续由 P4-02/P4-05 与 P3-04 后续任务实现。 diff --git a/src/facade/geometryRuntime.ts b/src/facade/geometryRuntime.ts index 480c060..35c933b 100644 --- a/src/facade/geometryRuntime.ts +++ b/src/facade/geometryRuntime.ts @@ -1,6 +1,6 @@ import { BitByBitOCCT, OccStateEnum } from '@bitbybit-dev/occt-worker' import type { Inputs } from '@bitbybit-dev/occt' -import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, MeshAsset, ShapeHandle } from './types' +import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, PadInput, PlanarProfile, PocketInput, Point3, RevolutionInput, ShapeHandle } from './types' type KernelShapeReference = Inputs.OCCT.TopoDSShapePointer type KernelMesh = Inputs.OCCT.DecomposedMeshDto @@ -101,6 +101,64 @@ export const validateBooleanCutInput = (input: BooleanCutInput) => { export const validateBooleanIntersectionInput = (input: BooleanIntersectionInput) => validateBooleanShapes(input, input.shapes, 2) +const samePoint = (left: Point3, right: Point3, tolerance = 1e-9) => left.every((coordinate, axis) => Math.abs(coordinate - right[axis]) <= tolerance) +const subtract = (left: Point3, right: Point3): Point3 => [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +const cross = (left: Point3, right: Point3): Point3 => [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]] +const dot = (left: Point3, right: Point3) => left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +const magnitude = (value: Point3) => Math.hypot(value[0], value[1], value[2]) + +const normalizedRing = (ring: Point3[]) => ring.length > 1 && samePoint(ring[0], ring.at(-1) as Point3) ? ring.slice(0, -1) : [...ring] + +const ringNormal = (ring: Point3[]): Point3 | null => { + const origin = ring[0] + for (let index = 1; index < ring.length - 1; index += 1) { + const candidate = cross(subtract(ring[index], origin), subtract(ring[index + 1], origin)) + if (magnitude(candidate) > 1e-9) return candidate + } + return null +} + +export const validatePlanarProfile = (profile: PlanarProfile) => { + const rings = [normalizedRing(profile.outer), ...(profile.holes ?? []).map(normalizedRing)] + for (const [ringIndex, ring] of rings.entries()) { + if (ring.length < 3) throw new RangeError(`Profile ring ${ringIndex} requires at least three distinct points.`) + ring.forEach((point, pointIndex) => { + validateVector(point, `profile ring ${ringIndex} point ${pointIndex}`) + if (samePoint(point, ring[(pointIndex + 1) % ring.length])) throw new RangeError(`Profile ring ${ringIndex} contains consecutive duplicate points.`) + }) + } + const origin = rings[0][0] + const normal = ringNormal(rings[0]) + if (!normal) throw new RangeError('Profile ring 0 is collinear.') + const normalLength = magnitude(normal) + for (const [ringIndex, ring] of rings.entries()) { + if (!ringNormal(ring)) throw new RangeError(`Profile ring ${ringIndex} is collinear.`) + for (const point of ring) if (Math.abs(dot(normal, subtract(point, origin))) / normalLength > 1e-7) throw new RangeError(`Profile ring ${ringIndex} is not coplanar with the outer ring.`) + } +} + +const validateLinearFeature = (input: GeometryDocumentContext & LinearFeatureParameters) => { + validateDocumentContext(input) + validatePlanarProfile(input.profile) + finitePositive(input.length, 'length') + validateVector(input.direction ?? [0, 1, 0], 'direction', false) +} + +export const validatePadInput = (input: PadInput) => validateLinearFeature(input) + +export const validatePocketInput = (input: PocketInput) => { + validateLinearFeature(input) + validateShapeContext(input, input.base) +} + +export const validateRevolutionInput = (input: RevolutionInput) => { + validateDocumentContext(input) + validatePlanarProfile(input.profile) + validateVector(input.axisOrigin ?? [0, 0, 0], 'axisOrigin') + validateVector(input.axisDirection ?? [0, 1, 0], 'axisDirection', false) + validateAngle(input.angle ?? 360, 'angle') +} + export const assertShapeHandleIntegrity = (actual: ShapeHandle, expected: ShapeHandle) => { if (actual.id !== expected.id || actual.kernel !== expected.kernel || actual.kind !== expected.kind || actual.documentId !== expected.documentId || actual.documentVersion !== expected.documentVersion) throw new Error(`Shape handle integrity check failed: ${actual.id}`) } @@ -293,6 +351,35 @@ export class BitbybitGeometryRuntime { return this.registerShape(kernelShape, input.documentId, input.documentVersion) } + async pad(input: PadInput): Promise { + validatePadInput(input) + const client = await this.readyClient() + const kernelShape = await this.createExtrusion(client, input) + return this.registerShape(kernelShape, input.documentId, input.documentVersion) + } + + async pocket(input: PocketInput): Promise { + validatePocketInput(input) + const base = this.resolveShape(input.base).reference + const client = await this.readyClient() + const tool = await this.createExtrusion(client, input) + const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false }) + return this.registerShape(kernelShape, input.documentId, input.documentVersion) + } + + async revolution(input: RevolutionInput): Promise { + validateRevolutionInput(input) + const client = await this.readyClient() + const face = await this.createProfileFace(client, input.profile) + const kernelShape = await client.occtWorkerManager.genericCallToWorkerPromise('plugins.feature.revolution', { + shape: face, + axisOrigin: input.axisOrigin ?? [0, 0, 0], + axisDirection: input.axisDirection ?? [0, 1, 0], + angle: input.angle ?? 360, + }) as KernelShapeReference + return this.registerShape(kernelShape, input.documentId, input.documentVersion) + } + async mesh(shape: ShapeHandle, precision = 0.05): Promise { finitePositive(precision, 'precision') const entry = this.resolveShape(shape) @@ -335,6 +422,27 @@ export class BitbybitGeometryRuntime { return this.client } + private async createProfileFace(client: BitByBitOCCT, profile: PlanarProfile): Promise { + const rings = [normalizedRing(profile.outer), ...(profile.holes ?? []).map(normalizedRing)] + const outerNormal = ringNormal(rings[0]) as Point3 + const orientedRings = rings.map((ring, index) => index > 0 && dot(outerNormal, ringNormal(ring) as Point3) > 0 ? [...ring].reverse() : ring) + const wires = await Promise.all(orientedRings.map((points) => client.occt.shapes.wire.createPolygonWire({ points }))) + return client.occt.shapes.face.createFaceFromWires({ shapes: wires, planar: true }) + } + + private async createExtrusion(client: BitByBitOCCT, input: LinearFeatureParameters): Promise { + const direction = input.direction ?? [0, 1, 0] + const directionLength = magnitude(direction) + const sign = input.reversed ? -1 : 1 + const extrusion: Point3 = direction.map((coordinate) => coordinate / directionLength * input.length * sign) as Point3 + let face = await this.createProfileFace(client, input.profile) + if (input.symmetricToPlane) { + const translation = extrusion.map((coordinate) => -coordinate / 2) as Point3 + face = await client.occt.transforms.translate({ shape: face, translation }) + } + return client.occt.operations.extrude({ shape: face, direction: extrusion }) + } + private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) { const handle: ShapeHandle = { id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`, diff --git a/src/facade/geometryWorker.ts b/src/facade/geometryWorker.ts index 72a4897..70f8396 100644 --- a/src/facade/geometryWorker.ts +++ b/src/facade/geometryWorker.ts @@ -39,6 +39,25 @@ const createWebCadPlugins = (occt: MainModule) => ({ } }, }, + feature: { + revolution: (inputs: { shape: TopoDS_Shape; axisOrigin: [number, number, number]; axisDirection: [number, number, number]; angle: number }) => { + const point = new occt.gp_Pnt(inputs.axisOrigin[0], inputs.axisOrigin[1], inputs.axisOrigin[2]) + const direction = new occt.gp_Dir(inputs.axisDirection[0], inputs.axisDirection[1], inputs.axisDirection[2]) + const axis = new occt.gp_Ax1(point, direction) + const operation = inputs.angle >= 360 + ? new occt.BRepPrimAPI_MakeRevol(inputs.shape, axis) + : new occt.BRepPrimAPI_MakeRevol(inputs.shape, axis, inputs.angle * Math.PI / 180, false) + try { + if (!operation.IsDone()) throw new Error('OCCT revolution failed.') + return operation.Shape() + } finally { + operation.delete() + axis.delete() + direction.delete() + point.delete() + } + }, + }, }) const initialize = async () => { diff --git a/src/facade/index.ts b/src/facade/index.ts index 33f5136..38eebc8 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -1,6 +1,6 @@ export { createMockFacade } from './mockFacade' export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler, SqliteProjectPersistence } from './projectStore' export { ThreeViewportAdapter } from './threeViewport' -export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, validateSphereInput } from './geometryRuntime' +export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime' 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, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, MeshAsset, ModelTreeItem, PersistenceCapabilities, Placement, ProjectResource, ProjectSaveResult, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' +export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, RevolutionInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 0e3d41e..1310173 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -132,7 +132,7 @@ export function createMockFacade(): BitBybitWebCadFacade { selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource }, - geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, + geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, } diff --git a/src/facade/types.ts b/src/facade/types.ts index aeddef7..5c11966 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -140,6 +140,34 @@ export type BooleanIntersectionInput = GeometryDocumentContext & { keepEdges?: boolean } +export type Point3 = [number, number, number] + +export type PlanarProfile = { + outer: Point3[] + holes?: Point3[][] +} + +export type LinearFeatureParameters = { + profile: PlanarProfile + length: number + direction?: Point3 + reversed?: boolean + symmetricToPlane?: boolean +} + +export type PadInput = GeometryDocumentContext & LinearFeatureParameters + +export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & { + base: ShapeHandle +} + +export type RevolutionInput = GeometryDocumentContext & { + profile: PlanarProfile + axisOrigin?: Point3 + axisDirection?: Point3 + angle?: number +} + export type CommandState = { id: string status: 'hidden' | 'disabled' | 'enabled' | 'active' @@ -266,6 +294,9 @@ export interface BitBybitWebCadFacade { union(input: BooleanUnionInput): Promise cut(input: BooleanCutInput): Promise intersection(input: BooleanIntersectionInput): Promise + pad(input: PadInput): Promise + pocket(input: PocketInput): Promise + revolution(input: RevolutionInput): Promise mesh(shape: ShapeHandle, precision?: number): Promise release(shape: ShapeHandle): Promise dispose(): void diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 360b8b6..00a0576 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { createMockFacade } from '../src/facade/mockFacade' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema' import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore' -import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePlacementInput, 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' test('facade exposes a stable initial document projection', () => { @@ -43,6 +43,15 @@ test('boolean geometry rejects cross-document and future-version operands', () = assert.throws(() => validateBooleanUnionInput({ shapes: [first], documentId: 'doc-one', documentVersion: 2 }), /at least 2/) }) +test('feature profiles reject degenerate and non-planar geometry', () => { + const rectangle = { outer: [[-1, 0, -1], [1, 0, -1], [1, 0, 1], [-1, 0, 1]] as [number, number, number][] } + assert.doesNotThrow(() => validatePlanarProfile(rectangle)) + assert.doesNotThrow(() => validatePadInput({ profile: rectangle, length: 4, documentId: 'doc', documentVersion: 1 })) + assert.throws(() => validatePlanarProfile({ outer: [[0, 0, 0], [1, 0, 0], [2, 0, 0]] }), /collinear/) + assert.throws(() => validatePlanarProfile({ outer: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0.1, 1]] }), /coplanar/) + assert.throws(() => validateRevolutionInput({ profile: rectangle, axisDirection: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /axisDirection/) +}) + test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () => { const shape: ShapeHandle = { id: 'shape-test', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 7 } const mesh = normalizeBitbybitMesh(shape, {