完善AMESim组件界面与仿真求解稳定性
This commit is contained in:
1 parent
e7177ab03e
commit
410ef535e8
34 files changed
+3340
-251
No files matched your search
+316
-47
@@ -89,6 +89,10 @@ import {
|
||||
type ParameterColumnLayout,
|
||||
type ParameterTableRow,
|
||||
} from "./ParameterTable";
|
||||
import {
|
||||
evaluateParameterExpression,
|
||||
isParameterExpressionValue,
|
||||
} from "./parameterExpression";
|
||||
import {
|
||||
WorkspaceViewTabs,
|
||||
type WorkspaceView,
|
||||
@@ -219,7 +223,17 @@ type SimulationNodeData = {
|
||||
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
|
||||
type SimulationEdge = Edge;
|
||||
|
||||
type SimulationNumericKey = "t_start" | "t_stop" | "step" | "max_step";
|
||||
|
||||
type SimulationConfig = {
|
||||
t_start: ParameterValue;
|
||||
t_stop: ParameterValue;
|
||||
step: ParameterValue;
|
||||
max_step: ParameterValue;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type ResolvedSimulationConfig = {
|
||||
t_start: number;
|
||||
t_stop: number;
|
||||
step: number;
|
||||
@@ -255,6 +269,10 @@ type ReactFlowProjectPayload = {
|
||||
simulation: SimulationConfig;
|
||||
};
|
||||
|
||||
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation"> & {
|
||||
simulation: ResolvedSimulationConfig;
|
||||
};
|
||||
|
||||
type SimulationResult = {
|
||||
success: boolean;
|
||||
status: "completed" | "stopped" | "stalled" | "failed";
|
||||
@@ -534,7 +552,7 @@ const unitOptions: Record<UnitQuantity, UnitOption[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const defaultSimulationConfig: SimulationConfig = {
|
||||
const defaultSimulationConfig: ResolvedSimulationConfig = {
|
||||
t_start: 0,
|
||||
t_stop: 2,
|
||||
step: 0.1,
|
||||
@@ -625,6 +643,12 @@ type SimulationStreamEvent =
|
||||
totalTime?: number;
|
||||
};
|
||||
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
|
||||
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
|
||||
t_start: "起始时间",
|
||||
t_stop: "结束时间",
|
||||
step: "采样步长",
|
||||
max_step: "最大积分步长",
|
||||
};
|
||||
|
||||
function physicalPort(
|
||||
name: string,
|
||||
@@ -1527,7 +1551,7 @@ type NormalizedSimulationProgress = {
|
||||
|
||||
function normalizeSimulationProgressEvent(
|
||||
event: Extract<SimulationStreamEvent, { event: "progress" }>,
|
||||
config: SimulationConfig,
|
||||
config: ResolvedSimulationConfig,
|
||||
): NormalizedSimulationProgress {
|
||||
const totalTime = Number.isFinite(event.totalTime)
|
||||
? Number(event.totalTime)
|
||||
@@ -2184,7 +2208,7 @@ function DeferredNumberInput({
|
||||
<input
|
||||
aria-invalid={invalid}
|
||||
aria-label={ariaLabel}
|
||||
inputMode="decimal"
|
||||
inputMode="text"
|
||||
onBlur={() => {
|
||||
editingRef.current = false;
|
||||
if (cancelCommitRef.current) {
|
||||
@@ -3013,10 +3037,11 @@ function FlowWorkbench() {
|
||||
const selectedUnit =
|
||||
selectedNode?.data.parameterUnits[key] ?? definition.unit ?? "";
|
||||
const unit = findUnitOption(definition, selectedUnit);
|
||||
const numericValue = Number(displayValue);
|
||||
const normalizedDisplayValue = displayValue.trim();
|
||||
const numericValue = Number(normalizedDisplayValue);
|
||||
const baseValue =
|
||||
displayValue.trim() === "" || !Number.isFinite(numericValue)
|
||||
? displayValue
|
||||
normalizedDisplayValue === "" || !Number.isFinite(numericValue)
|
||||
? normalizedDisplayValue
|
||||
: unit.toBase(numericValue);
|
||||
|
||||
updateSelectedNode((node) => ({
|
||||
@@ -3216,15 +3241,22 @@ function FlowWorkbench() {
|
||||
]);
|
||||
|
||||
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
|
||||
const normalizedValue = value.trim();
|
||||
const numericValue = Number(normalizedValue);
|
||||
setSimulationConfig((current) => ({
|
||||
...current,
|
||||
[key]: key === "method" ? value : Number(value),
|
||||
[key]:
|
||||
key === "method"
|
||||
? value
|
||||
: normalizedValue !== "" && Number.isFinite(numericValue)
|
||||
? numericValue
|
||||
: normalizedValue,
|
||||
}));
|
||||
};
|
||||
|
||||
const generateXml = () => {
|
||||
try {
|
||||
const xml = buildSystemXml(buildCurrentProject());
|
||||
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions);
|
||||
changeConsoleMode("normal");
|
||||
appendConsoleEntry(
|
||||
"success",
|
||||
@@ -3239,7 +3271,7 @@ function FlowWorkbench() {
|
||||
|
||||
const downloadXml = () => {
|
||||
try {
|
||||
const xml = buildSystemXml(buildCurrentProject());
|
||||
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions);
|
||||
downloadText(`${safeFilename(projectName)}.xml`, xml);
|
||||
appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`);
|
||||
} catch (error) {
|
||||
@@ -3402,7 +3434,7 @@ function FlowWorkbench() {
|
||||
|
||||
const publishSimulationResult = (
|
||||
result: SimulationResult,
|
||||
project: ReactFlowProjectPayload,
|
||||
project: ExecutableProjectPayload,
|
||||
) => {
|
||||
const resultStatus = result.status ?? (result.success ? "completed" : "failed");
|
||||
const sampleCount = Math.min(
|
||||
@@ -3510,34 +3542,48 @@ function FlowWorkbench() {
|
||||
const runSimulation = async () => {
|
||||
const issues = checkModel();
|
||||
const modelErrors = issues.filter((issue) => issue.severity === "error");
|
||||
const simulationResolution = resolveSimulationConfig(
|
||||
simulationConfigRef.current,
|
||||
);
|
||||
const progressConfig = simulationResolution.ok
|
||||
? simulationResolution.value
|
||||
: defaultSimulationConfig;
|
||||
if (modelErrors.length > 0) {
|
||||
changeConsoleMode("normal");
|
||||
setSimulationProgress({
|
||||
state: "error",
|
||||
percent: 0,
|
||||
message: "模型检查未通过",
|
||||
startTime: simulationConfigRef.current.t_start,
|
||||
simulatedTime: simulationConfigRef.current.t_start,
|
||||
totalTime: simulationConfigRef.current.t_stop,
|
||||
startTime: progressConfig.t_start,
|
||||
simulatedTime: progressConfig.t_start,
|
||||
totalTime: progressConfig.t_stop,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!simulationResolution.ok) {
|
||||
changeConsoleMode("normal");
|
||||
appendConsoleEntry("error", simulationResolution.message);
|
||||
return;
|
||||
}
|
||||
const project: ExecutableProjectPayload = {
|
||||
...buildCurrentProject(),
|
||||
simulation: simulationResolution.value,
|
||||
};
|
||||
changeConsoleMode("normal");
|
||||
setSimulationProgress({
|
||||
state: "running",
|
||||
percent: 0,
|
||||
message: "正在提交仿真任务",
|
||||
startTime: simulationConfigRef.current.t_start,
|
||||
simulatedTime: simulationConfigRef.current.t_start,
|
||||
totalTime: simulationConfigRef.current.t_stop,
|
||||
startTime: project.simulation.t_start,
|
||||
simulatedTime: project.simulation.t_start,
|
||||
totalTime: project.simulation.t_stop,
|
||||
});
|
||||
appendConsoleEntry(
|
||||
"info",
|
||||
`开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`,
|
||||
`开始仿真:${projectNameRef.current || "untitled"},时间范围 ${project.simulation.t_start} - ${project.simulation.t_stop} s`,
|
||||
);
|
||||
await runBusyAction("正在编译模型并运行仿真...", async () => {
|
||||
const project = buildCurrentProject();
|
||||
const xml = buildSystemXml(project);
|
||||
const xml = buildSystemXml(project, componentDefinitions);
|
||||
const simulationId = createSimulationId();
|
||||
activeSimulationIdRef.current = simulationId;
|
||||
setSimulationStopPending(false);
|
||||
@@ -3988,7 +4034,11 @@ function FlowWorkbench() {
|
||||
const error =
|
||||
gasReference && !gasReferenceAvailable
|
||||
? "引用的介质定义不存在"
|
||||
: parameterValidationMessage(definition, baseValue);
|
||||
: parameterValidationMessage(
|
||||
definition,
|
||||
baseValue,
|
||||
selectedUnit,
|
||||
);
|
||||
let valueControl: ReactNode;
|
||||
if (mediumDefinitionIndex) {
|
||||
valueControl = (
|
||||
@@ -4103,12 +4153,20 @@ function FlowWorkbench() {
|
||||
definition,
|
||||
selectedUnit,
|
||||
),
|
||||
calculatedValue: parameterCalculatedValueMetadata(
|
||||
baseValue,
|
||||
definition,
|
||||
selectedUnit,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
const simulationSettingErrors = simulationConfigValidationMessages(
|
||||
simulationConfig,
|
||||
);
|
||||
const simulationSettingRows: ParameterTableRow[] = [
|
||||
{
|
||||
id: "simulation-t-start",
|
||||
@@ -4116,6 +4174,7 @@ function FlowWorkbench() {
|
||||
value: (
|
||||
<DeferredNumberInput
|
||||
ariaLabel="起始时间"
|
||||
invalid={Boolean(simulationSettingErrors.t_start)}
|
||||
value={simulationConfig.t_start}
|
||||
onCommit={(value) => updateSimulationConfig("t_start", value)}
|
||||
onEditEnd={finishHistoryEdit}
|
||||
@@ -4127,6 +4186,11 @@ function FlowWorkbench() {
|
||||
defaultValue: `${defaultSimulationConfig.t_start} s`,
|
||||
range: "有限数值",
|
||||
currentUnit: "s",
|
||||
error: simulationSettingErrors.t_start,
|
||||
calculatedValue: simulationCalculatedValueMetadata(
|
||||
simulationConfig.t_start,
|
||||
"s",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "simulation-t-stop",
|
||||
@@ -4134,6 +4198,7 @@ function FlowWorkbench() {
|
||||
value: (
|
||||
<DeferredNumberInput
|
||||
ariaLabel="结束时间"
|
||||
invalid={Boolean(simulationSettingErrors.t_stop)}
|
||||
value={simulationConfig.t_stop}
|
||||
onCommit={(value) => updateSimulationConfig("t_stop", value)}
|
||||
onEditEnd={finishHistoryEdit}
|
||||
@@ -4145,6 +4210,11 @@ function FlowWorkbench() {
|
||||
defaultValue: `${defaultSimulationConfig.t_stop} s`,
|
||||
range: "> 起始时间",
|
||||
currentUnit: "s",
|
||||
error: simulationSettingErrors.t_stop,
|
||||
calculatedValue: simulationCalculatedValueMetadata(
|
||||
simulationConfig.t_stop,
|
||||
"s",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "simulation-step",
|
||||
@@ -4152,6 +4222,7 @@ function FlowWorkbench() {
|
||||
value: (
|
||||
<DeferredNumberInput
|
||||
ariaLabel="采样步长"
|
||||
invalid={Boolean(simulationSettingErrors.step)}
|
||||
value={simulationConfig.step}
|
||||
onCommit={(value) => updateSimulationConfig("step", value)}
|
||||
onEditEnd={finishHistoryEdit}
|
||||
@@ -4163,6 +4234,11 @@ function FlowWorkbench() {
|
||||
defaultValue: `${defaultSimulationConfig.step} s`,
|
||||
range: "> 0",
|
||||
currentUnit: "s",
|
||||
error: simulationSettingErrors.step,
|
||||
calculatedValue: simulationCalculatedValueMetadata(
|
||||
simulationConfig.step,
|
||||
"s",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "simulation-max-step",
|
||||
@@ -4170,6 +4246,7 @@ function FlowWorkbench() {
|
||||
value: (
|
||||
<DeferredNumberInput
|
||||
ariaLabel="最大积分步长"
|
||||
invalid={Boolean(simulationSettingErrors.max_step)}
|
||||
value={simulationConfig.max_step}
|
||||
onCommit={(value) => updateSimulationConfig("max_step", value)}
|
||||
onEditEnd={finishHistoryEdit}
|
||||
@@ -4181,6 +4258,11 @@ function FlowWorkbench() {
|
||||
defaultValue: `${defaultSimulationConfig.max_step} s`,
|
||||
range: "> 0",
|
||||
currentUnit: "s",
|
||||
error: simulationSettingErrors.max_step,
|
||||
calculatedValue: simulationCalculatedValueMetadata(
|
||||
simulationConfig.max_step,
|
||||
"s",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "simulation-method",
|
||||
@@ -4838,7 +4920,9 @@ function normalizeLoadedNode(
|
||||
label: node.data.label || definition?.label || node.id,
|
||||
componentType: node.data.componentType || definition?.type || "component",
|
||||
modelType: node.data.modelType || definition?.modelType || node.data.componentType,
|
||||
symbol: node.data.symbol || definition?.symbol || node.data.componentType,
|
||||
// Catalog symbols are versioned presentation metadata. Prefer the current
|
||||
// definition so projects saved with an old generic icon are upgraded on load.
|
||||
symbol: definition?.symbol || node.data.symbol || node.data.componentType,
|
||||
ports: normalizeLoadedPorts(node.data.ports, definition),
|
||||
parameters,
|
||||
parameterUnits,
|
||||
@@ -5122,17 +5206,95 @@ function parameterCurrentUnitMetadata(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type NumericInputResolution =
|
||||
| { ok: true; value: number; expression: boolean }
|
||||
| { ok: false; message: string; expression: boolean };
|
||||
|
||||
function resolveNumericInput(value: ParameterValue): NumericInputResolution {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value)
|
||||
? { ok: true, value, expression: false }
|
||||
: { ok: false, message: "请输入有限数值", expression: false };
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
if (!normalizedValue) {
|
||||
return { ok: false, message: "参数不能为空", expression: false };
|
||||
}
|
||||
const numericValue = Number(normalizedValue);
|
||||
if (Number.isFinite(numericValue)) {
|
||||
return { ok: true, value: numericValue, expression: false };
|
||||
}
|
||||
|
||||
const evaluated = evaluateParameterExpression(normalizedValue);
|
||||
return evaluated.ok
|
||||
? { ok: true, value: evaluated.value, expression: true }
|
||||
: {
|
||||
ok: false,
|
||||
message: `等式无法计算:${evaluated.message}`,
|
||||
expression: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveParameterValue(
|
||||
value: ParameterValue,
|
||||
definition: ParameterDefinition,
|
||||
selectedUnit: string,
|
||||
): NumericInputResolution {
|
||||
const resolved = resolveNumericInput(value);
|
||||
if (!resolved.ok) {
|
||||
return resolved;
|
||||
}
|
||||
if (resolved.expression && definition.editor) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "该离散参数只能通过下拉栏选择",
|
||||
expression: true,
|
||||
};
|
||||
}
|
||||
if (!resolved.expression) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const baseValue = findUnitOption(definition, selectedUnit).toBase(
|
||||
resolved.value,
|
||||
);
|
||||
return Number.isFinite(baseValue)
|
||||
? { ...resolved, value: baseValue }
|
||||
: {
|
||||
ok: false,
|
||||
message: "等式换算到基础单位后不是有限数值",
|
||||
expression: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parameterCalculatedValueMetadata(
|
||||
value: ParameterValue,
|
||||
definition: ParameterDefinition,
|
||||
selectedUnit: string,
|
||||
) {
|
||||
if (!isParameterExpressionValue(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = resolveParameterValue(value, definition, selectedUnit);
|
||||
if (!resolved.ok || !resolved.expression) {
|
||||
return undefined;
|
||||
}
|
||||
const unit = findUnitOption(definition, selectedUnit);
|
||||
const displayValue = unit.fromBase(resolved.value);
|
||||
return `${formatEditableNumber(displayValue)}${unit.label ? ` ${unit.label}` : ""}`;
|
||||
}
|
||||
|
||||
function parameterValidationMessage(
|
||||
definition: ParameterDefinition,
|
||||
value: ParameterValue,
|
||||
selectedUnit: string,
|
||||
) {
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return "参数不能为空";
|
||||
}
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return "请输入有效数字";
|
||||
const resolved = resolveParameterValue(value, definition, selectedUnit);
|
||||
if (!resolved.ok) {
|
||||
return resolved.message;
|
||||
}
|
||||
const numericValue = resolved.value;
|
||||
if (
|
||||
definition.options &&
|
||||
!definition.options.some((option) => option.value === numericValue)
|
||||
@@ -5153,6 +5315,85 @@ function parameterValidationMessage(
|
||||
return "";
|
||||
}
|
||||
|
||||
function simulationCalculatedValueMetadata(
|
||||
value: ParameterValue,
|
||||
unit: string,
|
||||
) {
|
||||
if (!isParameterExpressionValue(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = resolveNumericInput(value);
|
||||
if (!resolved.ok || !resolved.expression) {
|
||||
return undefined;
|
||||
}
|
||||
return `${formatEditableNumber(resolved.value)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
function simulationConfigValidationMessages(
|
||||
simulation: SimulationConfig,
|
||||
): Partial<Record<SimulationNumericKey, string>> {
|
||||
const resolutions = {
|
||||
t_start: resolveNumericInput(simulation.t_start),
|
||||
t_stop: resolveNumericInput(simulation.t_stop),
|
||||
step: resolveNumericInput(simulation.step),
|
||||
max_step: resolveNumericInput(simulation.max_step),
|
||||
} satisfies Record<SimulationNumericKey, NumericInputResolution>;
|
||||
const messages: Partial<Record<SimulationNumericKey, string>> = {};
|
||||
|
||||
(Object.keys(resolutions) as SimulationNumericKey[]).forEach((key) => {
|
||||
const resolved = resolutions[key];
|
||||
if (!resolved.ok) {
|
||||
messages[key] = resolved.message;
|
||||
}
|
||||
});
|
||||
if (
|
||||
resolutions.t_start.ok &&
|
||||
resolutions.t_stop.ok &&
|
||||
resolutions.t_stop.value <= resolutions.t_start.value
|
||||
) {
|
||||
messages.t_stop = "必须大于起始时间";
|
||||
}
|
||||
if (resolutions.step.ok && resolutions.step.value <= 0) {
|
||||
messages.step = "必须大于 0";
|
||||
}
|
||||
if (resolutions.max_step.ok && resolutions.max_step.value <= 0) {
|
||||
messages.max_step = "必须大于 0";
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
type SimulationConfigResolution =
|
||||
| { ok: true; value: ResolvedSimulationConfig }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function resolveSimulationConfig(
|
||||
simulation: SimulationConfig,
|
||||
): SimulationConfigResolution {
|
||||
const messages = simulationConfigValidationMessages(simulation);
|
||||
const firstInvalidKey = (Object.keys(messages) as SimulationNumericKey[])[0];
|
||||
if (firstInvalidKey) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `仿真设置“${simulationConfigLabels[firstInvalidKey]}”:${messages[firstInvalidKey]}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
t_start: (resolveNumericInput(simulation.t_start) as { ok: true; value: number })
|
||||
.value,
|
||||
t_stop: (resolveNumericInput(simulation.t_stop) as { ok: true; value: number })
|
||||
.value,
|
||||
step: (resolveNumericInput(simulation.step) as { ok: true; value: number })
|
||||
.value,
|
||||
max_step: (
|
||||
resolveNumericInput(simulation.max_step) as { ok: true; value: number }
|
||||
).value,
|
||||
method: simulation.method,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findDuplicateNames(nodes: SimulationNode[]) {
|
||||
const counts = new Map<string, number>();
|
||||
nodes.forEach((node) => {
|
||||
@@ -5236,9 +5477,16 @@ function validateModel(
|
||||
return;
|
||||
}
|
||||
Object.entries(definition.parameters).forEach(([key, parameter]) => {
|
||||
const message = parameterValidationMessage(parameter, node.data.parameters[key] ?? "");
|
||||
const selectedUnit =
|
||||
node.data.parameterUnits[key] ?? parameter.unit ?? "";
|
||||
const message = parameterValidationMessage(
|
||||
parameter,
|
||||
node.data.parameters[key] ?? "",
|
||||
selectedUnit,
|
||||
);
|
||||
if (message) {
|
||||
addIssue("error", `${node.data.label}:${parameter.label}${message}`, {
|
||||
const messageSeparator = message.startsWith("等式") ? ":" : "";
|
||||
addIssue("error", `${node.data.label}:${parameter.label}${messageSeparator}${message}`, {
|
||||
nodeId: node.id,
|
||||
});
|
||||
}
|
||||
@@ -5416,17 +5664,13 @@ function validateModel(
|
||||
});
|
||||
});
|
||||
|
||||
if (!Number.isFinite(simulation.t_start) || !Number.isFinite(simulation.t_stop)) {
|
||||
addIssue("error", "仿真起止时间必须是有效数字");
|
||||
} else if (simulation.t_stop <= simulation.t_start) {
|
||||
addIssue("error", "仿真结束时间必须大于起始时间");
|
||||
}
|
||||
if (!Number.isFinite(simulation.step) || simulation.step <= 0) {
|
||||
addIssue("error", "采样步长必须大于 0");
|
||||
}
|
||||
if (!Number.isFinite(simulation.max_step) || simulation.max_step <= 0) {
|
||||
addIssue("error", "最大积分步长必须大于 0");
|
||||
}
|
||||
const simulationErrors = simulationConfigValidationMessages(simulation);
|
||||
(Object.keys(simulationErrors) as SimulationNumericKey[]).forEach((key) => {
|
||||
const message = simulationErrors[key];
|
||||
if (message) {
|
||||
addIssue("error", `仿真设置:${simulationConfigLabels[key]}:${message}`);
|
||||
}
|
||||
});
|
||||
if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) {
|
||||
addIssue("error", "求解器不受支持");
|
||||
}
|
||||
@@ -5548,7 +5792,15 @@ function formatTime(date: Date) {
|
||||
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function buildSystemXml(project: ReactFlowProjectPayload) {
|
||||
function buildSystemXml(
|
||||
project: ReactFlowProjectPayload,
|
||||
componentDefinitions: ComponentDefinition[],
|
||||
) {
|
||||
const simulationResolution = resolveSimulationConfig(project.simulation);
|
||||
if (!simulationResolution.ok) {
|
||||
throw new Error(simulationResolution.message);
|
||||
}
|
||||
const resolvedSimulation = simulationResolution.value;
|
||||
const xmlDocument = document.implementation.createDocument("", "System", null);
|
||||
const system = xmlDocument.documentElement;
|
||||
system.setAttribute("name", project.name);
|
||||
@@ -5557,16 +5809,22 @@ function buildSystemXml(project: ReactFlowProjectPayload) {
|
||||
system.setAttribute("unitSystem", "SI");
|
||||
|
||||
const simulation = xmlDocument.createElement("Simulation");
|
||||
simulation.setAttribute("tStart", String(project.simulation.t_start));
|
||||
simulation.setAttribute("tStop", String(project.simulation.t_stop));
|
||||
simulation.setAttribute("step", String(project.simulation.step));
|
||||
simulation.setAttribute("maxStep", String(project.simulation.max_step));
|
||||
simulation.setAttribute("method", project.simulation.method);
|
||||
simulation.setAttribute("tStart", String(resolvedSimulation.t_start));
|
||||
simulation.setAttribute("tStop", String(resolvedSimulation.t_stop));
|
||||
simulation.setAttribute("step", String(resolvedSimulation.step));
|
||||
simulation.setAttribute("maxStep", String(resolvedSimulation.max_step));
|
||||
simulation.setAttribute("method", resolvedSimulation.method);
|
||||
const components = xmlDocument.createElement("Components");
|
||||
const connections = xmlDocument.createElement("Connections");
|
||||
system.append(simulation, components, connections);
|
||||
|
||||
project.nodes.forEach((node) => {
|
||||
const definition = componentDefinitions.find(
|
||||
(candidate) => candidate.type === node.data.componentType,
|
||||
);
|
||||
if (!definition) {
|
||||
throw new Error(`组件 ${node.data.label || node.id} 的类型不受支持`);
|
||||
}
|
||||
const component = xmlDocument.createElement("Component");
|
||||
component.setAttribute("id", node.id);
|
||||
component.setAttribute("name", node.data.label);
|
||||
@@ -5590,9 +5848,20 @@ function buildSystemXml(project: ReactFlowProjectPayload) {
|
||||
component.appendChild(port);
|
||||
});
|
||||
Object.entries(node.data.parameters).forEach(([name, value]) => {
|
||||
const parameterDefinition = definition.parameters[name];
|
||||
const selectedUnit =
|
||||
node.data.parameterUnits?.[name] ?? parameterDefinition?.unit ?? "";
|
||||
const resolved = parameterDefinition
|
||||
? resolveParameterValue(value, parameterDefinition, selectedUnit)
|
||||
: resolveNumericInput(value);
|
||||
if (!resolved.ok) {
|
||||
throw new Error(
|
||||
`组件“${node.data.label || node.id}”的参数“${parameterDefinition?.label ?? name}”:${resolved.message}`,
|
||||
);
|
||||
}
|
||||
const parameter = xmlDocument.createElement("Parameter");
|
||||
parameter.setAttribute("name", name);
|
||||
parameter.setAttribute("value", String(value));
|
||||
parameter.setAttribute("value", formatEditableNumber(resolved.value));
|
||||
component.appendChild(parameter);
|
||||
});
|
||||
components.appendChild(component);
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AmesimPnpl01Symbol } from "./componentSymbols/boundary";
|
||||
import {
|
||||
AmesimF000Symbol,
|
||||
AmesimForcSymbol,
|
||||
AmesimLmechn1Symbol,
|
||||
AmesimLstp00aSymbol,
|
||||
AmesimMecmas21Symbol,
|
||||
} from "./componentSymbols/mechanical";
|
||||
import {
|
||||
AmesimHeliumMediumSymbol,
|
||||
AmesimIdealAirMediumSymbol,
|
||||
} from "./componentSymbols/media";
|
||||
import {
|
||||
AmesimPn3Node2Symbol,
|
||||
AmesimP4Node2Symbol,
|
||||
AmesimPnl0001Symbol,
|
||||
AmesimPnl0002Symbol,
|
||||
AmesimPnl0003Symbol,
|
||||
AmesimPnl00rSymbol,
|
||||
AmesimPnor001Symbol,
|
||||
AmesimPnvo001FixedSymbol,
|
||||
AmesimPnvo001SignalSymbol,
|
||||
} from "./componentSymbols/pneumatic";
|
||||
import { SymbolSvg } from "./componentSymbols/primitives";
|
||||
import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals";
|
||||
import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage";
|
||||
import type {
|
||||
ComponentSymbolPresentation,
|
||||
SymbolDefinition,
|
||||
} from "./componentSymbols/types";
|
||||
|
||||
export type { ComponentSymbolPresentation } from "./componentSymbols/types";
|
||||
|
||||
export type ComponentSymbolSurface = "palette" | "canvas";
|
||||
export type ComponentSymbolPresentation = "framed" | "bare";
|
||||
|
||||
type ComponentSymbolProps = {
|
||||
symbol: string;
|
||||
surface: ComponentSymbolSurface;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SymbolDefinition = {
|
||||
presentation: ComponentSymbolPresentation;
|
||||
render: () => ReactNode;
|
||||
};
|
||||
|
||||
type SymbolSvgProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function SymbolSvg({ children, className }: SymbolSvgProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={["component-symbol-svg", className].filter(Boolean).join(" ")}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
viewBox="0 0 64 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CylinderSymbol() {
|
||||
return (
|
||||
<SymbolSvg>
|
||||
@@ -145,28 +149,6 @@ function JunctionSymbol() {
|
||||
);
|
||||
}
|
||||
|
||||
function MediumSymbol({ label }: { label: "Air" | "He" }) {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-medium-svg">
|
||||
<circle cx="32" cy="24" fill="#ffffff" r="22" stroke="#c026d3" strokeWidth="2" />
|
||||
<text
|
||||
className="component-symbol-medium-label"
|
||||
data-symbol-label={label}
|
||||
dominantBaseline="central"
|
||||
fill="#a21caf"
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize={label === "Air" ? 12 : 16}
|
||||
fontWeight="500"
|
||||
textAnchor="middle"
|
||||
x="32"
|
||||
y="24"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
const symbolRegistry = {
|
||||
cylinder: { presentation: "framed", render: CylinderSymbol },
|
||||
tank: { presentation: "framed", render: TankSymbol },
|
||||
@@ -176,13 +158,38 @@ const symbolRegistry = {
|
||||
generic: { presentation: "framed", render: GenericSymbol },
|
||||
signal: { presentation: "framed", render: SignalSymbol },
|
||||
junction: { presentation: "framed", render: JunctionSymbol },
|
||||
amesim_pnpl01: { presentation: "framed", render: AmesimPnpl01Symbol },
|
||||
amesim_pnch023: { presentation: "framed", render: AmesimPnch023Symbol },
|
||||
amesim_pnch012: { presentation: "framed", render: AmesimPnch012Symbol },
|
||||
amesim_pnor001: { presentation: "framed", render: AmesimPnor001Symbol },
|
||||
amesim_pnvo001_fixed: {
|
||||
presentation: "framed",
|
||||
render: AmesimPnvo001FixedSymbol,
|
||||
},
|
||||
amesim_pnvo001: {
|
||||
presentation: "framed",
|
||||
render: AmesimPnvo001SignalSymbol,
|
||||
},
|
||||
amesim_pnl00r: { presentation: "framed", render: AmesimPnl00rSymbol },
|
||||
amesim_pnl0001: { presentation: "framed", render: AmesimPnl0001Symbol },
|
||||
amesim_pnl0002: { presentation: "framed", render: AmesimPnl0002Symbol },
|
||||
amesim_pnl0003: { presentation: "framed", render: AmesimPnl0003Symbol },
|
||||
amesim_pn3node2: { presentation: "framed", render: AmesimPn3Node2Symbol },
|
||||
amesim_p4node2: { presentation: "framed", render: AmesimP4Node2Symbol },
|
||||
amesim_step0: { presentation: "framed", render: AmesimStep0Symbol },
|
||||
amesim_ud00: { presentation: "framed", render: AmesimUd00Symbol },
|
||||
amesim_f000: { presentation: "framed", render: AmesimF000Symbol },
|
||||
amesim_forc: { presentation: "framed", render: AmesimForcSymbol },
|
||||
amesim_mecmas21: { presentation: "framed", render: AmesimMecmas21Symbol },
|
||||
amesim_lstp00a: { presentation: "framed", render: AmesimLstp00aSymbol },
|
||||
amesim_lmechn1: { presentation: "framed", render: AmesimLmechn1Symbol },
|
||||
amesim_ideal_air_medium: {
|
||||
presentation: "bare",
|
||||
render: () => <MediumSymbol label="Air" />,
|
||||
render: AmesimIdealAirMediumSymbol,
|
||||
},
|
||||
amesim_helium_medium: {
|
||||
presentation: "bare",
|
||||
render: () => <MediumSymbol label="He" />,
|
||||
render: AmesimHeliumMediumSymbol,
|
||||
},
|
||||
} satisfies Record<string, SymbolDefinition>;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export type ParameterTableRow = {
|
||||
defaultValue?: string;
|
||||
range?: string;
|
||||
currentUnit?: string;
|
||||
calculatedValue?: string;
|
||||
};
|
||||
|
||||
type ParameterTableProps = {
|
||||
@@ -144,7 +145,8 @@ function rowHasHelpPreview(row: ParameterTableRow) {
|
||||
(typeof row.help === "string" && row.help) ||
|
||||
row.defaultValue ||
|
||||
row.range ||
|
||||
row.currentUnit,
|
||||
row.currentUnit ||
|
||||
row.calculatedValue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,10 +464,16 @@ export function ParameterTable({
|
||||
{previewDescription}
|
||||
</span>
|
||||
) : null}
|
||||
{previewRow.defaultValue ||
|
||||
{previewRow.calculatedValue ||
|
||||
previewRow.defaultValue ||
|
||||
previewRow.range ||
|
||||
previewRow.currentUnit ? (
|
||||
<span className="parameter-help-preview-meta">
|
||||
{previewRow.calculatedValue ? (
|
||||
<span data-testid="parameter-calculated-value">
|
||||
计算值:{previewRow.calculatedValue}
|
||||
</span>
|
||||
) : null}
|
||||
{previewRow.defaultValue ? (
|
||||
<span>默认值:{previewRow.defaultValue}</span>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SYMBOL_ACCENT, SYMBOL_STROKE, SymbolSvg } from "./primitives";
|
||||
|
||||
export function AmesimPnpl01Symbol() {
|
||||
return (
|
||||
<SymbolSvg domain="pneumatic">
|
||||
<path d="M2 24h34" stroke={SYMBOL_STROKE} strokeWidth="2" />
|
||||
<path
|
||||
d="m35 17 14 14m0-14L35 31"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
SYMBOL_ACCENT,
|
||||
SYMBOL_FILL,
|
||||
SYMBOL_MUTED,
|
||||
SYMBOL_STROKE,
|
||||
SymbolSvg,
|
||||
} from "./primitives";
|
||||
|
||||
const MECHANICAL_LINE_WIDTH = 1.8;
|
||||
|
||||
const mechanicalStroke = {
|
||||
fill: "none",
|
||||
stroke: SYMBOL_STROKE,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
strokeWidth: MECHANICAL_LINE_WIDTH,
|
||||
};
|
||||
|
||||
function MechanicalSocket({ side }: { side: "left" | "right" }) {
|
||||
const facingLeft = side === "left";
|
||||
return (
|
||||
<g {...mechanicalStroke}>
|
||||
<path d={facingLeft ? "M2 24h7" : "M55 24h7"} />
|
||||
<path d={facingLeft ? "M15 19H9v10h6" : "M49 19h6v10h-6"} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideHatching({ y, upward }: { y: number; upward: boolean }) {
|
||||
const direction = upward ? -1 : 1;
|
||||
return (
|
||||
<g stroke={SYMBOL_MUTED} strokeWidth="1">
|
||||
{[14, 22, 30, 38, 46].map((x) => (
|
||||
<path
|
||||
d={`M${x - 3} ${y + direction * 3}L${x + 3} ${y - direction * 3}`}
|
||||
key={x}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimF000Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-f000" domain="mechanical">
|
||||
<path d="M41 24h21" {...mechanicalStroke} />
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height="28"
|
||||
rx="1"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="2.2"
|
||||
width="18"
|
||||
x="23"
|
||||
y="12"
|
||||
/>
|
||||
<path d="M27 26h10" stroke={SYMBOL_ACCENT} strokeWidth="2" />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimForcSymbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-forc" domain="mechanical">
|
||||
<g
|
||||
fill="none"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M2 24h15" />
|
||||
<path d="m11 19 6 5-6 5" />
|
||||
</g>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="24"
|
||||
fill={SYMBOL_FILL}
|
||||
r="12"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="central"
|
||||
fill={SYMBOL_STROKE}
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
x="32"
|
||||
y="24"
|
||||
>
|
||||
F
|
||||
</text>
|
||||
<path d="M44 24h6M50 18v12M50 18h8M50 30h8M58 18v12M58 24h4" {...mechanicalStroke} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimMecmas21Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-mecmas21" domain="mechanical">
|
||||
<GuideHatching upward y={5} />
|
||||
<GuideHatching upward={false} y={43} />
|
||||
<path d="M10 8h44M10 40h44" stroke={SYMBOL_MUTED} strokeWidth="1.3" />
|
||||
<MechanicalSocket side="left" />
|
||||
<MechanicalSocket side="right" />
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height="26"
|
||||
rx="1"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="2"
|
||||
width="34"
|
||||
x="15"
|
||||
y="11"
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="central"
|
||||
fill={SYMBOL_STROKE}
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize="12"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
x="31"
|
||||
y="28"
|
||||
>
|
||||
M
|
||||
</text>
|
||||
<path
|
||||
d="M22 18h15m-5-4 5 4-5 4"
|
||||
fill="none"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimLstp00aSymbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-lstp00a" domain="mechanical">
|
||||
<path d="M2 24h8M54 24h8" {...mechanicalStroke} />
|
||||
<g transform="rotate(90 32 24)">
|
||||
<path d="M32 2v7M32 39v7" {...mechanicalStroke} />
|
||||
<path d="M27 9V5h10v4M27 39v4h10v-4" {...mechanicalStroke} />
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height="3"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="1.6"
|
||||
width="46"
|
||||
x="9"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height="3"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="1.6"
|
||||
width="46"
|
||||
x="9"
|
||||
y="31"
|
||||
/>
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height="3"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="1.6"
|
||||
width="46"
|
||||
x="9"
|
||||
y="39"
|
||||
/>
|
||||
<path
|
||||
d="M14 13v4c0 2 12 2 12 4s-12 2-12 4 12 2 12 6"
|
||||
fill="none"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M36 18v-5M36 18h14v6H36v7M40 22h6" {...mechanicalStroke} />
|
||||
</g>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimLmechn1Symbol() {
|
||||
const leftPortRows = [7, 12, 17, 22, 27, 32, 37, 42];
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-lmechn1" domain="mechanical">
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height="44"
|
||||
rx="1"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="1.8"
|
||||
width="12"
|
||||
x="27"
|
||||
y="2"
|
||||
/>
|
||||
{leftPortRows.map((y) => (
|
||||
<path
|
||||
d={`M5 ${y}h22`}
|
||||
key={y}
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
))}
|
||||
<path d="M39 24h20" stroke={SYMBOL_STROKE} strokeWidth="1.8" />
|
||||
<circle cx="33" cy="24" fill={SYMBOL_ACCENT} r="2.2" />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SYMBOL_ACCENT, SYMBOL_FILL, SYMBOL_STROKE, SymbolSvg } from "./primitives";
|
||||
|
||||
function MediumSymbol({ label }: { label: "Air" | "He" }) {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-medium-svg" domain="medium">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="24"
|
||||
fill={SYMBOL_FILL}
|
||||
r="21"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
className="component-symbol-medium-label"
|
||||
data-symbol-label={label}
|
||||
dominantBaseline="central"
|
||||
fill={SYMBOL_ACCENT}
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize={label === "Air" ? 12 : 16}
|
||||
fontWeight="500"
|
||||
textAnchor="middle"
|
||||
x="32"
|
||||
y="24"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimIdealAirMediumSymbol() {
|
||||
return <MediumSymbol label="Air" />;
|
||||
}
|
||||
|
||||
export function AmesimHeliumMediumSymbol() {
|
||||
return <MediumSymbol label="He" />;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
SYMBOL_ACCENT,
|
||||
SYMBOL_FILL,
|
||||
SYMBOL_MUTED,
|
||||
SYMBOL_STROKE,
|
||||
SymbolSvg,
|
||||
} from "./primitives";
|
||||
|
||||
const PNEUMATIC_STROKE_WIDTH = 1.7;
|
||||
|
||||
function OrificeBody({
|
||||
centerY = 25,
|
||||
leftPortY = centerY,
|
||||
}: {
|
||||
centerY?: number;
|
||||
leftPortY?: number;
|
||||
}) {
|
||||
const top = centerY - 10;
|
||||
const bottom = centerY + 10;
|
||||
const leftConnector =
|
||||
leftPortY === centerY
|
||||
? `M2 ${centerY}h18`
|
||||
: `M2 ${leftPortY}h14L20 ${centerY}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={`${leftConnector}M44 ${centerY}h18M20 ${centerY}h24`}
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||
/>
|
||||
<path
|
||||
d={`M20 ${top}c5 6 9 7 12 7s7-1 12-7M20 ${bottom}c5-6 9-7 12-7s7 1 12 7`}
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Constant-coefficient pneumatic orifice (AMESim PNOR001). */
|
||||
export function AmesimPnor001Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pnor001-svg" domain="pneumatic">
|
||||
<OrificeBody centerY={24} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** PNVO001 convenience variant whose opening is fixed by a parameter. */
|
||||
export function AmesimPnvo001FixedSymbol() {
|
||||
return (
|
||||
<SymbolSvg
|
||||
className="component-symbol-amesim-pnvo001-fixed-svg"
|
||||
domain="pneumatic"
|
||||
>
|
||||
<OrificeBody centerY={28} />
|
||||
<path
|
||||
d="M43 4 34 20m9-16-2 7-5-3"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Signal-modulated pneumatic orifice (AMESim PNVO001 / pn_morifice). */
|
||||
export function AmesimPnvo001SignalSymbol() {
|
||||
return (
|
||||
<SymbolSvg
|
||||
className="component-symbol-amesim-pnvo001-signal-svg"
|
||||
domain="pneumatic"
|
||||
>
|
||||
<OrificeBody centerY={25} leftPortY={32} />
|
||||
<path
|
||||
d="M2 16h11l21 5m0 0-7-1m7 1-5 5"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
type PipeSection = "C" | "R";
|
||||
|
||||
function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
|
||||
const left = 9;
|
||||
const right = 55;
|
||||
const top = 13;
|
||||
const bottom = 35;
|
||||
const sectionWidth = (right - left) / sections.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M2 24h7M55 24h7"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||
/>
|
||||
<rect
|
||||
fill={SYMBOL_FILL}
|
||||
height={bottom - top}
|
||||
rx="2"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||
width={right - left}
|
||||
x={left}
|
||||
y={top}
|
||||
/>
|
||||
{sections.slice(1).map((_, index) => {
|
||||
const x = left + sectionWidth * (index + 1);
|
||||
return (
|
||||
<path
|
||||
d={`M${x} ${top + 2}v${bottom - top - 4}`}
|
||||
key={x}
|
||||
stroke={SYMBOL_MUTED}
|
||||
strokeWidth="1.1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{sections.map((section, index) => (
|
||||
<text
|
||||
dominantBaseline="central"
|
||||
fill={section === "R" ? SYMBOL_ACCENT : SYMBOL_MUTED}
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize="9"
|
||||
fontWeight="700"
|
||||
key={`${section}-${index}`}
|
||||
textAnchor="middle"
|
||||
x={left + sectionWidth * (index + 0.5)}
|
||||
y="24"
|
||||
>
|
||||
{section}
|
||||
</text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure pneumatic resistance line (AMESim PNL00R). */
|
||||
export function AmesimPnl00rSymbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pnl00r-svg" domain="pneumatic">
|
||||
<PipeNetworkBody sections={["R"]} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** C-R dynamic pneumatic line (AMESim PNL0001). */
|
||||
export function AmesimPnl0001Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pnl0001-svg" domain="pneumatic">
|
||||
<PipeNetworkBody sections={["C", "R"]} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** R-C-R dynamic pneumatic line (AMESim PNL0002). */
|
||||
export function AmesimPnl0002Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pnl0002-svg" domain="pneumatic">
|
||||
<PipeNetworkBody sections={["R", "C", "R"]} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** C-R-C dynamic pneumatic line (AMESim PNL0003). */
|
||||
export function AmesimPnl0003Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pnl0003-svg" domain="pneumatic">
|
||||
<PipeNetworkBody sections={["C", "R", "C"]} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
function JunctionTerminal({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
fill={SYMBOL_FILL}
|
||||
r="2"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */
|
||||
export function AmesimPn3Node2Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pn3node2-svg" domain="pneumatic">
|
||||
<path
|
||||
d="M4 24h25M29 24C41 24 42 12 60 12M29 24c12 0 13 12 31 12"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||
/>
|
||||
<circle cx="29" cy="24" fill={SYMBOL_ACCENT} r="3" />
|
||||
<JunctionTerminal x={3} y={24} />
|
||||
<JunctionTerminal x={61} y={12} />
|
||||
<JunctionTerminal x={61} y={36} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Four-port pneumatic junction with one inlet-side and three outlet-side ports. */
|
||||
export function AmesimP4Node2Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-p4node2-svg" domain="pneumatic">
|
||||
<path
|
||||
d="M4 24h24M28 24C40 24 42 8 60 8M28 24h32M28 24c12 0 14 16 32 16"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||
/>
|
||||
<circle cx="28" cy="24" fill={SYMBOL_ACCENT} r="3" />
|
||||
<JunctionTerminal x={3} y={24} />
|
||||
<JunctionTerminal x={61} y={8} />
|
||||
<JunctionTerminal x={61} y={24} />
|
||||
<JunctionTerminal x={61} y={40} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SymbolDomain =
|
||||
| "neutral"
|
||||
| "pneumatic"
|
||||
| "mechanical"
|
||||
| "signal"
|
||||
| "medium";
|
||||
|
||||
type SymbolSvgProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
domain?: SymbolDomain;
|
||||
viewBox?: string;
|
||||
};
|
||||
|
||||
export const SYMBOL_STROKE = "var(--component-symbol-stroke, currentColor)";
|
||||
export const SYMBOL_FILL = "var(--component-symbol-fill, #ffffff)";
|
||||
export const SYMBOL_ACCENT = "var(--component-symbol-accent, currentColor)";
|
||||
export const SYMBOL_MUTED = "var(--component-symbol-muted, #64748b)";
|
||||
|
||||
export function SymbolSvg({
|
||||
children,
|
||||
className,
|
||||
domain = "neutral",
|
||||
viewBox = "0 0 64 48",
|
||||
}: SymbolSvgProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={["component-symbol-svg", className].filter(Boolean).join(" ")}
|
||||
data-symbol-domain={domain}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
viewBox={viewBox}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
SYMBOL_ACCENT,
|
||||
SYMBOL_FILL,
|
||||
SYMBOL_STROKE,
|
||||
SymbolSvg,
|
||||
} from "./primitives";
|
||||
|
||||
const SIGNAL_LINE_WIDTH = 1.8;
|
||||
|
||||
function SignalOutput() {
|
||||
return (
|
||||
<g
|
||||
fill="none"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={SIGNAL_LINE_WIDTH}
|
||||
>
|
||||
<path d="M46 24h14" />
|
||||
<path d="m54 20 6 4-6 4" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimStep0Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-step0" domain="signal">
|
||||
<circle
|
||||
cx="27"
|
||||
cy="24"
|
||||
fill={SYMBOL_FILL}
|
||||
r="18"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth={SIGNAL_LINE_WIDTH}
|
||||
/>
|
||||
<path
|
||||
d="M14 30h8V17h18"
|
||||
fill="none"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
strokeWidth="2.2"
|
||||
/>
|
||||
<SignalOutput />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimUd00Symbol() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-ud00" domain="signal">
|
||||
<circle
|
||||
cx="27"
|
||||
cy="24"
|
||||
fill={SYMBOL_FILL}
|
||||
r="18"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth={SIGNAL_LINE_WIDTH}
|
||||
/>
|
||||
<path
|
||||
d="M13 30h5l5-10h5l5-9 5 19h3"
|
||||
fill="none"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.2"
|
||||
/>
|
||||
<SignalOutput />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
SYMBOL_ACCENT,
|
||||
SYMBOL_FILL,
|
||||
SYMBOL_STROKE,
|
||||
SymbolSvg,
|
||||
} from "./primitives";
|
||||
|
||||
function ChamberCore({ variable }: { variable: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="24"
|
||||
fill={SYMBOL_FILL}
|
||||
r="13"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="central"
|
||||
fill={SYMBOL_ACCENT}
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize="13"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
x="32"
|
||||
y="24"
|
||||
>
|
||||
Cp
|
||||
</text>
|
||||
{variable ? (
|
||||
<g
|
||||
fill="none"
|
||||
stroke={SYMBOL_ACCENT}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="m20 13-5-5m0 0h4m-4 0v4" />
|
||||
<path d="m44 13 5-5m0 0h-4m4 0v4" />
|
||||
<path d="m20 35-5 5m0 0h4m-4 0v-4" />
|
||||
<path d="m44 35 5 5m0 0h-4m4 0v-4" />
|
||||
</g>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimPnch023Symbol() {
|
||||
return (
|
||||
<SymbolSvg domain="pneumatic">
|
||||
<path d="M2 24h17M45 24h17" stroke={SYMBOL_STROKE} strokeWidth="2" />
|
||||
<ChamberCore variable={false} />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmesimPnch012Symbol() {
|
||||
return (
|
||||
<SymbolSvg domain="pneumatic">
|
||||
<path
|
||||
d="M2 16h17M2 32h17M45 16h17M45 32h17"
|
||||
stroke={SYMBOL_STROKE}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<ChamberCore variable />
|
||||
</SymbolSvg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type ComponentSymbolPresentation = "framed" | "bare";
|
||||
|
||||
export type SymbolDefinition = {
|
||||
presentation: ComponentSymbolPresentation;
|
||||
render: () => ReactNode;
|
||||
};
|
||||
@@ -0,0 +1,458 @@
|
||||
export type ParameterExpressionEvaluation =
|
||||
| { ok: true; value: number }
|
||||
| { ok: false; message: string };
|
||||
|
||||
const MAX_INPUT_LENGTH = 512;
|
||||
const MAX_TOKEN_COUNT = 256;
|
||||
const MAX_OPERATION_COUNT = 256;
|
||||
const MAX_NESTING_DEPTH = 32;
|
||||
const MAX_FUNCTION_ARGUMENTS = 16;
|
||||
|
||||
const NUMERIC_LITERAL_PATTERN =
|
||||
/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
||||
const UNSIGNED_NUMBER_PREFIX =
|
||||
/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
|
||||
|
||||
type TokenKind =
|
||||
| "number"
|
||||
| "identifier"
|
||||
| "operator"
|
||||
| "leftParenthesis"
|
||||
| "rightParenthesis"
|
||||
| "comma"
|
||||
| "end";
|
||||
|
||||
type Token = {
|
||||
kind: TokenKind;
|
||||
text: string;
|
||||
position: number;
|
||||
value?: number;
|
||||
};
|
||||
|
||||
class ParameterExpressionError extends Error {}
|
||||
|
||||
/**
|
||||
* Distinguishes a stored equation from an ordinary numeric parameter value.
|
||||
* Invalid non-numeric strings intentionally count as expressions so callers
|
||||
* can run them through the evaluator and present its specific error message.
|
||||
*/
|
||||
export function isParameterExpressionValue(value: number | string): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.startsWith("=") || !NUMERIC_LITERAL_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely evaluates the arithmetic subset accepted by AMESim-style parameter
|
||||
* fields. This parser never executes JavaScript and cannot access globals,
|
||||
* object properties, or arbitrary functions.
|
||||
*/
|
||||
export function evaluateParameterExpression(
|
||||
input: string,
|
||||
): ParameterExpressionEvaluation {
|
||||
try {
|
||||
let source = input.trim();
|
||||
if (source.startsWith("=")) {
|
||||
source = source.slice(1).trim();
|
||||
}
|
||||
if (!source) {
|
||||
throw new ParameterExpressionError("表达式不能为空");
|
||||
}
|
||||
if (source.length > MAX_INPUT_LENGTH) {
|
||||
throw new ParameterExpressionError(
|
||||
`表达式长度不能超过 ${MAX_INPUT_LENGTH} 个字符`,
|
||||
);
|
||||
}
|
||||
|
||||
const parser = new ParameterExpressionParser(tokenize(source));
|
||||
return { ok: true, value: parser.parse() };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
error instanceof ParameterExpressionError
|
||||
? error.message
|
||||
: "表达式无法计算",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let position = 0;
|
||||
|
||||
const pushToken = (token: Token) => {
|
||||
tokens.push(token);
|
||||
if (tokens.length > MAX_TOKEN_COUNT) {
|
||||
throw new ParameterExpressionError(
|
||||
`表达式过于复杂,最多允许 ${MAX_TOKEN_COUNT} 个词元`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
while (position < source.length) {
|
||||
const character = source[position];
|
||||
if (/\s/.test(character)) {
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\d/.test(character) || (character === "." && /\d/.test(source[position + 1] ?? ""))) {
|
||||
const match = UNSIGNED_NUMBER_PREFIX.exec(source.slice(position));
|
||||
if (!match) {
|
||||
throw new ParameterExpressionError(
|
||||
`第 ${position + 1} 个字符附近的数字格式无效`,
|
||||
);
|
||||
}
|
||||
const text = match[0];
|
||||
const value = Number(text);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new ParameterExpressionError(`数字“${text}”不是有限数值`);
|
||||
}
|
||||
pushToken({ kind: "number", text, position, value });
|
||||
position += text.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[A-Za-z_]/.test(character)) {
|
||||
let end = position + 1;
|
||||
while (end < source.length && /[A-Za-z0-9_]/.test(source[end])) {
|
||||
end += 1;
|
||||
}
|
||||
const text = source.slice(position, end);
|
||||
pushToken({ kind: "identifier", text, position });
|
||||
position = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "*" && source[position + 1] === "*") {
|
||||
pushToken({ kind: "operator", text: "**", position });
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
if (["+", "-", "*", "/", "^"].includes(character)) {
|
||||
pushToken({ kind: "operator", text: character, position });
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "(") {
|
||||
pushToken({ kind: "leftParenthesis", text: character, position });
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === ")") {
|
||||
pushToken({ kind: "rightParenthesis", text: character, position });
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === ",") {
|
||||
pushToken({ kind: "comma", text: character, position });
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ParameterExpressionError(
|
||||
`第 ${position + 1} 个字符包含不支持的符号“${character}”`,
|
||||
);
|
||||
}
|
||||
|
||||
tokens.push({ kind: "end", text: "", position: source.length });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
class ParameterExpressionParser {
|
||||
private index = 0;
|
||||
private operationCount = 0;
|
||||
private readonly tokens: readonly Token[];
|
||||
|
||||
constructor(tokens: readonly Token[]) {
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
parse() {
|
||||
const value = this.parseAdditive(0);
|
||||
const trailing = this.current();
|
||||
if (trailing.kind !== "end") {
|
||||
throw new ParameterExpressionError(
|
||||
`第 ${trailing.position + 1} 个字符附近存在多余内容“${trailing.text}”`,
|
||||
);
|
||||
}
|
||||
return ensureFinite(value, "表达式的计算结果");
|
||||
}
|
||||
|
||||
private parseAdditive(depth: number): number {
|
||||
let value = this.parseMultiplicative(depth);
|
||||
while (this.isOperator("+") || this.isOperator("-")) {
|
||||
const operator = this.advance().text;
|
||||
const right = this.parseMultiplicative(depth);
|
||||
this.countOperation();
|
||||
value = ensureFinite(
|
||||
operator === "+" ? value + right : value - right,
|
||||
`运算“${operator}”的结果`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseMultiplicative(depth: number): number {
|
||||
let value = this.parseUnary(depth);
|
||||
while (this.isOperator("*") || this.isOperator("/")) {
|
||||
const operator = this.advance().text;
|
||||
const right = this.parseUnary(depth);
|
||||
this.countOperation();
|
||||
if (operator === "/" && right === 0) {
|
||||
throw new ParameterExpressionError("除数不能为 0");
|
||||
}
|
||||
value = ensureFinite(
|
||||
operator === "*" ? value * right : value / right,
|
||||
`运算“${operator}”的结果`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseUnary(depth: number): number {
|
||||
this.assertDepth(depth);
|
||||
if (this.isOperator("+") || this.isOperator("-")) {
|
||||
const operator = this.advance().text;
|
||||
this.countOperation();
|
||||
const operand = this.parseUnary(depth + 1);
|
||||
return ensureFinite(
|
||||
operator === "+" ? operand : -operand,
|
||||
`一元运算“${operator}”的结果`,
|
||||
);
|
||||
}
|
||||
return this.parsePower(depth);
|
||||
}
|
||||
|
||||
private parsePower(depth: number): number {
|
||||
this.assertDepth(depth);
|
||||
const base = this.parsePrimary(depth);
|
||||
if (!this.isOperator("^") && !this.isOperator("**")) {
|
||||
return base;
|
||||
}
|
||||
const operator = this.advance().text;
|
||||
const exponent = this.parseUnary(depth + 1);
|
||||
this.countOperation();
|
||||
return ensureFinite(Math.pow(base, exponent), `运算“${operator}”的结果`);
|
||||
}
|
||||
|
||||
private parsePrimary(depth: number): number {
|
||||
this.assertDepth(depth);
|
||||
const token = this.current();
|
||||
if (token.kind === "number") {
|
||||
this.advance();
|
||||
return ensureFinite(token.value ?? Number.NaN, `数字“${token.text}”`);
|
||||
}
|
||||
|
||||
if (token.kind === "identifier") {
|
||||
this.advance();
|
||||
const name = token.text.toLowerCase();
|
||||
if (this.current().kind === "leftParenthesis") {
|
||||
return this.parseFunctionCall(name, token.text, depth + 1);
|
||||
}
|
||||
if (name === "pi") {
|
||||
return Math.PI;
|
||||
}
|
||||
if (name === "e") {
|
||||
return Math.E;
|
||||
}
|
||||
throw new ParameterExpressionError(`未知标识符“${token.text}”`);
|
||||
}
|
||||
|
||||
if (token.kind === "leftParenthesis") {
|
||||
this.advance();
|
||||
const value = this.parseAdditive(depth + 1);
|
||||
this.expect("rightParenthesis", "缺少右括号“)”");
|
||||
return value;
|
||||
}
|
||||
|
||||
if (token.kind === "end") {
|
||||
throw new ParameterExpressionError("表达式结尾缺少数字、常量或函数");
|
||||
}
|
||||
throw new ParameterExpressionError(
|
||||
`第 ${token.position + 1} 个字符附近需要数字、常量或函数`,
|
||||
);
|
||||
}
|
||||
|
||||
private parseFunctionCall(
|
||||
normalizedName: string,
|
||||
sourceName: string,
|
||||
depth: number,
|
||||
): number {
|
||||
this.assertDepth(depth);
|
||||
this.expect("leftParenthesis", `函数 ${sourceName} 后缺少左括号“(”`);
|
||||
const arguments_: number[] = [];
|
||||
if (this.current().kind !== "rightParenthesis") {
|
||||
while (true) {
|
||||
if (arguments_.length >= MAX_FUNCTION_ARGUMENTS) {
|
||||
throw new ParameterExpressionError(
|
||||
`函数 ${sourceName} 最多接受 ${MAX_FUNCTION_ARGUMENTS} 个参数`,
|
||||
);
|
||||
}
|
||||
arguments_.push(this.parseAdditive(depth));
|
||||
if (this.current().kind !== "comma") {
|
||||
break;
|
||||
}
|
||||
this.advance();
|
||||
if (this.current().kind === "rightParenthesis") {
|
||||
throw new ParameterExpressionError(`函数 ${sourceName} 的逗号后缺少参数`);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.expect("rightParenthesis", `函数 ${sourceName} 缺少右括号“)”`);
|
||||
this.countOperation();
|
||||
return evaluateFunction(normalizedName, sourceName, arguments_);
|
||||
}
|
||||
|
||||
private current() {
|
||||
return this.tokens[this.index] ?? this.tokens[this.tokens.length - 1];
|
||||
}
|
||||
|
||||
private advance() {
|
||||
const token = this.current();
|
||||
if (token.kind !== "end") {
|
||||
this.index += 1;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
private expect(kind: TokenKind, message: string) {
|
||||
if (this.current().kind !== kind) {
|
||||
throw new ParameterExpressionError(message);
|
||||
}
|
||||
return this.advance();
|
||||
}
|
||||
|
||||
private isOperator(operator: string) {
|
||||
const token = this.current();
|
||||
return token.kind === "operator" && token.text === operator;
|
||||
}
|
||||
|
||||
private assertDepth(depth: number) {
|
||||
if (depth > MAX_NESTING_DEPTH) {
|
||||
throw new ParameterExpressionError(
|
||||
`表达式嵌套层级不能超过 ${MAX_NESTING_DEPTH} 层`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private countOperation() {
|
||||
this.operationCount += 1;
|
||||
if (this.operationCount > MAX_OPERATION_COUNT) {
|
||||
throw new ParameterExpressionError(
|
||||
`表达式过于复杂,最多允许 ${MAX_OPERATION_COUNT} 次运算`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateFunction(
|
||||
normalizedName: string,
|
||||
sourceName: string,
|
||||
arguments_: readonly number[],
|
||||
) {
|
||||
const requireCount = (expected: number) => {
|
||||
if (arguments_.length !== expected) {
|
||||
throw new ParameterExpressionError(
|
||||
`函数 ${sourceName} 需要 ${expected} 个参数,当前提供了 ${arguments_.length} 个`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const requireAtLeastOne = () => {
|
||||
if (arguments_.length === 0) {
|
||||
throw new ParameterExpressionError(`函数 ${sourceName} 至少需要 1 个参数`);
|
||||
}
|
||||
};
|
||||
|
||||
let value: number;
|
||||
switch (normalizedName) {
|
||||
case "sqrt":
|
||||
requireCount(1);
|
||||
if (arguments_[0] < 0) {
|
||||
throw new ParameterExpressionError("函数 sqrt 的参数不能小于 0");
|
||||
}
|
||||
value = Math.sqrt(arguments_[0]);
|
||||
break;
|
||||
case "abs":
|
||||
requireCount(1);
|
||||
value = Math.abs(arguments_[0]);
|
||||
break;
|
||||
case "sin":
|
||||
requireCount(1);
|
||||
value = Math.sin(arguments_[0]);
|
||||
break;
|
||||
case "cos":
|
||||
requireCount(1);
|
||||
value = Math.cos(arguments_[0]);
|
||||
break;
|
||||
case "tan":
|
||||
requireCount(1);
|
||||
value = Math.tan(arguments_[0]);
|
||||
break;
|
||||
case "asin":
|
||||
requireCount(1);
|
||||
if (arguments_[0] < -1 || arguments_[0] > 1) {
|
||||
throw new ParameterExpressionError("函数 asin 的参数必须位于 [-1, 1]");
|
||||
}
|
||||
value = Math.asin(arguments_[0]);
|
||||
break;
|
||||
case "acos":
|
||||
requireCount(1);
|
||||
if (arguments_[0] < -1 || arguments_[0] > 1) {
|
||||
throw new ParameterExpressionError("函数 acos 的参数必须位于 [-1, 1]");
|
||||
}
|
||||
value = Math.acos(arguments_[0]);
|
||||
break;
|
||||
case "atan":
|
||||
requireCount(1);
|
||||
value = Math.atan(arguments_[0]);
|
||||
break;
|
||||
case "exp":
|
||||
requireCount(1);
|
||||
value = Math.exp(arguments_[0]);
|
||||
break;
|
||||
case "ln":
|
||||
case "log":
|
||||
requireCount(1);
|
||||
if (arguments_[0] <= 0) {
|
||||
throw new ParameterExpressionError(`函数 ${sourceName} 的参数必须大于 0`);
|
||||
}
|
||||
value = Math.log(arguments_[0]);
|
||||
break;
|
||||
case "log10":
|
||||
requireCount(1);
|
||||
if (arguments_[0] <= 0) {
|
||||
throw new ParameterExpressionError("函数 log10 的参数必须大于 0");
|
||||
}
|
||||
value = Math.log10(arguments_[0]);
|
||||
break;
|
||||
case "min":
|
||||
requireAtLeastOne();
|
||||
value = Math.min(...arguments_);
|
||||
break;
|
||||
case "max":
|
||||
requireAtLeastOne();
|
||||
value = Math.max(...arguments_);
|
||||
break;
|
||||
case "pow":
|
||||
requireCount(2);
|
||||
value = Math.pow(arguments_[0], arguments_[1]);
|
||||
break;
|
||||
default:
|
||||
throw new ParameterExpressionError(`不支持函数“${sourceName}”`);
|
||||
}
|
||||
return ensureFinite(value, `函数 ${sourceName} 的计算结果`);
|
||||
}
|
||||
|
||||
function ensureFinite(value: number, context: string) {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new ParameterExpressionError(`${context}不是有限数值`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -964,6 +964,42 @@ textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.component-symbol-svg[data-symbol-domain="neutral"] {
|
||||
--component-symbol-stroke: #334155;
|
||||
--component-symbol-fill: #f8fafc;
|
||||
--component-symbol-accent: #2563eb;
|
||||
--component-symbol-muted: #94a3b8;
|
||||
}
|
||||
|
||||
.component-symbol-svg[data-symbol-domain="pneumatic"] {
|
||||
--component-symbol-stroke: #a12b70;
|
||||
--component-symbol-fill: #fff8fc;
|
||||
--component-symbol-accent: #bd2779;
|
||||
--component-symbol-muted: #d69aba;
|
||||
}
|
||||
|
||||
.component-symbol-svg[data-symbol-domain="mechanical"] {
|
||||
--component-symbol-stroke: #1f9d42;
|
||||
--component-symbol-fill: #f4fff6;
|
||||
--component-symbol-accent: #15803d;
|
||||
--component-symbol-muted: #86c994;
|
||||
}
|
||||
|
||||
.component-symbol-svg[data-symbol-domain="signal"] {
|
||||
--component-symbol-stroke: #ef4444;
|
||||
--component-symbol-fill: #fff8f8;
|
||||
--component-symbol-accent: #dc2626;
|
||||
--component-symbol-muted: #f5a3a3;
|
||||
}
|
||||
|
||||
.component-symbol-svg[data-symbol-domain="medium"] {
|
||||
--component-symbol-stroke: #c026d3;
|
||||
--component-symbol-fill: #ffffff;
|
||||
--component-symbol-accent: #a21caf;
|
||||
--component-symbol-muted: #e879f9;
|
||||
}
|
||||
|
||||
.component-symbol-medium-label {
|
||||
|
||||
Reference in new issue
Block a user