From a4b9d04ff4bf83c9f90e8cb94073ce2434f2e67f Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sun, 2 Aug 2026 17:39:28 -0400 Subject: [PATCH] EXU: add dimensional powers and area volume units --- docs/web-cad-implementation-plan.zh-CN.md | 2 +- src/facade/units.ts | 25 +++++++++++++++++++++-- tests/facade.test.ts | 5 +++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index efc07d3..e8694ea 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1698,7 +1698,7 @@ DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值 持久化复用 schema v1 已有 `object_properties` 表:`value_json` 保存完整 Property snapshot,`property_type` 保留可查询类型;删除/重写对象时依赖外键级联清理旧属性。内存降级、autosave、Undo/Redo 和 Facade state 均使用深拷贝,避免 options/property 数组共享引用。 -当前限制:Expression/Quantity 已完成基础词法、四则运算、长度/角度/百分比换算、对象属性引用、维度校验,以及 `abs/sin/cos/tan/atan2/min/max/clamp/round/pow` 函数的基础维度规则;locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG,支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。 +当前限制:Expression/Quantity 已完成基础词法、四则运算、`^` 幂运算、长度/面积/体积/角度/百分比换算、对象属性引用、维度校验,以及 `abs/sin/cos/tan/atan2/min/max/clamp/round/pow` 函数的基础维度规则;locale 规则、Spreadsheet alias 和完整 FreeCAD 表达式兼容仍待 EXU。PropertyLink、Expression 和下游对象现在进入统一依赖 DAG,支持 SCC 循环诊断、拓扑重算计划和 schema v3 持久化;当前重算执行器仍是 Facade 领域切片,尚未把每个节点调度到 OCCT/Sketcher Worker。多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。 ### 16.17 EXU/DAG/TSN 基础切片验证记录 diff --git a/src/facade/units.ts b/src/facade/units.ts index 2cf1627..1c4e0ed 100644 --- a/src/facade/units.ts +++ b/src/facade/units.ts @@ -18,6 +18,12 @@ const definitions: UnitDefinition[] = [ { id: 'm', symbol: 'm', dimension: 'length', factor: 1000 }, { id: 'in', symbol: 'in', dimension: 'length', factor: 25.4 }, { id: 'ft', symbol: 'ft', dimension: 'length', factor: 304.8 }, + { id: 'mm2', symbol: 'mm2', dimension: 'area', factor: 1 }, + { id: 'cm2', symbol: 'cm2', dimension: 'area', factor: 100 }, + { id: 'm2', symbol: 'm2', dimension: 'area', factor: 1_000_000 }, + { id: 'mm3', symbol: 'mm3', dimension: 'volume', factor: 1 }, + { id: 'cm3', symbol: 'cm3', dimension: 'volume', factor: 1_000 }, + { id: 'm3', symbol: 'm3', dimension: 'volume', factor: 1_000_000_000 }, { id: 'deg', symbol: 'deg', dimension: 'angle', factor: 1 }, { id: 'rad', symbol: 'rad', dimension: 'angle', factor: 180 / Math.PI }, { id: '%', symbol: '%', dimension: 'percent', factor: 1 }, @@ -63,7 +69,7 @@ const tokenize = (source: string): Token[] => { while (index < source.length) { const character = source[index] if (/\s/.test(character)) { index += 1; continue } - if ('+-*/(),'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue } + if ('+-*/^(),'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue } if (character === '%') { tokens.push({ kind: 'identifier', text: character, position: index }); index += 1; continue } const number = source.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/) if (number) { tokens.push({ kind: 'number', text: number[0], position: index }); index += number[0].length; continue } @@ -95,6 +101,15 @@ const divide = (left: Quantity, right: Quantity): Quantity => { throw new TypeError(`Cannot divide ${left.dimension} by ${right.dimension}.`) } +const power = (base: Quantity, exponent: Quantity): Quantity => { + if (exponent.dimension !== 'dimensionless' || !Number.isInteger(exponent.value)) throw new TypeError('Exponent must be a dimensionless integer.') + if (base.dimension === 'dimensionless') return { value: Math.pow(base.value, exponent.value), dimension: 'dimensionless' } + if (exponent.value === 1) return { ...base } + if (exponent.value === 2 && base.dimension === 'length') return { value: base.value ** 2, dimension: 'area' } + if (exponent.value === 3 && base.dimension === 'length') return { value: base.value ** 3, dimension: 'volume' } + throw new TypeError(`Cannot raise ${base.dimension} to the power ${exponent.value}.`) +} + const evaluateFunction = (name: string, args: Quantity[]): Quantity => { const normalized = name.toLowerCase() if (normalized === 'abs' || normalized === 'round') { @@ -170,7 +185,13 @@ class QuantityParser { private parseUnary(): Quantity { if (this.match('+')) return this.parseUnary() if (this.match('-')) { const value = this.parseUnary(); return { ...value, value: -value.value } } - return this.parsePrimary() + return this.parsePower() + } + + private parsePower(): Quantity { + const base = this.parsePrimary() + if (!this.match('^')) return base + return power(base, this.parseUnary()) } private parsePrimary(): Quantity { diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 6843f72..24b86fc 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -129,8 +129,13 @@ test('quantity expressions convert units and reject incompatible dimensions', () assert.equal(evaluateQuantityExpression('max(2 mm, 1 cm)').value.value, 10) assert.equal(evaluateQuantityExpression('clamp(15, 0, 10)').value.value, 10) assert.equal(evaluateQuantityExpression('round(2.6)').value.value, 3) + assert.equal(evaluateQuantityExpression('2 mm ^ 2').value.dimension, 'area') + assert.equal(evaluateQuantityExpression('2 mm ^ 3').value.dimension, 'volume') + assert.equal(evaluateQuantityExpression('1 cm2 + 100 mm2').value.value, 200) + assert.equal(evaluateQuantityExpression('2 ^ 3').value.value, 8) assert.throws(() => evaluateQuantityExpression('sin(2 mm)'), /angle or dimensionless/) assert.throws(() => evaluateQuantityExpression('unknown(1)'), /Unknown expression function/) + assert.throws(() => evaluateQuantityExpression('2 mm ^ 0.5'), /dimensionless integer/) }) test('topology signatures are independent of transient face indexes and flag ambiguity', () => {