diff --git a/config/compatibility-matrix.json b/config/compatibility-matrix.json
index abfa540..47885a0 100644
--- a/config/compatibility-matrix.json
+++ b/config/compatibility-matrix.json
@@ -17,7 +17,7 @@
"Inspection": { "status": "ui-manifest", "level": "unsupported", "commands": ["measure-distance", "measure-angle", "measure-area", "section", "check-dependencies"] }
},
"facadeCapabilities": {
- "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
+ "geometry": { "level": "experimental", "provider": "BitBybit OCCT 1.1.1", "operations": ["box", "cylinder", "sphere", "cone", "placement", "union", "cut", "intersection", "part-primitive-recompute", "part-boolean-recompute", "shape-check", "pad", "pocket", "pocket-through-all", "revolution", "fillet", "chamfer", "linear-pattern-whole-shape", "feature-shape-cache", "face-edge-vertex-topology", "step-export", "stl-export"] },
"document": { "level": "experimental", "operations": ["typed-properties", "expressions", "units", "dependency-dag", "parallel-level-recompute", "sync-recompute", "async-generation-recompute", "root-cause-diagnostics", "repair-branch-recompute", "undo-redo"] },
"sketcher": { "level": "experimental", "operations": ["point-line-circle-arc-model", "basic-constraints", "diameter-symmetric-tangent", "basic-solver", "persistence"] },
"fcstd": { "level": "read-only", "operations": ["zip-preflight", "document-xml-metadata", "proxy-report", "script-isolation"] }
diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md
index 8efe731..0bd063d 100644
--- a/docs/freecad-full-parity-plan.zh-CN.md
+++ b/docs/freecad-full-parity-plan.zh-CN.md
@@ -334,12 +334,13 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为
| P2-04 Project list/load | `project.list()` 暴露 SQLite/内存快照摘要,`app.document.load()` 将选定快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态、保留 Undo 边界并按完整对象图异步重算;Projects 页面按 documentId 调用 Facade API | 列表摘要、保存→修改→加载后属性恢复、选择清空、任务清空和加载重算调用通过测试 | FCStd 导入仍是只读 metadata/proxy;跨文档 ShapeHandle 和 Shape 持久化仍待完成 |
| P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 的浏览器中使用内存资源表,保留 SHA-256 身份和引用计数;恢复报告区分数据库完整但目标文档缺失的 `unavailable`;销毁 Worker 时清空临时资源 | Worker 类型检查、生产构建通过 | 内存资源会随刷新丢失;配额回收、持久资源校验和大文件压力仍待实现 |
| PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 |
-| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
+| P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`polar-pattern` 未实现状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 |
+| P3-05 Linear Pattern 实验切片 | `linear-pattern` 通过 Part Design 选择谓词、Task 和类型化属性创建 `PartDesign::LinearPattern`;按 Occurrences 2–100、总 Length 和 Horizontal/Vertical/Normal 方向调用 Placement 生成等距副本,再由 OCCT union 合并;临时副本在成功/失败时释放 | 间距/方向、联合输入、句柄释放、非法次数诊断、Base 链接、Body.Tip、属性校验和任务提交回归测试通过 | 当前复制整个 Base Shape,不等价于 FreeCAD 局部特征 Pattern;单实体规则、重叠结果策略、稳定 TopoRef、Generated/Modified/Deleted 历史、Polar Pattern 和 Hole 仍待实现,因此保持 `experimental` |
后续按以下顺序连续开发:
1. `P4-06/DAG-08`:同步/异步重算已共享抑制传播和根因诊断语义,并支持定位、解除错误后的最小闭包重算与合法特征抑制;继续补齐多配置抑制、Body Tip 重定向、最近有效 Shape 显式回滚和性能计数器。Shape 缓存已进入 Facade,但尚未持久化。
-2. `P3-04/P3-05`:Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写,Pocket Through all 已接入;继续补齐 Up to face、拓扑历史和 Pattern/Hole,未完成前保持 disabled。
+2. `P3-04/P3-05`:Pad/Pocket/Revolution/Fillet/Chamfer 已有 OCCT Worker 输入适配和 Shape 缓存回写,Pocket Through all 已接入;Linear Pattern 已完成整 Shape 实验切片;继续补齐 Up to face、局部特征/拓扑历史、Polar Pattern 和 Hole,未完成能力保持 disabled 或 experimental。
3. `TSN-03/05/07/08`:补齐 edge/vertex 签名、OCCT Generated/Modified/Deleted 历史、跨特征迁移和歧义处理;任何 transient index 不得写入持久引用。
4. `SK-03/SK-06/SK-07`:编译和验证 planegcs WASM,按基础/高级约束建立 FreeCAD 黄金回放;基础 solver 维持 experimental。
5. `P2-04/P2-05/P2-06`:完成保存点恢复报告、迁移回滚、配额回收和 BroadcastChannel 单写者冲突。
diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md
index 5f86366..29fe47e 100644
--- a/docs/web-cad-implementation-plan.zh-CN.md
+++ b/docs/web-cad-implementation-plan.zh-CN.md
@@ -1603,6 +1603,7 @@ P0 基线/治理
| P3-02 几何句柄与网格协议 | `IN PROGRESS` | Facade 已定义并实现受控 `ShapeHandle`、`MeshAsset`、`SubshapeRef` 类型;句柄携带 documentId/version,底层 OCCT hash 不出运行时,跨文档、字段篡改和释放后访问被拒绝;`geometry.topology()` 已从 BitBybit 网格生成面/边/点基础签名,OCCT 历史命名仍未实现 |
| 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 浏览器矩阵;重算 executor 已在 Worker ready 时执行 Pad/Pocket/Revolution,Revolution Angle/Reversed 已接入,Fillet/Chamfer 也有 Shape 缓存回写;Up to face、FCStd Shape 持久化仍待完成 |
+| P3-05 Fillet/Chamfer/Pattern/Hole | `IN PROGRESS` | Fillet/Chamfer 已接入 Shape 缓存;Linear Pattern 已贯通选择谓词、Task、`App::PropertyInteger`、Base/Body.Tip、Placement 等距副本、OCCT union、临时句柄释放和结构化输入诊断。当前为整 Shape 实验语义;局部特征历史、稳定 TopoRef、Polar Pattern 与 Hole 仍待实现 |
| P5-02 网格增量接入 | `IN PROGRESS` | Three Adapter 可用 `BufferGeometry` 接收 Facade `MeshAsset`,替换时释放旧 GPU geometry;视口优先使用重计算缓存的对象 Shape,失败时保留最近有效结果,无缓存时才创建并释放 Pad/Pocket 临时预览链;对象级增量缓存与选择映射尚未实现 |
| P8-01 第一批自动化场景 | `IN PROGRESS` | `tests/facade.test.ts` 已覆盖 47 个场景,包括 Part 命令、基本体/布尔重算、async generation 重算、DAG 分层并行、抑制传播/恢复、根因诊断/修复、OCCT 特征执行器、FCStd 安全检查、schema、串行/跨标签持久化、资源引用、事件、任务、禁用/未实现命令、历史和表达式;E2E/黄金几何待补齐 |
@@ -1697,7 +1698,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值
| 类型控件 | `PASS` | View 标签由 metadata 生成 1 个 checkbox、2 个 enumeration、2 个 numeric、2 个 color 控件 |
| Part primitive Task | `PASS` | Part 工作台任务可选择 Box/Cylinder/Sphere/Cone,尺寸和角度草稿写入 `Part::*` 对象属性并进入 OCCT 重算 |
| Part Boolean Task | `PASS` | Union/Cut/Intersection 任务提供 Base/Tool 对象选择槽,链接写入对象依赖图;确认后将新对象及其依赖闭包标记 dirty 并触发异步重算 |
-| Part Design Task | `PASS` | Pad/Pocket/Revolution/Fillet/Chamfer 任务草稿写入 Length/Type/Angle/Reversed/Midplane/Radius/Distance 属性,确认后仍通过 Facade 历史边界提交 |
+| Part Design Task | `PASS` | Pad/Pocket/Revolution/Fillet/Chamfer 任务草稿写入 Length/Type/Angle/Reversed/Midplane/Radius/Distance;Linear Pattern 写入 Base/Occurrences/Length/Direction,确认后仍通过 Facade 历史边界提交并更新 Body.Tip |
| 选择谓词 | `PASS` | 选择 Origin/文件夹后 Part Union/Check geometry 被禁用;选择非 Sketch 对象后 Sketcher Solve 被禁用,并返回可解释原因 |
| 校验 | `PASS` | 负 Length、越界 Percent、未知 Enumeration、无效颜色、缺失 Link、只读 Property 在 Facade 拒绝 |
| Undo/Redo | `PASS` | Length 42→50 可撤销回 42、重做至 50,Property 和树投影同步 |
diff --git a/src/App.tsx b/src/App.tsx
index c781be5..23124db 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -364,14 +364,14 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
const activeCommand = activeTask?.commandId
const activeDocument = facade.app.document.getActive()
const booleanCommand = activeCommand === 'union' || activeCommand === 'cut' || activeCommand === 'intersection'
- const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId))
+ const shapeObjects = activeDocument.objects.filter((object) => object.typeId.startsWith('Part::') || ['PartDesign::Feature', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::LinearPattern'].includes(object.typeId))
const draftLink = (name: string) => typeof activeTask?.draft[name] === 'string' ? String(activeTask.draft[name]) : ''
const acceptTask = () => {
if (!activeTask || activeTask.status !== 'preview') { showNotice('No active task'); return }
facade.task.apply()
void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Task accepted and recompute completed' : `Task accepted; recompute ${result.status}`))
}
- return
Active command
{isSketch ? 'Edit Sketch' : definition.taskTitle}
Preview
+ return Active command
{isSketch ? 'Edit Sketch' : definition.taskTitle}
Preview
}
function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) {
@@ -403,7 +403,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))]
editor =
} else if (property.type === 'App::PropertyColor') editor =
- else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor =
+ else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') editor =
else editor = { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} />
return <>{property.expression && {property.expression}
}>
}
diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts
index 8458aab..4d59a33 100644
--- a/src/facade/mockFacade.ts
+++ b/src/facade/mockFacade.ts
@@ -42,7 +42,7 @@ const initialTree: ModelTreeItem[] = [
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
]
-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('cone') ? 'Part::Cone' : 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('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
+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('cone') ? 'Part::Cone' : 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('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.id.startsWith('linear-pattern') ? 'PartDesign::LinearPattern' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
{ name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label },
@@ -111,6 +111,12 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
{ name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true },
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
]
+ if (item.id.startsWith('linear-pattern')) return [
+ { name: 'Base', label: 'Base', group: 'Pattern', scope: 'data', type: 'App::PropertyLink', value: null, recompute: true },
+ { name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data', type: 'App::PropertyInteger', value: 2, recompute: true },
+ { name: 'Length', label: 'Length', group: 'Pattern', scope: 'data', type: 'App::PropertyLength', value: 20, unit: 'mm', recompute: true },
+ { name: 'Direction', label: 'Direction', group: 'Pattern', scope: 'data', type: 'App::PropertyEnumeration', value: 'Horizontal', options: ['Horizontal', 'Vertical', 'Normal'], recompute: true },
+ ]
if (item.type === 'sketch') return [
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true },
{ name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true },
@@ -169,10 +175,10 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', '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 implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
-const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer'])
+const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'linear-pattern', 'primitive', 'union', 'cut', 'intersection', 'check-shape', 'solve-sketch'])
+const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'linear-pattern'])
const partCommands = new Set(['primitive', 'union', 'cut', 'intersection', 'check-shape'])
-const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer'])
+const shapeSelectionCommands = new Set(['union', 'cut', 'intersection', 'check-shape', 'fillet', 'chamfer', 'linear-pattern'])
const featureSelectionCommands = new Set(['pad', 'pocket', 'revolution'])
const shapeTypeIds = new Set([
'Part::Box',
@@ -189,6 +195,7 @@ const shapeTypeIds = new Set([
'PartDesign::Revolution',
'PartDesign::Fillet',
'PartDesign::Chamfer',
+ 'PartDesign::LinearPattern',
])
const featureCommands: Record = {
'create-body': { label: 'Body', detail: 'Part Design body' },
@@ -198,6 +205,7 @@ const featureCommands: Record = {
revolution: { label: 'Revolution', detail: 'Angle 360 deg' },
fillet: { label: 'Fillet', detail: 'Radius 3 mm' },
chamfer: { label: 'Chamfer', detail: 'Length 2 mm' },
+ 'linear-pattern': { label: 'Linear Pattern', detail: '2 occurrences over 20 mm' },
primitive: { label: 'Box', detail: '10 × 10 × 10 mm' },
union: { label: 'Union', detail: 'Boolean fuse' },
cut: { label: 'Cut', detail: 'Boolean difference' },
@@ -223,7 +231,9 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
if (property.readOnly) throw new Error(`${property.label} is read-only.`)
if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`)
if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`)
- if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
+ if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`)
+ if (property.type === 'App::PropertyInteger' && !Number.isSafeInteger(value)) throw new TypeError(`${property.label} requires an integer value.`)
+ if (property.name === 'Occurrences' && ((value as number) < 2 || (value as number) > 100)) throw new RangeError('Occurrences must be between 2 and 100.')
if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle') && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`)
if (property.type === 'App::PropertyAngle' && (value as number) > 360) throw new RangeError(`${property.label} must be between 0 and 360 degrees.`)
if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`)
@@ -314,8 +324,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
const beginTask = (commandId: string, draft: Record = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task }
const nextFeatureId = (label: string) => {
- const base = label.toLowerCase()
- const existing = state.document.tree.filter((item) => item.label.toLowerCase().startsWith(base)).length
+ const base = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
+ const existing = state.document.tree.filter((item) => item.id.startsWith(base)).length
return existing === 0 ? base : `${base}${String(existing).padStart(3, '0')}`
}
const appendFeature = (document: DocumentSnapshot, commandId: string, draft: Record = {}): { document: DocumentSnapshot; objectId: string } => {
@@ -343,13 +353,15 @@ export function createMockFacade(): BitBybitWebCadFacade {
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Profile' && sourceObject.typeId === 'Sketcher::SketchObject') return { ...property, value: sourceObject.id }
if (property.type === 'App::PropertyLink' && sourceObject && property.name === 'Base' && shapeTypeIds.has(sourceObject.typeId)) return { ...property, value: sourceObject.id }
if (property.type === 'App::PropertyBool' && typeof candidate === 'boolean') return { ...property, value: candidate }
- if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate }
+ if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyAngle' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat' || property.type === 'App::PropertyInteger') && typeof candidate === 'number' && Number.isFinite(candidate)) return { ...property, value: candidate }
if (property.type === 'App::PropertyEnumeration' && typeof candidate === 'string' && property.options?.includes(candidate)) return { ...property, value: candidate }
return property
})
if (isPartObject && commandId !== 'primitive') {
objectSnapshot.properties = objectSnapshot.properties.map((property) => property.name === 'Base' && typeof draft.base !== 'string' && state.selectedObjectId ? { ...property, value: state.selectedObjectId } : property)
}
+ for (const property of objectSnapshot.properties.filter((candidate) => candidate.recompute && !candidate.readOnly)) validatePropertyValue(document, property, property.value)
+ if (commandId === 'linear-pattern' && Number(objectSnapshot.properties.find((property) => property.name === 'Length')?.value) <= 0) throw new RangeError('Linear pattern length must be greater than zero.')
const objects = [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), objectSnapshot]
const tipObject = type === 'feature' && partDesignCommands.has(commandId) && commandId !== 'create-sketch' ? objects.find((object) => object.id === 'body') : undefined
if (tipObject) {
diff --git a/src/facade/recomputeEngine.ts b/src/facade/recomputeEngine.ts
index 0c4775f..c9909aa 100644
--- a/src/facade/recomputeEngine.ts
+++ b/src/facade/recomputeEngine.ts
@@ -1,6 +1,6 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
-import type { BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
+import type { ApplyPlacementInput, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, RevolutionInput, ShapeHandle } from './types'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
@@ -35,6 +35,7 @@ export type RecomputeGeometryRuntime = {
createCylinder(input: CreateCylinderInput): Promise
createSphere(input: CreateSphereInput): Promise
createCone(input: CreateConeInput): Promise
+ applyPlacement(input: ApplyPlacementInput): Promise
union(input: BooleanUnionInput): Promise
cut(input: BooleanCutInput): Promise
intersection(input: BooleanIntersectionInput): Promise
@@ -301,7 +302,7 @@ export const createFacadeGeometryRecomputeExecutor = (
return base
}
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
- if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
+ if (!['Part::Box', 'Part::Cylinder', 'Part::Sphere', 'Part::Cone', 'Part::Fuse', 'Part::Cut', 'Part::Common', 'PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Revolution', 'PartDesign::Fillet', 'PartDesign::Chamfer', 'PartDesign::LinearPattern'].includes(object.typeId)) return base
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket' || object.typeId === 'PartDesign::Revolution'
const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined
@@ -357,6 +358,26 @@ export const createFacadeGeometryRecomputeExecutor = (
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.')
result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) })
+ } else if (object.typeId === 'PartDesign::LinearPattern') {
+ const baseObject = linkedObject(object, 'Base', document)
+ const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
+ if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Linear pattern base has no valid recomputed Shape.')
+ const occurrences = numberProperty('Occurrences', 2)
+ if (!Number.isSafeInteger(occurrences) || occurrences < 2 || occurrences > 100) return geometryFailure(object.id, 'PATTERN_OCCURRENCES_INVALID', 'Linear pattern occurrences must be an integer between 2 and 100.')
+ const length = numberProperty('Length', 20)
+ if (!(length > 0)) return geometryFailure(object.id, 'PATTERN_LENGTH_INVALID', 'Linear pattern length must be greater than zero.')
+ const direction = propertyValue(object, 'Direction')
+ const axis: [number, number, number] = direction === 'Vertical' ? [0, 1, 0] : direction === 'Normal' ? [0, 0, 1] : [1, 0, 0]
+ const copies: ShapeHandle[] = []
+ try {
+ for (let index = 1; index < occurrences; index += 1) {
+ const offset = length * index / (occurrences - 1)
+ copies.push(await geometry.applyPlacement({ ...documentContext, shape: baseShape, placement: { translation: [axis[0] * offset, axis[1] * offset, axis[2] * offset], rotationAxis: [0, 0, 1], rotationAngle: 0 } }))
+ }
+ result = await geometry.union({ ...documentContext, shapes: [baseShape, ...copies] })
+ } finally {
+ await Promise.allSettled(copies.map((copy) => geometry.release(copy)))
+ }
} else {
const baseObject = linkedObject(object, 'Base', document)
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
diff --git a/src/facade/types.ts b/src/facade/types.ts
index 69c7d9f..3a05c26 100644
--- a/src/facade/types.ts
+++ b/src/facade/types.ts
@@ -21,7 +21,7 @@ export type ObjectPropertySnapshot = {
label: string
group: string
scope: 'data' | 'view'
- type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat'
+ type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyAngle' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' | 'App::PropertyInteger'
value: PropertyValue
unit?: string
readOnly?: boolean
diff --git a/tests/facade.test.ts b/tests/facade.test.ts
index ac2e75a..482f647 100644
--- a/tests/facade.test.ts
+++ b/tests/facade.test.ts
@@ -377,7 +377,8 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t
createCylinder: async () => shape('cylinder-shape'),
createSphere: async () => shape('sphere-shape'),
createCone: async () => shape('cone-shape'),
- union: async () => shape('union-shape'),
+ applyPlacement: async (input) => { calls.push(`placement:${input.placement.translation.join(',')}`); return shape(`pattern-copy-${calls.filter((call) => call.startsWith('placement:')).length}`) },
+ union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('union-shape') },
cut: async () => shape('cut-shape'),
intersection: async () => shape('intersection-shape'),
pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') },
@@ -437,6 +438,26 @@ test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains t
assert.equal(shapes.get('revolution')?.id, 'revolution-shape')
assert.ok(calls.includes('revolution:270:-1'))
+ const linearPattern = { id: 'linear-pattern', typeId: 'PartDesign::LinearPattern', properties: [
+ { name: 'Base', label: 'Base', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
+ { name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyInteger' as const, value: 3 },
+ { name: 'Length', label: 'Length', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 20, unit: 'mm' },
+ { name: 'Direction', label: 'Direction', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Vertical', options: ['Horizontal', 'Vertical', 'Normal'] },
+ ] }
+ const patternResult = await executor(linearPattern, document, context)
+ assert.equal(patternResult.status, 'success')
+ assert.equal(shapes.get('linear-pattern')?.id, 'union-shape')
+ assert.ok(calls.includes('placement:0,10,0'))
+ assert.ok(calls.includes('placement:0,20,0'))
+ assert.ok(calls.includes('union:pad-shape,pattern-copy-1,pattern-copy-2'))
+ assert.ok(calls.includes('release:pattern-copy-1'))
+ assert.ok(calls.includes('release:pattern-copy-2'))
+
+ const invalidPattern = { ...linearPattern, properties: linearPattern.properties.map((property) => property.name === 'Occurrences' ? { ...property, value: 1 } : property) }
+ const invalidPatternResult = await executor(invalidPattern, document, context)
+ assert.equal(invalidPatternResult.status, 'failed')
+ assert.equal(invalidPatternResult.errors?.[0].code, 'PATTERN_OCCURRENCES_INVALID')
+
const suppressedPad = { ...pad, properties: [...pad.properties, { name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
const suppressedResult = await executor(suppressedPad, document, context)
assert.equal(suppressedResult.status, 'suppressed')
@@ -453,6 +474,7 @@ test('Part primitive and boolean nodes execute through the same Shape cache', as
createCylinder: async () => shape('cylinder-shape'),
createSphere: async () => shape('sphere-shape'),
createCone: async () => shape('cone-shape'),
+ applyPlacement: async (input) => { calls.push(`placement:${input.placement.translation.join(',')}`); return shape('placed-shape') },
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('fuse-shape') },
cut: async () => shape('cut-shape'),
intersection: async () => shape('common-shape'),
@@ -620,10 +642,10 @@ test('disabled commands return a reason instead of mutating the document', () =>
test('manifest commands without a Bitbybit executor are explicitly unsupported', () => {
const facade = createMockFacade()
const before = facade.getState().document.version
- const state = facade.gui.command.getState('linear-pattern')
+ const state = facade.gui.command.getState('polar-pattern')
assert.equal(state.status, 'disabled')
assert.match(state.reason || '', /business executor/)
- facade.gui.command.execute({ commandId: 'linear-pattern' })
+ facade.gui.command.execute({ commandId: 'polar-pattern' })
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
assert.equal(facade.getState().document.version, before)
})
@@ -878,6 +900,25 @@ test('Part Design feature tasks commit a document object and remain undoable', (
assert.equal(revolution?.properties.find((property) => property.name === 'Angle')?.value, 360)
facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 })
assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180)
+
+ facade.gui.command.execute({ commandId: 'linear-pattern' })
+ assert.equal(facade.task.getActive()?.commandId, 'linear-pattern')
+ facade.task.update({ occurrences: 4, length: 30, direction: 'Vertical' })
+ facade.task.apply()
+ const pattern = facade.app.document.getObject('linear-pattern')
+ assert.equal(pattern?.typeId, 'PartDesign::LinearPattern')
+ assert.equal(pattern?.properties.find((property) => property.name === 'Base')?.value, 'revolution')
+ assert.equal(pattern?.properties.find((property) => property.name === 'Occurrences')?.value, 4)
+ assert.equal(pattern?.properties.find((property) => property.name === 'Length')?.value, 30)
+ assert.equal(pattern?.properties.find((property) => property.name === 'Direction')?.value, 'Vertical')
+ assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'linear-pattern')
+ assert.throws(() => facade.app.document.setProperty({ objectId: 'linear-pattern', propertyName: 'Occurrences', value: 1 }), /between 2 and 100/)
+
+ facade.gui.command.execute({ commandId: 'linear-pattern' })
+ facade.task.update({ occurrences: 1, length: 0 })
+ assert.throws(() => facade.task.apply(), /between 2 and 100/)
+ assert.equal(facade.app.document.getObject('linear-pattern001'), null)
+ facade.task.cancel()
})
test('Part workbench commands create primitive and boolean document objects', () => {