完善AMESim组件界面与仿真求解稳定性

This commit is contained in:
ljz committed 2026-08-02 00:57:48 +08:00
1 parent e7177ab03e
commit 410ef535e8
34 files changed
+3340 -251

No files matched your search

+316 -47
View File
@@ -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);
+59 -52
View File
@@ -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>;
+10 -2
View File
@@ -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>
);
}
+38
View File
@@ -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" />;
}
+233
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
export type ComponentSymbolPresentation = "framed" | "bare";
export type SymbolDefinition = {
presentation: ComponentSymbolPresentation;
render: () => ReactNode;
};
+458
View File
@@ -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;
}
+36
View File
@@ -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 {