EXU: add dimensional powers and area volume units

This commit is contained in:
2026-08-02 17:39:28 -04:00
parent 171fd670af
commit a4b9d04ff4
3 changed files with 29 additions and 3 deletions

View File

@@ -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 基础切片验证记录

View File

@@ -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 {

View File

@@ -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', () => {