格物 DSL 工程 APP 构建:构建端设计与工程 APP 定义
工程师在格物里选一份 DSL 输入(源 YS),从白名单允许的位置里挑出需要由用户提供的部分设为参数,例如模型 {{.model}}、受力面 {{.force_faces}}、载荷 {{.force_x}}、压力公式里的系数 {{.pressure_floor}}。后端据此生成带占位符的 YS 模板和参数定义(标准 JSON Schema),组成一份工程 APP 定义,放到共享存储上交给 luban。luban 按 schema 渲染表单,把用户输入的参数值回填进 YS 后自行运行。
flowchart LR
subgraph G[格物 构建端]
direction TB
W["可配置项白名单"] --> S["scan:列出源 YS 的候选参数"] --> C["构建页:选择候选、设置标题与范围"] --> N["generate:app.json + template.ys"] --> K["发布自检 R1–R12"]
end
subgraph L[luban]
direction TB
F["按 form 渲染表单"] --> A["上传模型、选面选体、输入数值"] --> R["校验并回填 YS"] --> X["luban 自行运行"]
end
K -->|共享存储路径| F
1范围
- 分析类型不设限:只要 DSL 能表达、eucliddsl 能导出的分析都可以做成工程 APP,例如 OpenRadioss 显式、Code_Aster 静力。
- 可配置的位置由白名单决定:只有白名单里的 DSL key 可以设为参数(第 4 节)。不在白名单里的内容保持源 YS 原样。
- 源 YS 有两种来源:格物作业留存的
job.ys,或者工程师上传的 YS 加几何。后者可以覆盖格物工况 builder 还生成不了的分析。 - 用户自带模型:模型(
geometry.source)和所有几何组都是参数,选面、选体没有默认值,工程 APP 不绑定任何一个模型。 - 交付:格物把工程 APP 定义写到共享存储(当前是 COS)的一个目录,并给出路径,由 luban 拉取。
- 这一版不做:材料(两个产品的材料库还没打通,材料保持源 YS 原样);顶层模型名
model <name>;luban 的组件/流程图概念;把 DSL 抽象成组件;表达式参数(只能替换公式里的数字);数值单位切换;多级联动。
2现状(设计依据)
| 事实 | 出处 | 对设计的影响 |
|---|---|---|
| 工况 JSON(Mongo Case)是唯一源,格物从不存储或编辑 DSL 文本,YS 由 Go builder 生成。 | openradioss_ys.go:93、wheel_kind.go:35 | 参数化的对象是 builder 的产物或上传的 YS,不改 builder。 |
提交时 YS 根据工况重新生成,写入本地 job.ys 后连同工作目录上传到集群,不进 COS。 | openradioss.go:397、wheel_bending_runner.go:112、abaqus.go:220 | 需要把 job.ys 留存到 COS,作为源 YS。 |
YS 是花括号块结构:geometry、mesh、function、material、assign、condition、step、output。几何组写成 group X { type surface|volume; mesh_group …; indices [...] },indices 就是选取得到的 femapId。 | openradioss_ys.go:922、case_types.go:231 | 用“块关键字路径 + 行关键字”定位可配置项,例如 geometry.group.indices。 |
压力公式写在 function X { expression { arguments X Y; formula { … } } } 里,由 pressure X 引用。 | wheel_radial_enhanced_ys.go:376-401 | 公式里的数字可以设为参数(第 4 节)。 |
格物的运行链路:femapcli --input x.ys --output out/ 按 mesh.source 生成网格,再由 eucliddsl --input job.ys --output job.<ext> 编译。 | generateMesh_dsl.go:49-83、worker/preprocess/.../adapter-femap/src/cli.rs:110、openradioss.go:269 | 仅供参考。回填后的 YS 是一份完整的 DSL 输入,怎么运行由 luban 决定。 |
| DSL 的数字不带单位,单位制由 YS 的作者约定。格物 builder 统一用 N、mm、t、s、MPa,并输出最短往返浮点。 | wheel_bending_ys_test.go:84-118、openradioss_ys.go:1109;eucliddsl README 未规定单位 | app.json 用 runtime.units 声明模板的单位制,数值参数的 scale 以它为准。 |
eucliddsl 不支持文件级常量;--output x.ys 能把 DSL 解析为 IR 再写回,不依赖网格,公式里未定义的名字会报错。 | 本地 release 二进制(git base 505eaed) | 占位符在文本层替换;.ys 输出用作语法自检(R11)。 |
3思路:一份白名单串起全链路
- 白名单(catalog):列出允许设为参数的 DSL key,每个 key 固定 render 类型和允许的输入类型。它是唯一的规则源:scan 只列它允许的候选,generate 按它填参数定义,自检按它校验,元 schema 的 key 枚举也由它生成。
- 源 YS → 候选:scan 扫描源 YS,按白名单列出所有可设为参数的位置。每个候选有一个稳定的定位符(locator),例如
condition.mechanical.neumann.force.value@RemoteForce[0],以及它的源值。 - selection:构建页保存的数据,只有工程师的选择:用哪些候选、参数名、标题、单位与 scale、范围、选项、分组。
- generate:源 YS + selection 生成
app.json和template.ys。参数类型、items、默认值(源值按 scale 换算)、binding、占位符、sha256 都自动生成,不手写。 - 占位符与源值:占位符
{{.slot}}替换源 YS 中的一段字节区间。把源值反推成参数值再回填,必须还原出源 YS(R10)。
一个参数走完全程(示例里的弯曲载荷 force_x)
| 环节 | 内容 |
|---|---|
| 白名单 | condition.mechanical.neumann.force.value → render number,输入类型 number |
| scan 候选 | locator condition.mechanical.neumann.force.value@RemoteForce[0],源值 4135 |
| selection(构建页保存) | {"field": "force_x", "title": "弯曲载荷 FX", "from": [上面的 locator], "x-unit": "kN", "scale": 1000, "minimum": 0.1, "maximum": 50} |
| generate → form | "force_x": {"type": "number", "x-widget": "number", "x-unit": "kN", "minimum": 0.1, "maximum": 50, "default": 4.135} |
| generate → binding | {"slot": "force_x", "field": "force_x", "key": "condition.mechanical.neumann.force.value", "render": {"kind": "number", "scale": 1000}} |
| template.ys | value {{.force_x}} 0 0 |
| luban 用户输入 | "force_x": 10.5 |
| 回填后的 YS | value 10500 0 0 |
4可配置项白名单
key 的写法是“外层块关键字 + 行关键字”,顶层 model 块不计入。例如 condition "RemoteForce" { mechanical { neumann { force { value … } } } } 里的 value 是 condition.mechanical.neumann.force.value。占位符替换整段块正文时,key 就是外层块路径。
| key | 源 YS 中的样子 | 输入类型 | render |
|---|---|---|---|
geometry.source | source "my_wheel.STEP" | model-upload | file |
geometry.group.indices | indices [61 62] | face-pick(type surface)/ body-pick(type volume) | ids |
mesh.generation.min_size / max_size | max_size 6.0 | number | number |
mesh.create_mesh.point.coordinates | coordinates 0 0 -1000000 | number(每个分量一个候选) | number |
condition.mechanical.dirichlet | 块正文中 region 之后的部分,例如 dof UX UY UZ 和 value 0 0 0 | select / switch | map |
condition.mechanical.dirichlet.value | value 0 0 0 | number | number |
condition.mechanical.neumann.force.value | value 4135 0 0 | number | number |
condition.mechanical.neumann.pressure | pressure 3 | number | number |
initial.kinematic.velocity.value | value 0 0 -10000 | number | number |
step.control.time.duration | duration 0.01 | number | number |
function.expression.formula | 公式里的数字字面量,例如 1.9 * max(0.0, …) 中的 1.9、0.0 | number | number |
公式里的数字
例如乘用车车轮径向疲劳滚动试验的压力载荷公式,把 0.0 设为数值参数:
function RadialPressure {
expression {
arguments X Y
formula {
{{.peak_pressure}} * max({{.pressure_floor}}, cos(min(pi, abs(atan2(X, Y))* pi / (2.0 * {{.half_angle}}))))
}
}
}- 只能替换数字字面量:scan 只把公式里的无符号数字列为候选,示例公式得到
1.9、0.0、2.0、0.61四个,按出现顺序编号 [0]–[3]。名字、运算符、函数调用都不能设为参数。 - 负数加括号:公式里的负值回填为
(-0.05),避免出现X - -0.05。 - 比较时按数字处理:R10 的分词会把
( ) , * / +单独切开,并把(-0.05)视同-0.05。所以max(0.0,与回填的max(0,按数值比较。
5构建端流程(格物 structure web)
- 选源 YS:从作业详情发起“构建工程 APP”(DSL 作业,已成功且留存了
job.ys),或者上传 YS 和它引用的几何。几何走现有转换,供构建页 3D 联动。 - 加载:后端 scan 源 YS,返回 YS 文本、候选参数列表(只含白名单 key)和几何的 viewer 模型。
- 设置参数:在 YS 视图里点击候选,填写参数名、标题、单位与 scale、范围、选项和分组。
geometry.source和每个几何组都必须设为参数(R9),构建页默认已勾选。 - 预览:后端用当前 selection 执行 generate 和自检,右侧用生成的 form 渲染表单。“试渲染”时回填示例值,返回渲染后的 YS 和相对源 YS 的 diff。
- 发布:冻结版本,执行 R1–R12,把工程 APP 定义写到共享存储目录,返回
app.json的路径。
source "1975_ray.STEP"
group FIXED {
indices [61 62 63 …]
}
…
value 4135 0 0- 参数
- force_x
- 标题
- 弯曲载荷 FX
- 单位
- kN(×1000 → N)
- 范围
- 0.1 – 50
- 分组
- 约束与载荷
发布自检(JSON Schema 表达不了的语义规则)
| 规则 | 内容 |
|---|---|
| R1 | files.template.sha256 与文件内容一致。 |
| R2 | 模板只包含 {{.key}} 形式的占位符。模板里的 slot 集合与 bindings 完全一致,每个 slot 只绑定一次。源 YS 中不允许出现 {{ 或 }}。 |
| R3 | binding 引用的 field 必须存在。render 类型与输入类型匹配:number 对 number,face/body-pick 对 ids,model-upload 对 file,select/switch 对 map。map 必须覆盖全部选项(switch 是 "true"/"false")。 |
| R4 | 选面、选体、模型必须必填且不带条件。其余 field 要么必填,要么有 default。带 x-visible-when 的 field 不能必填,必须有 default,且只能引用一个本身不带条件的 field。 |
| R5 | 每个 field 要么被 binding 引用,要么被别的 field 的 x-visible-when 引用。 |
| R6 / R7 | x-group 必须在 x-groups 中定义,required 里的键必须存在。 |
| R8 | 所有 default 本身要能通过 form 校验。 |
| R9 | 恰好一个 model-upload field,它只写入 geometry.source 的文件名;geometry 块里每个 indices 都必须是绑定到选取 field 的 slot。 |
| R10 | 把源值反推成参数值(ids 解析为 femap_id,数值除以 scale,map 找到文本相同的选项),这些参数值必须通过 form 校验,回填后与源 YS 逐 token 一致。数字按相对误差 1e-10 比较,因为 builder 输出最短往返浮点,而回填用 %.12g。被隐藏的条件字段使用 default,所以它的 default 必须等于源值。 |
| R11 | 用源值和若干边界值回填后,eucliddsl --output x.ys 解析通过。上传的 YS 没有跑过,必须通过 R11 才能发布。 |
| R12 | 每个 binding 声明的 key 必须在白名单里,并且等于占位符在模板中的实际位置;输入类型必须是该 key 允许的类型。key 的枚举和 key 对应的 render 类型也写进了元 schema,由元 schema 先行拦截。 |
6后端:生成与维护
由 selection 生成 app.json
后端不手写 app.json。构建页只保存 selection,app.json 和 template.ys 每次由 generate 确定性地生成:
| app.json 中的内容 | 来源 |
|---|---|
type、x-widget、items、required(选取与模型) | 白名单 key 的 render 类型;几何组的 type surface|volume 决定 face-pick 还是 body-pick |
default | 源值除以 scale;select 的默认是 text: null(保持源 YS 原文)的那个选项 |
bindings(slot、key、render、cases) | selection 的 from 对应的候选。一个参数可以绑定多个候选(必须是同一个 key),这时 slot 名为 参数名_序号 |
template.ys、files.template.sha256 | 按候选的字节区间替换为占位符后计算 |
标题、描述、单位、scale、范围、选项、分组、显隐、x-accept | 工程师在构建页填写,存在 selection 里 |
app、source、runtime | 工程 APP 信息、源 YS 来源、目标后端与单位制 |
生成后先用元 schema 校验,再跑 R1–R12,全部通过才能保存为可发布版本。selection 引用了白名单以外的候选时,generate 直接报错。
| 谁做 | 做什么 |
|---|---|
后端(structure,pkg/dslapp) | scan、generate、R1–R12、渲染预览、发布。app.json 和 template.ys 只在后端生成。 |
| 构建页(格物前端) | 展示源 YS 和候选,编辑并保存 selection;用 dslAppPreview 返回的 form 渲染预览(与 luban 同一套 rjsf 渲染),展示自检结果和渲染 diff。前端不拼 app.json。 |
代码组织(Go,structure 服务)
catalog.go 白名单:key → render、允许的输入类型(唯一规则源) keypath.go YS 块结构遍历、key 路径计算(scan 与 R12 共用) scan.go 源 YS → 候选列表(locator、字节区间、源值) generate.go 源 YS + selection → AppDefinition + template rules.go R1–R12、R10 反推与比较 render.go 参数值 → 回填后的 YS(预览与自检共用,可供 luban 参考) types.go AppDefinition / Form / Field / Binding 结构体 schema.go app.schema.base.json + catalog → app.schema.json app.schema.base.json 元 schema 的手写部分(go:embed) app.schema.json 生成产物,提交入库,供 luban 获取 testdata/ 两个示例(源 YS、selection、期望产物)与反例
元 schema 的维护
- 手写与生成分开:结构、输入类型、render 类型等写在
app.schema.base.json;configurableKey枚举和 key → render 类型的约束由catalog.go生成(go generate ./pkg/dslapp)。新增可配置项只改白名单一处。 - 防止漂移:测试里重新生成元 schema,与入库的
app.schema.json比对,不一致就失败;两个示例由 selection 重新 generate,必须通过全部自检;反例必须被预期的规则拦下。 - 版本:
$id带版本号,app.json的$schema与它相同,两者都是标识符,实际文件按版本放在共享存储上。只增加白名单 key 或可选关键字时升次版本(1.0 → 1.1),已发布的工程 APP 不受影响;改变已有规则时升主版本。各版本发布到共享存储dsl_app/schemas/<版本>/app.schema.json,luban 按app.json声明的版本校验。
接口与存储(尽量复用现有接口)
| 动作 | 现有接口/位置 | 改动 |
|---|---|---|
| 留存源 YS | service_hub 的 3 个 prepare 写 job.ys 处:openradioss.go:397、wheel_bending_runner.go:112、abaqus.go:220 | 写完后上传到 COS <caseDir>/_job_dsl/<jid>.ys,规则同 _mesh_dsl(mesh_dsl_builder.go:51)。上传失败只记日志,不影响作业。只覆盖上线后的作业 |
| 上传源 YS | 现有文件上传与几何转换(WS convert,ws_logic.go:191) | 复用。上传的 YS 和几何作为源 YS 来源,几何转换结果供构建页 3D 联动。 |
| 加载源 YS 与候选 | WS /api/structure/ws 的消息分发(ws_logic.go:191-247) | 新消息 dslAppSource{jid} 或 dslAppSource{ysPath, geometryPath},返回 YS、scan 得到的候选和几何的 viewer key。新增消息只是追加,老客户端不受影响。 |
| 保存 selection / 列表 | 同上 | 新消息 dslAppSave{draftId, selection}、dslAppList |
| 预览与试渲染 | 同上 | 新消息 dslAppPreview{draftId, values}:执行 generate 和 R1–R10、R12,返回生成的 form、渲染后的 YS 和 diff;R11 交给下面的 hub 任务。 |
| 发布 | 同上 | 新消息 dslAppPublish{draftId}:冻结版本,写目录到共享存储,返回 app.json 路径。 |
| eucliddsl 自检 | hub 任务分发(solver_task_init.go:69),调用方式同 "Solver-Calc"(calculation.go:921) | 新 hub 任务 Solver-DslAppCheck:eucliddsl 只部署在 service_hub(openradioss.go:130)。 |
| 存储 | 无 | 新 Mongo 集合 dsl_app,一个版本一个文档,字段见下。不修改现有表。 |
{
_id, companyId, uid,
appKey, version, // 从 selection.app 复制,供列表查询
status: "draft" | "published",
selection, // 构建页保存的唯一输入,含 app、source、runtime、groups、params
appPath, schemaVersion, // 发布后:共享存储上 app.json 的路径与元 schema 版本
createdAt, updatedAt, publishedAt
}7工程 APP 定义目录(共享存储)
<共享存储>/dsl_app/<appKey>/<version>/
├── app.json 工程 APP 信息、运行目标、form、bindings
├── template.ys 带占位符 {{.slot}} 的 YS 模板
└── help/… 可选:x-help-image 引用的提示图
<共享存储>/dsl_app/schemas/<版本>/app.schema.json
格物只交这两个文件(以及可选的提示图),不带样例模型、viewer 文件或网格:模型由用户上传,网格从回填后的 YS 生成。luban 拿到的是 app.json 的路径,files.*.path 都相对于这个目录。当前共享存储是 COS,以后两套系统共用存储,按路径分发即可。
8JSON Schema
- 元 schema
app.schema.json:约束整个app.json,包括 binding 的key必须在白名单里。luban 导入时先用它校验。 - form:
app.json里的一个字段,本身就是一份标准 JSON Schema 2020-12,描述用户提交的参数值对象。任何 2020-12 校验器都能直接拿它校验参数值,例如前端的 ajv,或 Go 的google/jsonschema-go(Titan 已在用 v0.4.2,已实测)。x-*关键字会被校验器忽略。根上固定写additionalProperties: false,因此未知键会被直接拒绝。
form 关键字
| 关键字 | 标准/扩展 | 含义 |
|---|---|---|
additionalProperties: false | 标准 | 根上必须写,拒绝未知参数键。 |
type title description default required | 标准 | 常规含义。default 只允许出现在 number、select、switch 上。 |
minimum maximum exclusive* multipleOf | 标准 | number 的范围,按显示单位填写。 |
oneOf: [{const, title}] | 标准 | 下拉选项:值和显示名。 |
minItems maxItems | 标准 | 选取数量,例如单个冲击体写 maxItems: 1。 |
properties.name.pattern | 标准 | 模型文件名的约束,供前端即时提示。不论 pattern 怎么写,回填时都会强制检查:只允许字母、数字和 _ . -,扩展名必须在 x-accept 里。 |
x-widget | 扩展 | model-upload | face-pick | body-pick | number | select | switch |
x-accept | 扩展 | model-upload 接受的扩展名,例如 [".step", ".stp"]。 |
x-unit | 扩展 | number 的显示单位,固定,不提供切换。无量纲写空串。 |
x-group x-order、根上的 x-groups | 扩展 | 分组与排序。数据保持平铺,分组只影响显示。 |
x-visible-when: {field, equals|in} | 扩展 | 按另一个 field 的取值决定显隐。隐藏时使用 default,不校验用户输入。 |
x-help-image | 扩展 | 提示图,引用目录内的文件。 |
参数值格式
| x-widget | 参数值 | render | 回填到 YS 的文本 |
|---|---|---|---|
| model-upload | {"path": "<共享存储路径>", "name": "my_wheel.STEP"} | {kind:"file"} | name。luban 把文件以这个名字放在回填后的 YS 同目录。 |
| face-pick / body-pick | [{"femap_id":61, …viewer 元数据}] | {kind:"ids"} | femap_id 去重、升序,用单个空格连接,即 12 40 |
| number | 10.5(按显示单位) | {kind:"number", scale} | printf("%.12g", v*scale),即 10500;key 为 function.expression.formula 且为负数时加括号 |
| select | "axial" | {kind:"map", cases} | cases["axial"] 原样输出 |
| switch | true | {kind:"map", cases} | cases["true"] |
选取参数值里的 femap_id 是用户上传模型中面或体的拓扑 id,也就是 DSL geometry.group … indices 使用的那套编号(STEP 导入 Femap 后的实体 id,格物前端从 HOOPS 节点的 femapId 属性读取)。luban 的选取控件必须给出同一套编号;怎么从用户模型得到这套编号,由 luban 自行实现。参数值里可以附带 viewer 元数据(name、clickPoint 等),回填时只读取 femap_id。
app.schema.json(元 schema,完整,由 base + 白名单生成)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gewu.yuansuan.cn/schemas/dsl-app/1.0/app.schema.json",
"title": "Gewu DSL app descriptor (app.json) 1.0",
"description": "Defines one published DSL app: a YS template with slots, the form whose values fill the slots, and how each value is rendered into the template. How the rendered YS is meshed, compiled and solved is up to the consumer. Rules JSON Schema cannot express are listed in the design doc ('semantic rules') and enforced by gewu at publish time.",
"type": "object",
"required": [
"formatVersion",
"app",
"runtime",
"files",
"form",
"bindings"
],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"formatVersion": {
"const": "1.0"
},
"app": {
"type": "object",
"required": [
"key",
"version",
"title"
],
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
"description": "Stable app identity across versions."
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"description": {
"type": "string",
"maxLength": 2000
},
"cover": {
"$ref": "#/$defs/fileRef"
}
}
},
"source": {
"type": "object",
"description": "Traceability back to gewu. Consumers must not depend on it.",
"required": [
"kind",
"ysPath"
],
"additionalProperties": false,
"properties": {
"kind": {
"enum": [
"job",
"upload"
],
"description": "job: job.ys kept from a finished gewu job. upload: a YS the engineer uploaded."
},
"caseId": {
"type": "string"
},
"jobId": {
"type": "string"
},
"ysPath": {
"type": "string"
},
"builtAt": {
"type": "string",
"format": "date-time"
}
}
},
"runtime": {
"type": "object",
"description": "What the template was written for. It does not prescribe how the consumer runs it.",
"required": [
"target",
"dslVersion",
"units"
],
"additionalProperties": false,
"properties": {
"target": {
"enum": [
"openradioss",
"code_aster",
"abaqus",
"ansys"
],
"description": "eucliddsl backend the source YS targets."
},
"dslVersion": {
"type": "string",
"minLength": 1,
"description": "eucliddsl build (git base) the source YS was checked with."
},
"units": {
"type": "object",
"description": "Unit system the template's numbers are written in. DSL numbers carry no units, so every number slot's scale and every material value refers to this system.",
"required": [
"length",
"mass",
"time",
"force",
"stress"
],
"additionalProperties": false,
"properties": {
"length": {
"type": "string",
"minLength": 1
},
"mass": {
"type": "string",
"minLength": 1
},
"time": {
"type": "string",
"minLength": 1
},
"force": {
"type": "string",
"minLength": 1
},
"stress": {
"type": "string",
"minLength": 1
}
}
}
}
},
"files": {
"type": "object",
"required": [
"template"
],
"additionalProperties": false,
"properties": {
"template": {
"$ref": "#/$defs/fileRef"
}
}
},
"form": {
"$ref": "#/$defs/form"
},
"bindings": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/binding"
}
}
},
"$defs": {
"key": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,63}$"
},
"fileRef": {
"type": "object",
"required": [
"path",
"sha256"
],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_./-]*$",
"not": {
"pattern": "\\.\\."
}
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
},
"description": "path is relative to the directory that holds app.json."
},
"form": {
"type": "object",
"description": "A JSON Schema (2020-12) of the values object. Any 2020-12 validator validates values with it; x-* keywords are UI hints that validators ignore.",
"required": [
"type",
"properties",
"additionalProperties"
],
"additionalProperties": false,
"properties": {
"$schema": {
"const": "https://json-schema.org/draft/2020-12/schema"
},
"type": {
"const": "object"
},
"additionalProperties": {
"const": false,
"description": "Unknown value keys are rejected by any validator."
},
"title": {
"type": "string"
},
"required": {
"type": "array",
"items": {
"$ref": "#/$defs/key"
},
"uniqueItems": true
},
"x-groups": {
"type": "array",
"items": {
"type": "object",
"required": [
"key",
"title"
],
"additionalProperties": false,
"properties": {
"key": {
"$ref": "#/$defs/key"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
},
"properties": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/key"
},
"additionalProperties": {
"$ref": "#/$defs/field"
}
}
}
},
"fieldCommon": {
"type": "object",
"required": [
"title",
"x-widget"
],
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"description": {
"type": "string",
"maxLength": 500
},
"x-group": {
"$ref": "#/$defs/key"
},
"x-order": {
"type": "integer"
},
"x-help-image": {
"$ref": "#/$defs/fileRef"
},
"x-visible-when": {
"$ref": "#/$defs/visibleWhen"
}
}
},
"visibleWhen": {
"type": "object",
"required": [
"field"
],
"additionalProperties": false,
"properties": {
"field": {
"$ref": "#/$defs/key"
},
"equals": {
"type": [
"string",
"number",
"boolean"
]
},
"in": {
"type": "array",
"minItems": 1,
"items": {
"type": [
"string",
"number",
"boolean"
]
}
}
},
"oneOf": [
{
"required": [
"equals"
]
},
{
"required": [
"in"
]
}
]
},
"field": {
"oneOf": [
{
"$ref": "#/$defs/numberField"
},
{
"$ref": "#/$defs/selectField"
},
{
"$ref": "#/$defs/switchField"
},
{
"$ref": "#/$defs/pickField"
},
{
"$ref": "#/$defs/modelField"
}
]
},
"numberField": {
"allOf": [
{
"$ref": "#/$defs/fieldCommon"
}
],
"unevaluatedProperties": false,
"required": [
"type",
"x-unit"
],
"properties": {
"type": {
"enum": [
"number",
"integer"
]
},
"x-widget": {
"const": "number"
},
"x-unit": {
"type": "string",
"description": "Display unit shown next to the input. Empty string for dimensionless values."
},
"minimum": {
"type": "number"
},
"maximum": {
"type": "number"
},
"exclusiveMinimum": {
"type": "number"
},
"exclusiveMaximum": {
"type": "number"
},
"multipleOf": {
"type": "number",
"exclusiveMinimum": 0
},
"default": {
"type": "number"
}
}
},
"selectField": {
"allOf": [
{
"$ref": "#/$defs/fieldCommon"
}
],
"unevaluatedProperties": false,
"required": [
"type",
"oneOf"
],
"properties": {
"type": {
"const": "string"
},
"x-widget": {
"const": "select"
},
"oneOf": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"const",
"title"
],
"additionalProperties": false,
"properties": {
"const": {
"type": "string",
"minLength": 1
},
"title": {
"type": "string",
"minLength": 1
}
}
}
},
"default": {
"type": "string"
}
}
},
"switchField": {
"allOf": [
{
"$ref": "#/$defs/fieldCommon"
}
],
"unevaluatedProperties": false,
"required": [
"type"
],
"properties": {
"type": {
"const": "boolean"
},
"x-widget": {
"const": "switch"
},
"default": {
"type": "boolean"
}
}
},
"pickField": {
"allOf": [
{
"$ref": "#/$defs/fieldCommon"
}
],
"unevaluatedProperties": false,
"required": [
"type",
"items"
],
"properties": {
"type": {
"const": "array"
},
"x-widget": {
"enum": [
"face-pick",
"body-pick"
]
},
"minItems": {
"type": "integer",
"minimum": 0
},
"maxItems": {
"type": "integer",
"minimum": 1
},
"items": {
"$ref": "#/$defs/entityRefSchema"
}
},
"description": "Face or body pick on the uploaded model. No default: ids only mean something for one model."
},
"entityRefSchema": {
"description": "Item schema of a pick value. Pickers may store extra viewer metadata next to femap_id; only femap_id is rendered.",
"type": "object",
"required": [
"type",
"required",
"properties"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "object"
},
"required": {
"const": [
"femap_id"
]
},
"properties": {
"type": "object",
"required": [
"femap_id"
],
"additionalProperties": false,
"properties": {
"femap_id": {
"type": "object",
"required": [
"type",
"minimum"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "integer"
},
"minimum": {
"const": 1
}
}
}
}
}
}
},
"binding": {
"type": "object",
"required": [
"slot",
"field",
"key",
"render"
],
"additionalProperties": false,
"properties": {
"slot": {
"$ref": "#/$defs/key",
"description": "Placeholder {{.slot}} in the template."
},
"field": {
"$ref": "#/$defs/key",
"description": "Value key in form.properties."
},
"key": {
"$ref": "#/$defs/configurableKey"
},
"render": {
"oneOf": [
{
"type": "object",
"required": [
"kind"
],
"additionalProperties": false,
"properties": {
"kind": {
"const": "number"
},
"scale": {
"type": "number",
"not": {
"const": 0
},
"default": 1,
"description": "DSL number = input * scale; converts the display unit to runtime.units."
}
}
},
{
"type": "object",
"required": [
"kind"
],
"additionalProperties": false,
"properties": {
"kind": {
"const": "ids"
}
}
},
{
"type": "object",
"required": [
"kind",
"cases"
],
"additionalProperties": false,
"properties": {
"kind": {
"const": "map"
},
"cases": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "string"
},
"description": "Value (select const, or \"true\"/\"false\" for a switch) to literal YS text."
}
}
},
{
"type": "object",
"required": [
"kind"
],
"additionalProperties": false,
"properties": {
"kind": {
"const": "file"
}
},
"description": "Renders the value's file name; the consumer places the file under that name next to the rendered YS."
}
]
}
},
"allOf": [
{
"if": {
"properties": {
"key": {
"enum": [
"geometry.source"
]
}
},
"required": [
"key"
]
},
"then": {
"properties": {
"render": {
"properties": {
"kind": {
"const": "file"
}
}
}
}
}
},
{
"if": {
"properties": {
"key": {
"enum": [
"geometry.group.indices"
]
}
},
"required": [
"key"
]
},
"then": {
"properties": {
"render": {
"properties": {
"kind": {
"const": "ids"
}
}
}
}
}
},
{
"if": {
"properties": {
"key": {
"enum": [
"condition.mechanical.dirichlet"
]
}
},
"required": [
"key"
]
},
"then": {
"properties": {
"render": {
"properties": {
"kind": {
"const": "map"
}
}
}
}
}
},
{
"if": {
"properties": {
"key": {
"enum": [
"condition.mechanical.dirichlet.value",
"condition.mechanical.neumann.force.value",
"condition.mechanical.neumann.pressure",
"function.expression.formula",
"initial.kinematic.velocity.value",
"mesh.create_mesh.point.coordinates",
"mesh.generation.max_size",
"mesh.generation.min_size",
"step.control.time.duration"
]
}
},
"required": [
"key"
]
},
"then": {
"properties": {
"render": {
"properties": {
"kind": {
"const": "number"
}
}
}
}
}
}
]
},
"modelField": {
"allOf": [
{
"$ref": "#/$defs/fieldCommon"
}
],
"unevaluatedProperties": false,
"required": [
"type",
"required",
"properties",
"x-accept"
],
"properties": {
"type": {
"const": "object"
},
"x-widget": {
"const": "model-upload"
},
"x-accept": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^\\.[a-z0-9_]+$"
},
"description": "Accepted file extensions, lower case."
},
"required": {
"const": [
"path",
"name"
]
},
"properties": {
"type": "object",
"required": [
"path",
"name"
],
"additionalProperties": false,
"properties": {
"path": {
"const": {
"type": "string",
"minLength": 1
}
},
"name": {
"type": "object",
"required": [
"type",
"pattern"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "string"
},
"pattern": {
"type": "string"
}
}
}
}
}
},
"description": "The user's CAD model. No default: the app is not tied to one model. Whatever the name pattern, the consumer must reject a name outside [A-Za-z0-9_.-] or with an extension not in x-accept, because the name is written into source \"...\"."
},
"configurableKey": {
"description": "Allowlisted DSL key path the slot fills: enclosing block keywords plus the line keyword. Generated from the catalog.",
"enum": [
"condition.mechanical.dirichlet",
"condition.mechanical.dirichlet.value",
"condition.mechanical.neumann.force.value",
"condition.mechanical.neumann.pressure",
"function.expression.formula",
"geometry.group.indices",
"geometry.source",
"initial.kinematic.velocity.value",
"mesh.create_mesh.point.coordinates",
"mesh.generation.max_size",
"mesh.generation.min_size",
"step.control.time.duration"
]
}
},
"$comment": "Generated by `dslapp.py schema` from app.schema.base.json and CATALOG. Edit the base or the catalog, not this file."
}9luban 解析
导入
- 按格物给出的路径读取
app.json,用它声明的元 schema 版本校验,并核对files.template.sha256。$schema(等于元 schema 的$id,如https://gewu.yuansuan.cn/schemas/dsl-app/1.0/app.schema.json)只是标识符,不需要能访问:luban 取其中的版本段1.0,从共享存储dsl_app/schemas/1.0/app.schema.json读取。 runtime.target是源 YS 面向的 eucliddsl 后端,runtime.dslVersion是源 YS 自检用的 eucliddsl 版本,runtime.units是模板的单位制。它们描述模板,不规定 luban 怎么运行。
前端渲染
form 是标准 JSON Schema,推荐用 react-jsonschema-form(rjsf,antd 主题)直接渲染,只需把 x-widget 映射到控件:
| 部分 | 做法 |
|---|---|
| 校验 | @rjsf/validator-ajv8 配 Ajv2020;ajv 的 strict 模式会对 x-* 报错,关掉 strict 或把它们注册为词表。 |
| number | antd InputNumber,单位 x-unit 显示在输入框后。 |
| select / switch | rjsf 默认把 oneOf: [{const, title}] 渲染成下拉,boolean 渲染成开关。 |
| face-pick / body-pick | 自定义 field,内部是 HOOPS 选取,值为 [{femap_id, …}]。 |
| model-upload | 自定义 field,上传到共享存储,值为 {path, name}。 |
| 分组与排序 | 由 x-groups/x-order 生成 uiSchema 的 ui:order,用 ObjectFieldTemplate 按组渲染。 |
x-visible-when | rjsf 没有内置。每次 formData 变化时重新计算 uiSchema,隐藏的字段不渲染,值重置为 default,与后端的处理一致。 |
import Form from "@rjsf/antd";
import { customizeValidator } from "@rjsf/validator-ajv8";
import Ajv2020 from "ajv/dist/2020";
const validator = customizeValidator({ AjvClass: Ajv2020, ajvOptionsOverrides: { strict: false } });
const fields = { "face-pick": FacePick, "body-pick": BodyPick, "model-upload": ModelUpload, number: UnitNumber };
function hidden(f: any, data: any) {
const vw = f["x-visible-when"];
return !!vw && !("equals" in vw ? data[vw.field] === vw.equals : vw.in.includes(data[vw.field]));
}
function uiSchemaOf(form: any, data: any) {
const props = Object.entries<any>(form.properties)
.sort(([, a], [, b]) => (a["x-order"] ?? 0) - (b["x-order"] ?? 0));
const ui: any = { "ui:order": props.map(([k]) => k) };
for (const [k, f] of props) {
ui[k] = hidden(f, data) ? { "ui:widget": "hidden" } : { "ui:field": fields[f["x-widget"]] ? f["x-widget"] : undefined };
}
return ui;
}
// Hidden fields fall back to their default, as the backend does, so a stale input cannot block submit.
function effective(form: any, data: any) {
const out = { ...data };
for (const [k, f] of Object.entries<any>(form.properties)) if (hidden(f, data)) out[k] = f.default;
return out;
}
<Form schema={app.form} uiSchema={uiSchemaOf(app.form, data)} fields={fields}
validator={validator} formData={data} onChange={e => setData(effective(app.form, e.formData))}
onSubmit={e => submit(e.formData)} />动态渲染样例
下面直接用 rjsf(@rjsf/core 5.24.13 默认主题,Ajv2020 校验,中文错误信息)渲染第 10 节两个示例的 app.json。控件映射、分组和显隐处理与上面的 TSX 相同。右侧用移植自 dslapp.py 的回填逻辑实时生成 YS,与源 YS 不同的行会高亮。选面、选体在 luban 中由 3D 选取给出 femap_id,这里改为手动输入 femap_id;模型只取文件名,不上传。可以试试:把弯曲载荷改成 60、打开自定义网格尺寸、把文件名改成 ../a.STEP、把压力下限系数改成 −0.05。
正在从 CDN 加载 rjsf…
回填(以参考实现 dslapp.py 的 render 为准)
拒绝未知键
plain = values 中不带 x-visible-when 的字段;缺失且有 default 的用 default 补
effective = plain + 每个条件字段(可见且有输入时取输入值,否则取 default)
模型文件名只能用 [A-Za-z0-9_.-],扩展名必须在 x-accept 里
用 form 校验 effective
texts = { b.slot: render(b, effective[b.field]) for b in bindings } // 公式里的负数加括号
job.ys = template.ys 中每个 {{.slot}} 替换为 texts[slot] // 等价于 text/template + missingkey=error
把模型文件以 values[model].name 放到 job.ys 同目录
之后由 luban 自行运行10示例
两个示例都由 generate 从“源 YS + selection”生成,没有手写的 app.json。
车轮弯曲静力(1975_ray)
源 YS 由现有 builder BuildWheelBendingYS 根据 newWheelBendingCase 测试工况生成。共 9 个参数、8 个占位符:模型、轮体、固定面、固定方式、加载面、加载点 Z 坐标、弯曲载荷、最大网格尺寸,外加一个只用来控制显隐的开关。材料保持源 YS 原样。
selection.json(构建页保存的数据)
{
"app": {
"key": "wheel-bending-1975-ray",
"version": "1.0.0",
"title": "车轮弯曲静力分析",
"description": "车轮弯曲静力分析:上传车轮模型,选择轮体、固定面与加载面,设置固定方式、加载点与弯曲载荷。"
},
"source": {
"kind": "job",
"caseId": "case-1975-ray",
"jobId": "1890000000000000000",
"ysPath": "structure/demo/case-1975-ray/_job_dsl/1890000000000000000.ys",
"builtAt": "2026-10-23T10:00:00+08:00"
},
"runtime": {
"target": "code_aster",
"dslVersion": "505eaedc3937c5c211505ff5fe7ccfbadd2d72ec",
"units": { "length": "mm", "mass": "t", "time": "s", "force": "N", "stress": "MPa" }
},
"groups": [
{ "key": "model", "title": "模型" },
{ "key": "load", "title": "约束与载荷" },
{ "key": "mesh", "title": "网格" }
],
"params": [
{ "field": "model", "title": "车轮模型", "description": "上传车轮模型(STEP)。", "from": ["geometry.source@"],
"x-accept": [".step", ".stp"], "name-pattern": "^[A-Za-z0-9_.-]{1,96}\\.(step|stp|STEP|STP)$",
"x-group": "model", "x-order": 1 },
{ "field": "solid_bodies", "title": "轮体", "description": "参与计算的实体。", "from": ["geometry.group.indices@SOLID"],
"x-group": "model", "x-order": 2 },
{ "field": "fixed_faces", "title": "固定面", "description": "选择轮辋安装面。", "from": ["geometry.group.indices@FIXED"],
"x-group": "load", "x-order": 1 },
{ "field": "fixed_dof", "title": "固定方式", "from": ["condition.mechanical.dirichlet@FixedSupport"],
"options": [
{ "const": "all", "title": "全约束(UX UY UZ)", "text": null },
{ "const": "axial", "title": "仅约束轴向(UZ)", "text": "dof UZ\n value 0" }
],
"required": true, "x-group": "load", "x-order": 2 },
{ "field": "force_faces", "title": "加载面", "description": "螺栓孔面,与外部加载点刚性耦合。",
"from": ["geometry.group.indices@FORCE"], "x-group": "load", "x-order": 3 },
{ "field": "load_point_z", "title": "加载点 Z 坐标", "description": "外部加载点的位置随模型变化。",
"from": ["mesh.create_mesh.point.coordinates@MASTER[2]"], "x-unit": "m", "scale": 1000,
"x-group": "load", "x-order": 4 },
{ "field": "force_x", "title": "弯曲载荷 FX", "description": "作用在外部加载点上的力。",
"from": ["condition.mechanical.neumann.force.value@RemoteForce[0]"], "x-unit": "kN", "scale": 1000,
"minimum": 0.1, "maximum": 50, "required": true, "x-group": "load", "x-order": 5 },
{ "field": "custom_mesh", "title": "自定义网格尺寸", "widget": "switch", "default": false,
"x-group": "mesh", "x-order": 1 },
{ "field": "mesh_max_size", "title": "最大网格尺寸", "from": ["mesh.generation.max_size@[0]"], "x-unit": "mm",
"minimum": 2, "maximum": 20, "x-visible-when": { "field": "custom_mesh", "equals": true },
"x-group": "mesh", "x-order": 2 }
]
}app.json(生成)
{
"$schema": "https://gewu.yuansuan.cn/schemas/dsl-app/1.0/app.schema.json",
"formatVersion": "1.0",
"app": {
"key": "wheel-bending-1975-ray",
"version": "1.0.0",
"title": "车轮弯曲静力分析",
"description": "车轮弯曲静力分析:上传车轮模型,选择轮体、固定面与加载面,设置固定方式、加载点与弯曲载荷。"
},
"source": {
"kind": "job",
"caseId": "case-1975-ray",
"jobId": "1890000000000000000",
"ysPath": "structure/demo/case-1975-ray/_job_dsl/1890000000000000000.ys",
"builtAt": "2026-10-23T10:00:00+08:00"
},
"runtime": {
"target": "code_aster",
"dslVersion": "505eaedc3937c5c211505ff5fe7ccfbadd2d72ec",
"units": {
"length": "mm",
"mass": "t",
"time": "s",
"force": "N",
"stress": "MPa"
}
},
"files": {
"template": {
"path": "template.ys",
"sha256": "ed44d08bcf4437558c811258679ce73ffc6d79bf836d445a2980c85476ab7ba7"
}
},
"form": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"title": "车轮弯曲静力分析",
"x-groups": [
{
"key": "model",
"title": "模型"
},
{
"key": "load",
"title": "约束与载荷"
},
{
"key": "mesh",
"title": "网格"
}
],
"required": [
"model",
"solid_bodies",
"fixed_faces",
"fixed_dof",
"force_faces",
"force_x"
],
"properties": {
"model": {
"title": "车轮模型",
"description": "上传车轮模型(STEP)。",
"type": "object",
"x-widget": "model-upload",
"x-accept": [
".step",
".stp"
],
"required": [
"path",
"name"
],
"properties": {
"path": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]{1,96}\\.(step|stp|STEP|STP)$"
}
},
"x-group": "model",
"x-order": 1
},
"solid_bodies": {
"title": "轮体",
"description": "参与计算的实体。",
"type": "array",
"x-widget": "body-pick",
"minItems": 1,
"items": {
"type": "object",
"required": [
"femap_id"
],
"properties": {
"femap_id": {
"type": "integer",
"minimum": 1
}
}
},
"x-group": "model",
"x-order": 2
},
"fixed_faces": {
"title": "固定面",
"description": "选择轮辋安装面。",
"type": "array",
"x-widget": "face-pick",
"minItems": 1,
"items": {
"type": "object",
"required": [
"femap_id"
],
"properties": {
"femap_id": {
"type": "integer",
"minimum": 1
}
}
},
"x-group": "load",
"x-order": 1
},
"fixed_dof": {
"title": "固定方式",
"type": "string",
"x-widget": "select",
"oneOf": [
{
"const": "all",
"title": "全约束(UX UY UZ)"
},
{
"const": "axial",
"title": "仅约束轴向(UZ)"
}
],
"default": "all",
"x-group": "load",
"x-order": 2
},
"force_faces": {
"title": "加载面",
"description": "螺栓孔面,与外部加载点刚性耦合。",
"type": "array",
"x-widget": "face-pick",
"minItems": 1,
"items": {
"type": "object",
"required": [
"femap_id"
],
"properties": {
"femap_id": {
"type": "integer",
"minimum": 1
}
}
},
"x-group": "load",
"x-order": 3
},
"load_point_z": {
"title": "加载点 Z 坐标",
"description": "外部加载点的位置随模型变化。",
"type": "number",
"x-widget": "number",
"x-unit": "m",
"default": -1000.0,
"x-group": "load",
"x-order": 4
},
"force_x": {
"title": "弯曲载荷 FX",
"description": "作用在外部加载点上的力。",
"type": "number",
"x-widget": "number",
"x-unit": "kN",
"minimum": 0.1,
"maximum": 50,
"default": 4.135,
"x-group": "load",
"x-order": 5
},
"custom_mesh": {
"title": "自定义网格尺寸",
"type": "boolean",
"x-widget": "switch",
"default": false,
"x-group": "mesh",
"x-order": 1
},
"mesh_max_size": {
"title": "最大网格尺寸",
"type": "number",
"x-widget": "number",
"x-unit": "mm",
"minimum": 2,
"maximum": 20,
"default": 6.0,
"x-group": "mesh",
"x-order": 2,
"x-visible-when": {
"field": "custom_mesh",
"equals": true
}
}
}
},
"bindings": [
{
"slot": "model",
"field": "model",
"key": "geometry.source",
"render": {
"kind": "file"
}
},
{
"slot": "solid_bodies",
"field": "solid_bodies",
"key": "geometry.group.indices",
"render": {
"kind": "ids"
}
},
{
"slot": "fixed_faces",
"field": "fixed_faces",
"key": "geometry.group.indices",
"render": {
"kind": "ids"
}
},
{
"slot": "fixed_dof",
"field": "fixed_dof",
"key": "condition.mechanical.dirichlet",
"render": {
"kind": "map",
"cases": {
"all": "dof UX UY UZ\n value 0 0 0",
"axial": "dof UZ\n value 0"
}
}
},
{
"slot": "force_faces",
"field": "force_faces",
"key": "geometry.group.indices",
"render": {
"kind": "ids"
}
},
{
"slot": "load_point_z",
"field": "load_point_z",
"key": "mesh.create_mesh.point.coordinates",
"render": {
"kind": "number",
"scale": 1000
}
},
{
"slot": "force_x",
"field": "force_x",
"key": "condition.mechanical.neumann.force.value",
"render": {
"kind": "number",
"scale": 1000
}
},
{
"slot": "mesh_max_size",
"field": "mesh_max_size",
"key": "mesh.generation.max_size",
"render": {
"kind": "number"
}
}
]
}template.ys(生成)
model wheel_bending {
version 1.0
geometry {
source "{{.model}}"
group SOLID {
type volume
mesh_group element
indices [{{.solid_bodies}}]
}
group FIXED {
type surface
mesh_group node
indices [{{.fixed_faces}}]
}
group FORCE {
type surface
mesh_group node
indices [{{.force_faces}}]
}
}
mesh {
source code_aster "mesh_test.med" format med
generation {
engine femap
order 1
min_size 2.0
max_size {{.mesh_max_size}}
}
create_mesh {
point MASTER {
coordinates 0 0 {{.load_point_z}}
}
}
}
material "铝" {
property {
density 2.68e-09
}
behavior {
elastic isotropic {
E 72400
nu 0.33
}
}
}
assign "SolidBody1_铝" {
region SOLID
material_binding {
material "铝"
}
hypothesis_package {
continuum {
solid
}
}
}
assign "RemotePointDiscrete" {
region MASTER
hypothesis_package {
discrete {
translation
}
}
}
condition "FixedSupport" {
mechanical {
dirichlet {
region FIXED
{{.fixed_dof}}
}
}
}
condition "RemoteForceCoupling" {
interaction {
connection {
constraint {
point_link {
point MASTER
region FORCE
}
}
}
}
}
condition "RemoteForce" {
mechanical {
neumann {
region MASTER
force {
component FX FY FZ
value {{.force_x}} 0 0
}
}
}
}
step "StaticStructural" {
procedure static linear
use {
assign "SolidBody1_铝"
assign "RemotePointDiscrete"
}
apply "FixedSupport"
apply "RemoteForceCoupling"
apply "RemoteForce"
}
output {
field {
region "SOLID"
location node
variable displacement
variable stress
variable strain
variable mises
}
field {
region "SOLID"
location node
variable reaction_nodal
}
}
}回填结果:用户模型 my_wheel.STEP、轮体 1 个、固定面 2 个、加载面 2 个、仅约束轴向、加载点 Z −800 m、网格 8 mm、FX 10.5 kN
--- source.ys
+++ job.ys
@@ -2,24 +2,24 @@
version 1.0
geometry {
- source "1975_ray.STEP"
+ source "my_wheel.STEP"
group SOLID {
type volume
mesh_group element
- indices [1]
+ indices [3]
}
group FIXED {
type surface
mesh_group node
- indices [61 62 63 118 119 120]
+ indices [12 40]
}
group FORCE {
type surface
mesh_group node
- indices [12 17 22 27 32 878 884 890 896 902]
+ indices [7 9]
}
}
@@ -30,12 +30,12 @@
engine femap
order 1
min_size 2.0
- max_size 6.0
+ max_size 8
}
create_mesh {
point MASTER {
- coordinates 0 0 -1000000
+ coordinates 0 0 -800000
}
}
}
@@ -77,8 +77,8 @@
mechanical {
dirichlet {
region FIXED
- dof UX UY UZ
- value 0 0 0
+ dof UZ
+ value 0
}
}
}
@@ -102,7 +102,7 @@
region MASTER
force {
component FX FY FZ
- value 4135 0 0
+ value 10500 0 0
}
}
}乘用车车轮径向疲劳滚动试验(压力公式)
源 YS 由 BuildWheelRadialEnhancedYS 生成,公式换成 1.9 * max(0.0, cos(min(pi, abs(atan2(X, Y))* pi / (2.0 * 0.61))))。共 9 个参数:模型、4 个几何组、公式里的 1.9、0.0、0.61,以及充气压力。
selection.json(构建页保存的数据)
{
"app": {
"key": "wheel-radial-fatigue-pressure",
"version": "1.0.0",
"title": "乘用车车轮径向疲劳滚动试验",
"description": "径向载荷按压力公式分布:上传车轮模型,选择受压面,设置峰值压力、压力下限系数和分布角。"
},
"source": {
"kind": "job",
"caseId": "case-radial-demo",
"jobId": "1890000000000000001",
"ysPath": "structure/demo/case-radial-demo/_job_dsl/1890000000000000001.ys",
"builtAt": "2026-10-23T10:00:00+08:00"
},
"runtime": {
"target": "code_aster",
"dslVersion": "505eaedc3937c5c211505ff5fe7ccfbadd2d72ec",
"units": { "length": "mm", "mass": "t", "time": "s", "force": "N", "stress": "MPa" }
},
"groups": [
{ "key": "model", "title": "模型" },
{ "key": "load", "title": "约束与载荷" }
],
"params": [
{ "field": "model", "title": "车轮模型", "from": ["geometry.source@"],
"x-accept": [".step", ".stp"], "name-pattern": "^[A-Za-z0-9_.-]{1,96}\\.(step|stp|STEP|STP)$",
"x-group": "model", "x-order": 1 },
{ "field": "solid_bodies", "title": "轮体", "from": ["geometry.group.indices@SOLID"], "x-group": "model", "x-order": 2 },
{ "field": "fixed_faces", "title": "固定面", "from": ["geometry.group.indices@FIXED"], "x-group": "load", "x-order": 1 },
{ "field": "radial_faces", "title": "径向载荷面", "from": ["geometry.group.indices@RADIAL_PRESSURE"],
"x-group": "load", "x-order": 2 },
{ "field": "inflation_faces", "title": "充气压力面", "from": ["geometry.group.indices@INFLATION_PRESSURE"],
"x-group": "load", "x-order": 3 },
{ "field": "peak_pressure", "title": "峰值压力", "description": "公式 1.9 * max(…) 中的 1.9。",
"from": ["function.expression.formula@RadialPressure[0]"], "x-unit": "MPa", "minimum": 0.1, "maximum": 10,
"required": true, "x-group": "load", "x-order": 4 },
{ "field": "pressure_floor", "title": "压力下限系数", "description": "公式 max(0.0, cos(…)) 中的 0.0。",
"from": ["function.expression.formula@RadialPressure[1]"], "x-unit": "", "minimum": -1, "maximum": 1,
"required": true, "x-group": "load", "x-order": 5 },
{ "field": "half_angle", "title": "载荷分布角参数", "description": "公式 (2.0 * 0.61) 中的 0.61。",
"from": ["function.expression.formula@RadialPressure[3]"], "x-unit": "rad", "minimum": 0.1, "maximum": 1.57,
"required": true, "x-group": "load", "x-order": 6 },
{ "field": "inflation_pressure", "title": "充气压力",
"from": ["condition.mechanical.neumann.pressure@InflationPressure[0]"], "x-unit": "MPa", "minimum": 0, "maximum": 10,
"required": true, "x-group": "load", "x-order": 7 }
]
}回填结果:峰值压力 1.9、压力下限系数 −0.05、分布角参数 0.61、充气压力 0.45 MPa
--- source.ys
+++ job.ys
@@ -2,7 +2,7 @@
version 1.0
geometry {
- source "1975_ray.STEP"
+ source "w.STEP"
group SOLID {
type volume
@@ -13,19 +13,19 @@
group FIXED {
type surface
mesh_group node
- indices [11 12 15 16 17 20 21 22 25 26 27 30 31 32 35 43 78 145 874 877 878 879 880 883 884 885 886 889 890 891 892 895 896 897 898 901 902 903]
+ indices [11]
}
group RADIAL_PRESSURE {
type surface
mesh_group element
- indices [61 62 63 118 119 120]
+ indices [61]
}
group INFLATION_PRESSURE {
type surface
mesh_group element
- indices [61 62 73 90 98 99 100 119 120 164 168 169 196 197 198 199 200 201 202 290 314 318 319 346 347 348 349 350 351 352 433 440 441 471 491 492 493 494 495 496 497 578 585 586 620 640 641 642 643 644 645 646 733 760 780 781 782 783 784 785 786]
+ indices [62]
}
}
@@ -44,7 +44,7 @@
expression {
arguments X Y
formula {
- 1.9 * max(0.0, cos(min(pi, abs(atan2(X, Y))* pi / (2.0 * 0.61))))
+ 1.9 * max((-0.05), cos(min(pi, abs(atan2(X, Y))* pi / (2.0 * 0.61))))
}
}
}
@@ -96,7 +96,7 @@
mechanical {
neumann {
region INFLATION_PRESSURE
- pressure 3
+ pressure 0.45
}
}
}dslapp.py(参考实现:scan、generate、自检、回填、元 schema 生成)
"""Reference implementation of the DSL engineering APP definition (gewu side + consumer render).
One allowlist (CATALOG) drives every step:
scan source YS -> blank candidates (only allowlisted keys)
generate source YS + selection (what the build page saves) -> app.json + template.ys
check publish checks: meta-schema, R1-R12, round trip
render values -> rendered YS (what luban does per job)
Usage:
python3 dslapp.py scan <source.ys>
python3 dslapp.py generate <source.ys> <selection.json> <out_dir>
python3 dslapp.py check <app_dir> <app.schema.json> [source.ys]
python3 dslapp.py render <app_dir> <values.json|-> <out.ys>
"""
import hashlib
import json
import math
import re
import sys
from pathlib import Path
import jsonschema
PLACEHOLDER = re.compile(r"\{\{\.([a-z][a-z0-9_]{0,63})\}\}")
UNSIGNED = r"(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
NUMBER = rf"[-+]?{UNSIGNED}"
NUMBER_TOKEN = re.compile(rf"^{NUMBER}$")
SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]{1,128}")
PICKS = ("face-pick", "body-pick")
NO_DEFAULT = PICKS + ("model-upload",)
FORMULA_KEY = "function.expression.formula"
# Allowlist of configurable DSL keys: render kind and allowed widgets per key.
# A key path is the chain of enclosing block keywords plus the line keyword, e.g. geometry.group.indices.
CATALOG = {
"geometry.source": {"render": "file", "widgets": ["model-upload"]},
"geometry.group.indices": {"render": "ids", "widgets": list(PICKS)},
"mesh.generation.min_size": {"render": "number", "widgets": ["number"]},
"mesh.generation.max_size": {"render": "number", "widgets": ["number"]},
"mesh.create_mesh.point.coordinates": {"render": "number", "widgets": ["number"]},
"condition.mechanical.dirichlet": {"render": "map", "widgets": ["select", "switch"]},
"condition.mechanical.dirichlet.value": {"render": "number", "widgets": ["number"]},
"condition.mechanical.neumann.force.value": {"render": "number", "widgets": ["number"]},
"condition.mechanical.neumann.pressure": {"render": "number", "widgets": ["number"]},
"initial.kinematic.velocity.value": {"render": "number", "widgets": ["number"]},
"step.control.time.duration": {"render": "number", "widgets": ["number"]},
FORMULA_KEY: {"render": "number", "widgets": ["number"]},
}
WIDGET_RENDER = {"number": "number", "face-pick": "ids", "body-pick": "ids", "select": "map", "switch": "map",
"model-upload": "file"}
class DslAppError(Exception):
pass
# ---------------------------------------------------------------- key paths
def walk(text):
"""Yield (offset, line, block_keywords, block_names) for every line; the stack excludes the line's own block."""
stack, names, pos = [], [], 0
for line in text.splitlines(keepends=True):
s = line.strip()
if s.startswith("}") and stack:
stack.pop()
names.pop()
yield pos, line, list(stack), list(names)
if s.endswith("{") and not s.startswith("}"):
head = s[:-1].split()
keyword = head[0] if head and head[0] != "model" else None # the top-level model block is not in key paths
stack.append(keyword)
names.append(head[1].strip('"') if keyword and len(head) > 1 else "")
pos += len(line)
def key_of(path, word):
return ".".join([p for p in path if p] + ([word] if word else []))
def line_word(text):
m = re.match(r"\s*([a-z_]+)\b", text)
return m.group(1) if m else None
def owner_of(names):
return next((n for n in reversed(names) if n), "")
# ---------------------------------------------------------------- scan
def scan(source):
"""List blank candidates allowed by CATALOG, each with a stable locator and its original text."""
group_types = {}
for _, line, path, names in walk(source):
if path[-1:] == ["group"] and line.strip().startswith("type "):
group_types[names[-1]] = line.split()[1]
out = []
for off, line, path, names in walk(source):
s = line.strip()
owner = owner_of(names)
if key_of(path, None) == FORMULA_KEY:
for j, m in enumerate(re.finditer(rf"(?<![\w.]){UNSIGNED}(?![\w.])", line)):
out.append({"locator": f"{FORMULA_KEY}@{owner}[{j}]", "key": FORMULA_KEY,
"start": off + m.start(), "end": off + m.end(), "original": m.group(0)})
continue
word = line_word(line)
key = key_of(path, word)
entry = CATALOG.get(key)
if not entry:
continue
if s.endswith("{"):
if entry["render"] != "map":
continue
body_start = off + len(line)
depth, i = 1, body_start
while depth:
depth += {"{": 1, "}": -1}.get(source[i], 0)
i += 1
body = source[body_start:i - 1]
region = re.match(r"\s*region [^\n]*\n", body) # the region stays fixed; options replace what follows
skip = region.end() if region else 0
text = body[skip:].strip()
start = body_start + skip + (len(body[skip:]) - len(body[skip:].lstrip()))
out.append({"locator": f"{key}@{owner_of(names + [s.split()[1].strip(chr(34)) if len(s.split()) > 2 else ''])}",
"key": key, "start": start, "end": start + len(text), "original": text})
continue
rest_at = off + line.index(word) + len(word)
rest = line[line.index(word) + len(word):].rstrip("\n")
if entry["render"] == "file":
m = re.search(r'"([^"]*)"', rest)
out.append({"locator": f"{key}@", "key": key, "start": rest_at + m.start(1), "end": rest_at + m.end(1),
"original": m.group(1)})
elif entry["render"] == "ids":
m = re.search(r"\[([^\]]*)\]", rest)
out.append({"locator": f"{key}@{owner}", "key": key, "start": rest_at + m.start(1), "end": rest_at + m.end(1),
"original": m.group(1),
"widget": "body-pick" if group_types.get(owner) == "volume" else "face-pick"})
elif entry["render"] == "number":
for j, m in enumerate(re.finditer(rf"(?<![\w.]){NUMBER}(?![\w.])", rest)):
out.append({"locator": f"{key}@{owner}[{j}]", "key": key, "start": rest_at + m.start(),
"end": rest_at + m.end(), "original": m.group(0)})
return out
# ---------------------------------------------------------------- generate
def field_for(p, picked, kind):
"""Field schema from the catalog kind plus the engineer's settings in the selection entry."""
f = {"title": p["title"]}
if "description" in p:
f["description"] = p["description"]
if kind == "file":
f.update({"type": "object", "x-widget": "model-upload", "x-accept": p["x-accept"], "required": ["path", "name"],
"properties": {"path": {"type": "string", "minLength": 1},
"name": {"type": "string", "pattern": p["name-pattern"]}}})
elif kind == "ids":
f.update({"type": "array", "x-widget": picked[0]["widget"], "minItems": p.get("minItems", 1)})
if "maxItems" in p:
f["maxItems"] = p["maxItems"]
f["items"] = {"type": "object", "required": ["femap_id"],
"properties": {"femap_id": {"type": "integer", "minimum": 1}}}
elif kind == "number":
f.update({"type": "number", "x-widget": "number", "x-unit": p.get("x-unit", "")})
for k in ("minimum", "maximum"):
if k in p:
f[k] = p[k]
f["default"] = float("%.12g" % (float(picked[0]["original"]) / p.get("scale", 1)))
elif p.get("widget") == "switch":
f.update({"type": "boolean", "x-widget": "switch", "default": p["default"]})
else:
f.update({"type": "string", "x-widget": "select",
"oneOf": [{"const": o["const"], "title": o["title"]} for o in p["options"]]})
f["default"] = next(o["const"] for o in p["options"] if o.get("text") is None)
for k in ("x-group", "x-order", "x-visible-when"):
if k in p:
f[k] = p[k]
return f
def generate(source, selection):
"""Build app.json and template.ys from the source YS and the engineer's selection."""
if "{{" in source or "}}" in source:
raise DslAppError("source YS must not contain {{ or }}")
candidates = {c["locator"]: c for c in scan(source)}
props, required, bindings, spans = {}, [], [], []
for p in selection["params"]:
name = p["field"]
missing = [loc for loc in p.get("from", []) if loc not in candidates]
if missing:
raise DslAppError(f"{name}: {missing} are not allowlisted candidates of this source")
picked = [candidates[loc] for loc in p.get("from", [])]
keys = {c["key"] for c in picked}
if len(keys) > 1:
raise DslAppError(f"{name}: one field fills one key")
key = keys.pop() if keys else None
kind = CATALOG[key]["render"] if key else None
props[name] = field_for(p, picked, kind)
if kind in ("file", "ids") or p.get("required"):
required.append(name)
for i, c in enumerate(picked):
slot = name if len(picked) == 1 else f"{name}_{i + 1}"
render = {"kind": kind}
if kind == "number" and p.get("scale", 1) != 1:
render["scale"] = p["scale"]
if kind == "map":
render["cases"] = {o["const"]: c["original"] if o.get("text") is None else o["text"] for o in p["options"]}
bindings.append({"slot": slot, "field": name, "key": key, "render": render})
spans.append((c["start"], c["end"], slot))
spans.sort()
for a, b in zip(spans, spans[1:]):
if a[1] > b[0]:
raise DslAppError(f"slots {a[2]} and {b[2]} overlap")
parts, pos = [], 0
for start, end, slot in spans:
parts += [source[pos:start], "{{." + slot + "}}"]
pos = end
template = "".join(parts + [source[pos:]])
form = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": False,
"title": selection["app"]["title"], "x-groups": selection["groups"], "required": required,
"properties": props}
app = {"$schema": "https://gewu.yuansuan.cn/schemas/dsl-app/1.0/app.schema.json", "formatVersion": "1.0",
"app": selection["app"], "source": selection["source"], "runtime": selection["runtime"],
"files": {"template": {"path": "template.ys",
"sha256": hashlib.sha256(template.encode("utf-8")).hexdigest()}},
"form": form, "bindings": bindings}
return app, template
# ---------------------------------------------------------------- publish checks
def load(app_dir):
app_dir = Path(app_dir)
app = json.loads((app_dir / "app.json").read_text(encoding="utf-8"))
template = (app_dir / app["files"]["template"]["path"]).read_text(encoding="utf-8")
return app, template
def template_keys(template):
"""Key path of every placeholder occurrence in the template."""
found = []
for _, line, path, _ in walk(template):
for m in PLACEHOLDER.finditer(line):
before = line[:m.start()]
if key_of(path, None) == FORMULA_KEY or before.strip() == "":
found.append((m.group(1), key_of(path, None)))
else:
found.append((m.group(1), key_of(path, line_word(before))))
return found
def semantic_errors(app, template, app_dir):
errs = []
form = app["form"]
props = form["properties"]
required = set(form.get("required", []))
groups = {g["key"] for g in form.get("x-groups", [])}
bound_fields = {b["field"] for b in app["bindings"]}
slot_field = {b["slot"]: b["field"] for b in app["bindings"]}
digest = hashlib.sha256((Path(app_dir) / app["files"]["template"]["path"]).read_bytes()).hexdigest()
if digest != app["files"]["template"]["sha256"]:
errs.append("R1 template sha256 mismatch")
stripped = PLACEHOLDER.sub("", template)
if "{{" in stripped or "}}" in stripped:
errs.append("R2 template has delimiters that are not {{.key}} placeholders")
bound = [b["slot"] for b in app["bindings"]]
if len(bound) != len(set(bound)):
errs.append("R2 a slot is bound more than once")
if set(PLACEHOLDER.findall(template)) != set(bound):
errs.append(f"R2 template slots {sorted(set(PLACEHOLDER.findall(template)))} != bound slots {sorted(set(bound))}")
for b in app["bindings"]:
field = props.get(b["field"])
if field is None:
errs.append(f"R3 binding {b['slot']} references unknown field {b['field']}")
continue
want = WIDGET_RENDER[field["x-widget"]]
if b["render"]["kind"] != want:
errs.append(f"R3 binding {b['slot']}: widget {field['x-widget']} needs render kind {want}")
if want == "map":
keys = [o["const"] for o in field["oneOf"]] if field["x-widget"] == "select" else ["true", "false"]
missing = set(keys) - set(b["render"]["cases"])
if missing:
errs.append(f"R3 binding {b['slot']}: cases missing {sorted(missing)}")
if field["x-widget"] not in CATALOG[b["key"]]["widgets"]:
errs.append(f"R12 key {b['key']} does not allow widget {field['x-widget']}")
referenced = {f["x-visible-when"]["field"] for f in props.values() if "x-visible-when" in f}
for key, f in props.items():
if f["x-widget"] in NO_DEFAULT and (key not in required or "x-visible-when" in f):
errs.append(f"R4 {f['x-widget']} field {key} must be required and unconditional")
if key not in required and "default" not in f:
errs.append(f"R4 field {key} is neither required nor has a default")
vw = f.get("x-visible-when")
if vw:
if key in required:
errs.append(f"R4 conditional field {key} must not be in required")
if "default" not in f:
errs.append(f"R4 conditional field {key} needs a default")
target = props.get(vw["field"])
if target is None or "x-visible-when" in target or vw["field"] == key:
errs.append(f"R4 field {key}: x-visible-when must reference an unconditional other field")
if key not in bound_fields and key not in referenced:
errs.append(f"R5 field {key} is neither bound nor used by x-visible-when")
if "x-group" in f and f["x-group"] not in groups:
errs.append(f"R6 field {key} uses unknown group {f['x-group']}")
for key in required - set(props):
errs.append(f"R7 required key {key} is not a property")
defaults = {k: f["default"] for k, f in props.items() if "default" in f}
for e in jsonschema.Draft202012Validator(dict(form, required=[])).iter_errors(defaults):
errs.append(f"R8 default invalid at {list(e.absolute_path)}: {e.message}")
models = [k for k, f in props.items() if f["x-widget"] == "model-upload"]
if len(models) != 1:
errs.append("R9 exactly one model-upload field is required")
else:
model_slots = [s for s, f in slot_field.items() if f == models[0]]
geometry = geometry_block(template)
if len(model_slots) != 1 or f'source "{{{{.{model_slots[0]}}}}}"' not in geometry:
errs.append("R9 the model field must fill exactly the geometry.source file name")
for m in re.finditer(r"indices \[([^\]]*)\]", geometry):
slot = PLACEHOLDER.fullmatch(m.group(1).strip())
widget = props.get(slot_field.get(slot.group(1), ""), {}).get("x-widget") if slot else None
if widget not in PICKS:
errs.append(f"R9 every geometry group must be a pick slot: {m.group(0)}")
declared = {b["slot"]: b["key"] for b in app["bindings"]}
for slot, actual in template_keys(template):
if slot in declared and declared[slot] != actual:
errs.append(f"R12 slot {slot} declares key {declared[slot]} but sits at {actual}")
return errs
def geometry_block(template):
lines, depth, inside = [], 0, False
for line in template.splitlines():
if depth == 1 and line.strip().startswith("geometry") and line.strip().endswith("{"):
inside = True
if inside:
lines.append(line)
depth += line.count("{") - line.count("}")
if inside and depth == 1:
break
return "\n".join(lines)
def source_values(app, template, source):
"""Invert the source YS into values: what each slot held before it was cut."""
parts, names, pos = [], [], 0
for m in PLACEHOLDER.finditer(template):
parts += [re.escape(template[pos:m.start()]), f"(?P<s{len(names)}>.*?)"]
names.append(m.group(1))
pos = m.end()
parts.append(re.escape(template[pos:]))
match = re.fullmatch("".join(parts), source, flags=re.S)
if not match:
raise DslAppError("template does not align with the source YS")
texts = {}
for i, name in enumerate(names):
if texts.setdefault(name, match.group(f"s{i}")) != match.group(f"s{i}"):
raise DslAppError(f"slot {name} holds different texts in the source")
props = app["form"]["properties"]
values = {}
for b in app["bindings"]:
text, render = texts[b["slot"]], b["render"]
if render["kind"] == "ids":
value = [{"femap_id": int(t)} for t in text.split()]
elif render["kind"] == "file":
value = {"path": text, "name": text}
elif render["kind"] == "number":
if not NUMBER_TOKEN.match(text):
raise DslAppError(f"slot {b['slot']} does not hold a number literal")
value = float(text) / render.get("scale", 1)
else:
keys = [k for k, v in render["cases"].items() if v == text]
if not keys:
raise DslAppError(f"original text of slot {b['slot']} is none of its cases")
value = keys[0] if props[b["field"]]["x-widget"] == "select" else keys[0] == "true"
if b["field"] in values and values[b["field"]] != value:
raise DslAppError(f"field {b['field']} gets different originals from its slots")
values[b["field"]] = value
return values
# ---------------------------------------------------------------- render
def visible(field, values):
vw = field.get("x-visible-when")
if not vw:
return True
value = values.get(vw["field"])
return value == vw["equals"] if "equals" in vw else value in vw["in"]
def resolve_values(app, values):
"""Visibility, defaults, validation. Returns the effective values used for rendering."""
props = app["form"]["properties"]
unknown = set(values) - set(props)
if unknown:
raise DslAppError(f"unknown value keys {sorted(unknown)}")
plain = {k: v for k, v in values.items() if "x-visible-when" not in props[k]}
for k, f in props.items():
if "x-visible-when" not in f and k not in plain and "default" in f:
plain[k] = f["default"]
effective = dict(plain)
for k, f in props.items():
if "x-visible-when" in f:
effective[k] = values[k] if visible(f, plain) and k in values else f["default"]
for k, f in props.items():
model = effective.get(k)
if f["x-widget"] == "model-upload" and isinstance(model, dict):
name = model.get("name", "")
ext = name[name.rfind("."):].lower() if "." in name else ""
if not SAFE_NAME.fullmatch(name) or ext not in f["x-accept"]:
raise DslAppError(f"{k}: file name {name!r} must use [A-Za-z0-9_.-] and end with one of {f['x-accept']}")
errors = list(jsonschema.Draft202012Validator(app["form"]).iter_errors(effective))
if errors:
raise DslAppError("; ".join(f"{list(e.absolute_path) or e.validator}: {e.message}" for e in errors))
return effective
def render_value(binding, value):
render = binding["render"]
kind = render["kind"]
if kind == "number":
text = "%.12g" % (value * render.get("scale", 1))
return f"({text})" if binding["key"] == FORMULA_KEY and text.startswith("-") else text
if kind == "ids":
return " ".join(str(i) for i in sorted({e["femap_id"] for e in value}))
if kind == "file":
return value["name"]
key = ("true" if value else "false") if isinstance(value, bool) else value
return render["cases"][key]
def render(app, template, values):
effective = resolve_values(app, values)
texts = {b["slot"]: render_value(b, effective[b["field"]]) for b in app["bindings"]}
return PLACEHOLDER.sub(lambda m: texts[m.group(1)], template)
def tokens(text):
text = re.sub(rf"\(({NUMBER})\)", r"\1", text) # (-0.05) equals -0.05
return re.findall(rf'"[^"]*"|[{{}}\[\](),*/+]|(?<![\w.]){NUMBER}(?![\w.])|[^\s{{}}\[\]"(),*/+]+', text)
def same_ys(a, b):
"""Token-wise equality; numeric tokens compare by value within 1e-10 relative (6.0 == 6).
The builder writes shortest round-trip floats while number slots render with %.12g.
"""
ta, tb = tokens(a), tokens(b)
if len(ta) != len(tb):
return False
for x, y in zip(ta, tb):
if x == y:
continue
if NUMBER_TOKEN.match(x) and NUMBER_TOKEN.match(y) and math.isclose(float(x), float(y), rel_tol=1e-10, abs_tol=0):
continue
return False
return True
def round_trip_errors(app, template, source):
try:
restored = render(app, template, source_values(app, template, source))
except DslAppError as e:
return [f"R10 {e}"]
return [] if same_ys(restored, source) else ["R10 rendering the original values does not reproduce the source YS"]
def meta_schema(base):
"""Fill the catalog-driven parts of the meta-schema: allowed keys and key -> render kind."""
m = json.loads(json.dumps(base))
d = m["$defs"]
d["configurableKey"]["enum"] = sorted(CATALOG)
by_kind = {}
for key, entry in sorted(CATALOG.items()):
by_kind.setdefault(entry["render"], []).append(key)
d["binding"]["allOf"] = [
{"if": {"properties": {"key": {"enum": keys}}, "required": ["key"]},
"then": {"properties": {"render": {"properties": {"kind": {"const": kind}}}}}}
for kind, keys in sorted(by_kind.items())
]
return m
def main():
cmd = sys.argv[1]
if cmd == "schema":
base = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
Path(sys.argv[3]).write_text(json.dumps(meta_schema(base), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {sys.argv[3]}: {len(CATALOG)} configurable keys")
return
if cmd == "scan":
for c in scan(Path(sys.argv[2]).read_text(encoding="utf-8")):
print(f"{c['locator']:56s} {c['original'][:44]!r}")
return
if cmd == "generate":
source = Path(sys.argv[2]).read_text(encoding="utf-8")
selection = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8"))
app, template = generate(source, selection)
out = Path(sys.argv[4])
out.mkdir(parents=True, exist_ok=True)
(out / "template.ys").write_text(template, encoding="utf-8")
(out / "app.json").write_text(json.dumps(app, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"generated {out}/app.json: {len(app['form']['properties'])} fields, {len(app['bindings'])} slots")
return
app_dir = sys.argv[2]
app, template = load(app_dir)
if cmd == "check":
meta = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8"))
errs = [f"{list(e.absolute_path)}: {e.message}" for e in
jsonschema.Draft202012Validator(meta, format_checker=jsonschema.FormatChecker()).iter_errors(app)]
errs = errs or semantic_errors(app, template, app_dir)
source = Path(sys.argv[4]) if len(sys.argv) > 4 else Path(app_dir) / "source.ys"
if not errs and source.exists():
errs = round_trip_errors(app, template, source.read_text(encoding="utf-8"))
print("\n".join(errs) if errs else "OK")
sys.exit(1 if errs else 0)
if cmd == "render":
raw = sys.stdin.read() if sys.argv[3] == "-" else Path(sys.argv[3]).read_text(encoding="utf-8")
try:
out = render(app, template, json.loads(raw))
except DslAppError as e:
print(f"REJECTED: {e}")
sys.exit(2)
Path(sys.argv[4]).write_text(out, encoding="utf-8")
print(f"rendered {sys.argv[4]}")
if __name__ == "__main__":
main()11验证记录(本地已运行)
| 项目 | 结果 |
|---|---|
| 元 schema 由 base + 白名单生成(12 个 key);两个示例由 selection 生成,均通过元 schema 和 R1–R10、R12,源值回填后还原源 YS | OK |
| 25 个反例,每个都被预期的规则拦下。元 schema:未知控件、数值缺单位、模型缺 x-accept、选取项多字段、form 未禁止未知键、runtime 夹带运行指令、runtime 缺 units、选取带 default、模型带 default、binding 缺 key、key 不在白名单(材料、模型名)、key 与 render 类型不符。R2:模板多出 slot。R3:引用未知字段、map 缺选项。R4:无默认且非必填、选取非必填。R8:默认越界。R9:没有模型字段、几何组未设为参数。R12:声明的 key 与实际位置不符。R10:源值越界、源值不在选项里、隐藏字段的默认与源值不符 | 25/25 拒绝 |
| selection 引用白名单以外的候选(材料 E)时 generate 报错 | 拒绝 |
9 组非法参数值:超上限、非法选项、条件字段越界、未知键、缺少选取、文件名含 ../、文件名含引号、空面集与 femap_id=0、公式系数超范围 | 9/9 拒绝 |
开关关闭时条件字段的输入被忽略并改用 default;重复选取的面会去重;公式里的负数回填为 (-0.05) | OK |
eucliddsl --output x.ys 解析 4 份回填后的 YS(两个示例各两份),包括 max((-0.05), …);把公式系数换成 abc 时 eucliddsl 报未定义名字 | OK |
第 9 节动态渲染样例的回填逻辑(dslapp.py render 的 JS 移植):4 份回填结果与 dslapp.py 输出逐字节一致,%.12g 格式化 8 个边界值一致,3 个非法文件名被拒绝;浏览器中两个示例都能渲染,超上限时不回填,关闭开关后改用 default,桌面和手机宽度都没有横向滚动 | OK |
Go google/jsonschema-go v0.4.2 用两个示例的 form 校验:合法参数值通过;超上限、缺少选取被拒绝;x-* 被忽略 | OK |
- luban 从用户模型得到的面/体编号,是否与 DSL indices 的编号(Femap 导入 STEP 后的实体 id)一致。这一点由 luban 保证,建议双方用同一个 STEP 对一次编号。
- 白名单里的
initial.kinematic.velocity.value、step.control.time.duration来自 OpenRadioss builder 的写法,两个示例没有覆盖。 - R11 的 hub 任务和 Go 版
pkg/dslapp还没写,上面的结果来自 Python 参考实现和本地 eucliddsl。 - 运行链路(划网格、编译、求解)不在本文范围,没有验证。
- “模型名”按顶层
model <name>理解,不在白名单里;用户自带模型仍通过geometry.source。
12分期
- P1 格物:
structure/pkg/dslapp(白名单、scan、generate、R1–R12、render、元 schema 生成与测试),job.ys 留存,上传 YS 作为源 YS,dsl_app集合,5 个 WS 消息,Solver-DslAppCheck自检任务,构建页面(YS 视图与候选、3D 联动、参数配置、表单预览),发布到共享存储。 - luban:按本文的元 schema 和回填规则导入、渲染、回填,并自行实现运行。