diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d40877..4e1794b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,10 @@ cmake_minimum_required(VERSION 3.21) -project(idef0_editor LANGUAGES CXX) +project(erlu_idef0_editor LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(Qt6 REQUIRED COMPONENTS Widgets Svg) qt_standard_project_setup() @@ -21,19 +21,25 @@ qt_add_library(idef0_core SHARED option(BUILD_COLORS_PLUGIN "Build the Colors plugin" ON) -qt_add_executable(idef0_editor +qt_add_executable(erlu_idef0_editor src/main.cpp ) +qt_add_resources(erlu_idef0_editor "app_icons" + PREFIX "/icons" + BASE assets/icons + FILES assets/icons/erlu.svg +) + if(APPLE) - set(MACOS_BUNDLE_ID "com.gregorybednov.idef0-editor") - set(MACOS_BUNDLE_NAME "IDEF0 Editor") + set(MACOS_BUNDLE_ID "com.gregorybednov.erlu-idef0-editor") + set(MACOS_BUNDLE_NAME "erlu IDEF0 editor") configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in ${CMAKE_CURRENT_BINARY_DIR}/Info.plist @ONLY ) - set_target_properties(idef0_editor PROPERTIES + set_target_properties(erlu_idef0_editor PROPERTIES MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME "${MACOS_BUNDLE_NAME}" MACOSX_BUNDLE_GUI_IDENTIFIER "${MACOS_BUNDLE_ID}" @@ -41,13 +47,13 @@ if(APPLE) ) endif() -target_compile_definitions(idef0_editor PRIVATE $<$>:QT_NO_DEBUG_OUTPUT>) +target_compile_definitions(erlu_idef0_editor PRIVATE $<$>:QT_NO_DEBUG_OUTPUT>) target_include_directories(idef0_core PRIVATE src) -target_include_directories(idef0_editor PRIVATE src) +target_include_directories(erlu_idef0_editor PRIVATE src) target_link_libraries(idef0_core PRIVATE Qt6::Widgets) -target_link_libraries(idef0_editor PRIVATE Qt6::Widgets idef0_core) +target_link_libraries(erlu_idef0_editor PRIVATE Qt6::Widgets Qt6::Svg idef0_core) set_target_properties(idef0_core PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" @@ -69,11 +75,11 @@ if(BUILD_COLORS_PLUGIN) ) endif() -set_target_properties(idef0_editor PROPERTIES +set_target_properties(erlu_idef0_editor PROPERTIES INSTALL_RPATH "\$ORIGIN/../lib" ) -install(TARGETS idef0_editor +install(TARGETS erlu_idef0_editor BUNDLE DESTINATION . RUNTIME DESTINATION bin ) @@ -90,8 +96,10 @@ if(BUILD_COLORS_PLUGIN) endif() if(UNIX AND NOT APPLE) - install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/packaging/linux/idef0-editor.desktop + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/packaging/linux/erlu-idef0-editor.desktop DESTINATION share/applications) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/packaging/linux/idef0.xml DESTINATION share/mime/packages) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/erlu.svg + DESTINATION share/icons/hicolor/scalable/apps) endif() diff --git a/default.nix b/default.nix index ff3acd5..6561cdd 100644 --- a/default.nix +++ b/default.nix @@ -7,7 +7,7 @@ let qt = pkgs.qt6; in stdenv.mkDerivation rec { - pname = "idef0_editor"; + pname = "erlu_idef0_editor"; version = "0.1.0"; src = lib.cleanSourceWith { @@ -68,7 +68,7 @@ stdenv.mkDerivation rec { meta = with lib; { description = "IDEF0 diagram editor built with Qt 6 Widgets"; license = licenses.lgpl3Plus; - mainProgram = "idef0_editor"; + mainProgram = "erlu_idef0_editor"; platforms = platforms.linux; }; } diff --git a/packaging/linux/idef0-editor.desktop b/packaging/linux/idef0-editor.desktop deleted file mode 100644 index dd53292..0000000 --- a/packaging/linux/idef0-editor.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Type=Application -Name=IDEF0 Editor -Comment=IDEF0 diagram editor -Exec=idef0_editor %f -Terminal=false -Categories=Office;Graphics; -MimeType=application/x-idef0+json; -StartupNotify=true diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index e3fc4b3..a30f05f 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,7 @@ static const char* kDiagramFileFilter = QT_TR_NOOP("IDEF0 Diagram (*.idef0);;JSON Diagram (*.json)"); static const char* kPdfFileFilter = QT_TR_NOOP("PDF (*.pdf)"); +static const char* kMarkdownFileFilter = QT_TR_NOOP("Markdown (*.md)"); static QPageSize::PageSizeId pageSizeFromMeta(const QVariantMap& meta) { const QString size = meta.value("pageSize", "A4").toString(); @@ -78,6 +80,13 @@ static QVariantMap stripPerDiagramState(QVariantMap meta) { return meta; } +static QString normalizeCurrencyName(const QString& currency) { + if (currency == QCoreApplication::translate("MainWindow", "man-hours") || currency == QStringLiteral("man-hours")) { + return QCoreApplication::translate("MainWindow", "person-hours"); + } + return currency; +} + static QVariantMap mergeWithDiagramState(QVariantMap globalMeta, const QVariantMap& diagramMeta) { for (const char* key : {"state", "working", "draft", "recommended", "publication"}) { const QString k = QString::fromLatin1(key); @@ -132,6 +141,7 @@ void MainWindow::setupActions() { auto* actSave = new QAction(tr("Save"), this); auto* actSaveAs = new QAction(tr("Save As…"), this); auto* actExportPdf = new QAction(tr("Export to PDF…"), this); + auto* actExportMarkdown = new QAction(tr("Export to Markdown…"), this); actNew->setShortcut(QKeySequence::New); actOpen->setShortcut(QKeySequence::Open); actSave->setShortcut(QKeySequence::Save); @@ -143,12 +153,18 @@ void MainWindow::setupActions() { fileMenu->addAction(actSaveAs); fileMenu->addSeparator(); fileMenu->addAction(actExportPdf); + fileMenu->addAction(actExportMarkdown); auto* editMenu = menuBar()->addMenu(tr("&Edit")); + auto* toolsMenu = menuBar()->addMenu(tr("&Tools")); auto* actSwapNums = new QAction(tr("Swap numbers…"), this); auto* actCallMech = new QAction(tr("Call Mechanism add"), this); + auto* actValidation = new QAction(tr("Validation"), this); + auto* actCalcPrices = new QAction(tr("Calculate Prices"), this); editMenu->addAction(actSwapNums); editMenu->addAction(actCallMech); + toolsMenu->addAction(actValidation); + toolsMenu->addAction(actCalcPrices); auto* viewMenu = menuBar()->addMenu(tr("&View")); auto* actFit = new QAction(tr("Fit"), this); @@ -177,7 +193,7 @@ void MainWindow::setupActions() { connect(actNodeTree, &QAction::triggered, this, [this]{ showNodeTreeDialog(); }); - viewMenu->addAction(actNodeTree); + toolsMenu->addAction(actNodeTree); m_actDarkMode = new QAction(tr("Dark mode"), this); m_actDarkMode->setCheckable(true); @@ -309,6 +325,9 @@ void MainWindow::setupActions() { if (dlg.exec() != QDialog::Accepted) return; exportPdf(scope->currentIndex() == 1, pageNumbers->isChecked(), forceLight->isChecked()); }); + connect(actExportMarkdown, &QAction::triggered, this, [this]{ + exportMarkdownExplanation(); + }); connect(actCallMech, &QAction::triggered, this, [this]{ if (!m_scene) return; @@ -360,6 +379,13 @@ void MainWindow::setupActions() { statusBar()->showMessage(tr("Click bottom frame to place call mechanism arrow. Esc to cancel."), 5000); }); + connect(actValidation, &QAction::triggered, this, [this]{ + runValidation(); + }); + connect(actCalcPrices, &QAction::triggered, this, [this]{ + calculatePrices(); + }); + m_pluginManager = new PluginManager(this, pluginsMenu, this); m_pluginManager->loadPlugins(); } @@ -445,7 +471,8 @@ bool MainWindow::showWelcome() { currency->setEditable(true); currency->addItem(curSym); if (curSym != "$") currency->addItem("$"); - const QString existingCur = m_welcomeState.value("currency").toString(); + currency->addItem(tr("person-hours")); + const QString existingCur = normalizeCurrencyName(m_welcomeState.value("currency").toString()); if (!existingCur.isEmpty()) { currency->setCurrentText(existingCur); } else { @@ -454,12 +481,18 @@ bool MainWindow::showWelcome() { auto* placement = new QComboBox(units); placement->addItems({"1?", "?1", "1 ?", "? 1"}); + const QString personHours = tr("person-hours"); const QString placementVal = m_welcomeState.value("currencyPlacement").toString(); if (!placementVal.isEmpty() && placement->findText(placementVal) >= 0) { placement->setCurrentText(placementVal); } else { - placement->setCurrentText(symbolPlacementDefault(loc, currency->currentText())); + placement->setCurrentText(currency->currentText() == personHours + ? QStringLiteral("1 ?") + : symbolPlacementDefault(loc, currency->currentText())); } + connect(currency, &QComboBox::currentTextChanged, this, [placement, personHours](const QString& cur){ + if (cur == personHours) placement->setCurrentText(QStringLiteral("1 ?")); + }); auto* timeUnit = new QComboBox(units); const QStringList unitsList = {tr("Seconds"), tr("Minutes"), tr("Hours"), tr("Days"), tr("Weeks"), tr("Months"), tr("Years")}; @@ -517,7 +550,7 @@ bool MainWindow::showWelcome() { m_welcomeState["title"] = title->text(); m_welcomeState["organization"] = organization->text(); m_welcomeState["initials"] = initials->text(); - m_welcomeState["currency"] = currency->currentText(); + m_welcomeState["currency"] = normalizeCurrencyName(currency->currentText()); m_welcomeState["currencyPlacement"] = placement->currentText(); m_welcomeState["timeUnit"] = timeUnit->currentText(); m_welcomeState["pageSize"] = sizeCombo->currentText(); @@ -549,6 +582,13 @@ void MainWindow::newDiagram() { m_scene->createBlockAt(bounds.center()); m_currentFile.clear(); markDirty(false); + // Keep main window visible and focused after closing modal creation dialogs. + QTimer::singleShot(0, this, [this]{ + setWindowState(windowState() & ~Qt::WindowMinimized); + showNormal(); + raise(); + activateWindow(); + }); } bool MainWindow::promptStartup() { @@ -571,6 +611,12 @@ bool MainWindow::promptStartup() { m_scene->createBlockAt(bounds.center()); m_currentFile.clear(); markDirty(false); + QTimer::singleShot(0, this, [this]{ + setWindowState(windowState() & ~Qt::WindowMinimized); + showNormal(); + raise(); + activateWindow(); + }); return true; } @@ -595,6 +641,7 @@ bool MainWindow::loadDiagramFromPath(const QString& path) { } if (root.contains("meta")) { m_welcomeState = stripPerDiagramState(root.value("meta").toMap()); + m_welcomeState["currency"] = normalizeCurrencyName(m_welcomeState.value("currency").toString()); } if (m_actDarkMode && m_actFollowSystemTheme) { const QSignalBlocker b2(m_actDarkMode); @@ -716,6 +763,137 @@ bool MainWindow::exportPdf(bool allDiagrams, bool numberPages, bool forceLightTh return true; } +bool MainWindow::exportMarkdownExplanation() { + if (!m_scene) return false; + QString path = QFileDialog::getSaveFileName(this, tr("Export to Markdown"), QString(), tr(kMarkdownFileFilter)); + if (path.isEmpty()) return false; + if (QFileInfo(path).suffix().isEmpty()) path += ".md"; + + const QVariantMap exported = m_scene->exportToVariant(); + const QVariantMap rootDiagram = exported.value("diagram").toMap(); + const QVariantMap children = exported.value("children").toMap(); + + auto sortedBlocks = [](const QVariantMap& diagram) { + QVector out; + const QVariantList blocks = diagram.value("blocks").toList(); + out.reserve(blocks.size()); + for (const QVariant& vb : blocks) out.push_back(vb.toMap()); + std::sort(out.begin(), out.end(), [](const QVariantMap& a, const QVariantMap& b) { + const QString na = a.value("number").toString(); + const QString nb = b.value("number").toString(); + if (na == nb) return a.value("id", -1).toInt() < b.value("id", -1).toInt(); + return na.localeAwareCompare(nb) < 0; + }); + return out; + }; + + auto addUniqueSorted = [](QStringList& list, const QString& value) { + const QString v = value.trimmed(); + if (v.isEmpty() || list.contains(v)) return; + list.push_back(v); + }; + + auto joinOrDash = [this](QStringList list) { + if (list.isEmpty()) return tr("none"); + std::sort(list.begin(), list.end(), [](const QString& a, const QString& b) { + return a.localeAwareCompare(b) < 0; + }); + return list.join(", "); + }; + + struct Ports { + QStringList inputs; + QStringList outputs; + QStringList mechanisms; + QStringList controls; + }; + auto collectPorts = [&](const QVariantMap& diagram) { + Ports p; + const QVariantList arrows = diagram.value("arrows").toList(); + for (const QVariant& va : arrows) { + const QVariantMap a = va.toMap(); + const QString label = a.value("label").toString().trimmed(); + if (label.isEmpty()) continue; + const QVariantMap from = a.value("from").toMap(); + const QVariantMap to = a.value("to").toMap(); + const int fk = from.value("kind").toInt(0); + const int fp = from.value("port").toInt(0); + const int tk = to.value("kind").toInt(0); + const int tp = to.value("port").toInt(0); + if (fk == 3) { + if (fp == 0) addUniqueSorted(p.inputs, label); + if (fp == 1) addUniqueSorted(p.controls, label); + if (fp == 2) addUniqueSorted(p.outputs, label); + if (fp == 3) addUniqueSorted(p.mechanisms, label); + } + if (tk == 3) { + if (tp == 0) addUniqueSorted(p.inputs, label); + if (tp == 1) addUniqueSorted(p.controls, label); + if (tp == 2) addUniqueSorted(p.outputs, label); + if (tp == 3) addUniqueSorted(p.mechanisms, label); + } + } + return p; + }; + + auto stars = [](int count) { return QString(count, '*'); }; + auto safeName = [this](const QString& s) { + const QString trimmed = s.trimmed(); + return trimmed.isEmpty() ? tr("Untitled") : trimmed; + }; + + QString md; + std::function emitNode; + emitNode = [&](const QVariantMap& diagram, const QString& key, const QString& number, int depth, const QString& title) { + const Ports p = collectPorts(diagram); + md += QStringLiteral("%1 %2: %3\n").arg(stars(depth), number, safeName(title)); + md += QStringLiteral("%1 Inputs: %2\n").arg(stars(depth + 1), joinOrDash(p.inputs)); + md += QStringLiteral("%1 Outputs: %2\n").arg(stars(depth + 1), joinOrDash(p.outputs)); + md += QStringLiteral("%1 Mechanisms: %2\n").arg(stars(depth + 1), joinOrDash(p.mechanisms)); + md += QStringLiteral("%1 Controls: %2\n").arg(stars(depth + 1), joinOrDash(p.controls)); + md += QStringLiteral("%1 Subprocesses:\n").arg(stars(depth + 1)); + + const QVector blocks = sortedBlocks(diagram); + if (blocks.isEmpty()) { + md += QStringLiteral("%1 %2\n").arg(stars(depth + 2), tr("none")); + return; + } + + for (const QVariantMap& b : blocks) { + const int id = b.value("id", -1).toInt(); + const QString childKey = key.isEmpty() ? QString::number(id) : (key + "/" + QString::number(id)); + const QString childNumber = b.value("number").toString().trimmed().isEmpty() + ? QStringLiteral("A?") + : b.value("number").toString().trimmed(); + const QString childTitle = safeName(b.value("title").toString()); + if (id >= 0 && children.contains(childKey)) { + emitNode(children.value(childKey).toMap(), childKey, childNumber, depth + 2, childTitle); + } else { + md += QStringLiteral("%1 %2: %3\n").arg(stars(depth + 2), childNumber, childTitle); + } + } + }; + + QString rootTitle = m_welcomeState.value("title").toString(); + for (const QVariantMap& b : sortedBlocks(rootDiagram)) { + const QString n = b.value("number").toString(); + if (n.compare(QStringLiteral("A0"), Qt::CaseInsensitive) == 0) { + rootTitle = b.value("title").toString(); + break; + } + } + emitNode(rootDiagram, QString(), QStringLiteral("A0"), 1, rootTitle); + + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + QMessageBox::warning(this, tr("Export failed"), tr("Could not open file for writing.")); + return false; + } + f.write(md.toUtf8()); + f.close(); + return true; +} + bool MainWindow::effectiveDarkMode() const { const bool followSystem = m_welcomeState.value("followSystemTheme", false).toBool(); if (!followSystem) { @@ -866,6 +1044,316 @@ void MainWindow::showNodeTreeDialog() { dlg->deleteLater(); } +void MainWindow::runValidation() { + if (!m_scene) return; + + const QVariantMap exported = m_scene->exportToVariant(); + const QVariantMap rootDiagram = exported.value("diagram").toMap(); + const QVariantMap children = exported.value("children").toMap(); + + struct DiagCtx { + QString key; // "" for root, "1/2/3" for descendants + QString node; // A0, A1, A1.2, ... + QVariantMap diagram; // variant map with blocks/arrows + }; + + QHash diags; + std::function collect; + collect = [&](const QString& key, const QString& node, const QVariantMap& diagram) { + diags.insert(key, DiagCtx{key, node, diagram}); + const QVariantList blocks = diagram.value("blocks").toList(); + for (const QVariant& vb : blocks) { + const QVariantMap b = vb.toMap(); + const int id = b.value("id", -1).toInt(); + if (id < 0) continue; + const QString childKey = key.isEmpty() ? QString::number(id) : key + "/" + QString::number(id); + if (!children.contains(childKey)) continue; + const QString childNode = b.value("number").toString().isEmpty() ? node : b.value("number").toString(); + collect(childKey, childNode, children.value(childKey).toMap()); + } + }; + collect(QString(), QStringLiteral("A0"), rootDiagram); + + auto epKind = [](const QVariantMap& ep) { return ep.value("kind").toInt(0); }; // 1=Block, 2=Junction, 3=Scene + auto epId = [](const QVariantMap& ep) { return ep.value("id", -1).toInt(); }; + auto epPort = [](const QVariantMap& ep) { return ep.value("port", 0).toInt(); }; // 0=I,1=C,2=O,3=M + auto epTun = [](const QVariantMap& ep) { return ep.value("tunneled").toBool(); }; + auto blockLabel = [](const QVariantMap& b) { + const QString num = b.value("number").toString(); + const QString title = b.value("title").toString(); + if (num.isEmpty()) return title; + return title.isEmpty() ? num : QStringLiteral("%1 %2").arg(num, title); + }; + + QStringList errors; + QStringList warnings; + + // rule 5 + if (rootDiagram.value("blocks").toList().size() > 1) { + errors << tr("There are several blocks in the context (root) diagram (there should be only one)."); + } + + for (const DiagCtx& d : diags) { + const bool isRoot = d.key.isEmpty(); + const QVariantList blocks = d.diagram.value("blocks").toList(); + const QVariantList arrows = d.diagram.value("arrows").toList(); + + QHash blockById; + for (const QVariant& vb : blocks) { + const QVariantMap b = vb.toMap(); + blockById.insert(b.value("id", -1).toInt(), b); + } + + // rules 6,7 for non-root + if (!isRoot) { + if (blocks.size() < 3) { + errors << tr("The decomposition diagram (%1) contains fewer than 3 blocks (there should be 3–8).").arg(d.node); + } else if (blocks.size() > 8) { + errors << tr("The decomposition diagram (%1) contains more than 8 blocks (there should be 3–8).").arg(d.node); + } + } + + // rule 8 hanging interface circles + if (!isRoot) { + bool hasHanging = false; + for (const QVariant& va : arrows) { + const QVariantMap a = va.toMap(); + if (a.value("isInterface").toBool() && + a.value("isInterfaceStub").toBool() && + !a.value("interfaceEdge").toMap().value("tunneled").toBool()) { + hasHanging = true; + break; + } + } + if (hasHanging) { + errors << tr("The decomposition of block (%1) is incomplete; there are hanging white connector circles.").arg(d.node); + } + } + + QHash inIC; + QHash outO; + QHash inByBlockLabel; + QHash outByBlockLabel; + QHash sameLabelEntries; // blockId:port:label + QHash hasOtherBlockLink; + QHash hasIncident; + QHash allIncidentTunneled; + + for (auto it = blockById.cbegin(); it != blockById.cend(); ++it) { + hasOtherBlockLink[it.key()] = false; + hasIncident[it.key()] = false; + allIncidentTunneled[it.key()] = true; + } + + for (const QVariant& va : arrows) { + const QVariantMap a = va.toMap(); + const QVariantMap f = a.value("from").toMap(); + const QVariantMap t = a.value("to").toMap(); + const QString lbl = a.value("label").toString().trimmed(); + + const int fk = epKind(f), tk = epKind(t); + const int fid = epId(f), tid = epId(t); + const int fp = epPort(f), tp = epPort(t); + + if (fk == 1 && blockById.contains(fid)) { + hasIncident[fid] = true; + allIncidentTunneled[fid] = allIncidentTunneled[fid] && epTun(f); + } + if (tk == 1 && blockById.contains(tid)) { + hasIncident[tid] = true; + allIncidentTunneled[tid] = allIncidentTunneled[tid] && epTun(t); + } + + if (tk == 1 && blockById.contains(tid) && (tp == 0 || tp == 1)) { + inIC[tid] += 1; + } + if (fk == 1 && blockById.contains(fid) && fp == 2) { + outO[fid] += 1; + } + + if (tk == 1 && blockById.contains(tid) && tp == 0 && !lbl.isEmpty()) { + inByBlockLabel[tid].push_back(lbl); + } + if (fk == 1 && blockById.contains(fid) && fp == 2 && !lbl.isEmpty()) { + outByBlockLabel[fid].push_back(lbl); + } + + if (tk == 1 && blockById.contains(tid) && (tp == 0 || tp == 1 || tp == 2) && !lbl.isEmpty()) { + const QString key = QStringLiteral("%1:%2:%3").arg(tid).arg(tp).arg(lbl); + sameLabelEntries[key] += 1; + } + + if (fk == 1 && tk == 1 && blockById.contains(fid) && blockById.contains(tid) && fid != tid) { + hasOtherBlockLink[fid] = true; + hasOtherBlockLink[tid] = true; + } + + // warnings 1..4 + if (!isRoot && fk == 3 && tk == 1) { + if (fp == 3 && tp == 0) warnings << tr("The mechanism of diagram %1 acts as an input for block %2.").arg(d.node, blockById.value(tid).value("number").toString()); + if (fp == 3 && tp == 1) warnings << tr("The mechanism of diagram %1 acts as a control for block %2.").arg(d.node, blockById.value(tid).value("number").toString()); + if (fp == 1 && tp == 3) warnings << tr("The control of diagram %1 acts as a mechanism for block %2.").arg(d.node, blockById.value(tid).value("number").toString()); + if (fp == 0 && tp == 3) warnings << tr("The input of diagram %1 acts as a mechanism for block %2.").arg(d.node, blockById.value(tid).value("number").toString()); + } + + // warning 5 + if (fk == 1 && tk == 1 && fp == 2 && tp == 3 && blockById.contains(fid) && blockById.contains(tid)) { + warnings << tr("The output of block %1 acts as a mechanism for block %2.") + .arg(blockById.value(fid).value("number").toString(), + blockById.value(tid).value("number").toString()); + } + } + + for (auto it = blockById.cbegin(); it != blockById.cend(); ++it) { + const int id = it.key(); + const QVariantMap b = it.value(); + const QString title = b.value("title").toString(); + const QString titleTrim = title.trimmed(); + + // rules 1,2 + if (title == tr("Function") || title == QStringLiteral("Function")) { + errors << tr("The block name should reflect what it does. Do not leave block %1 with a default name.").arg(blockLabel(b)); + } + if (titleTrim.isEmpty()) { + errors << tr("The block name should reflect what it does. Do not leave block %1 with an empty name.").arg(blockLabel(b)); + } + + // rules 3,4 + if (inIC.value(id, 0) == 0) { + errors << tr("Each block must have at least one input or control: %1.").arg(blockLabel(b)); + } + if (outO.value(id, 0) == 0) { + errors << tr("Each block must have at least one output: %1.").arg(blockLabel(b)); + } + + // rule 11 + QSet inNames, outNames; + for (const QString& s : inByBlockLabel.value(id)) inNames.insert(s.trimmed()); + for (const QString& s : outByBlockLabel.value(id)) outNames.insert(s.trimmed()); + for (const QString& s : inNames) { + if (!s.isEmpty() && outNames.contains(s)) { + errors << tr("Input and output names are identical (%1) in process %2.").arg(s, b.value("number").toString()); + } + } + + // warning 6 + if (hasIncident.value(id, false) && allIncidentTunneled.value(id, false)) { + warnings << tr("Block %1 is a black box (all connected endpoints are tunneled).").arg(b.value("number").toString()); + } + + // warning 8 + if (!isRoot && blocks.size() > 1 && !hasOtherBlockLink.value(id, false)) { + warnings << tr("Block %1 is not related to other blocks in decomposition %2.").arg(b.value("number").toString(), d.node); + } + } + + // rule 10 + for (auto it = sameLabelEntries.cbegin(); it != sameLabelEntries.cend(); ++it) { + if (it.value() > 1) { + errors << tr("The same arrow enters the same block several times in diagram %1 (%2).").arg(d.node, it.key()); + } + } + + // warning 7 + for (const QVariant& vb : blocks) { + const QVariantMap b = vb.toMap(); + if (!b.contains("price") || b.value("price").isNull()) continue; + const int id = b.value("id", -1).toInt(); + if (id < 0) continue; + const QString childKey = d.key.isEmpty() ? QString::number(id) : d.key + "/" + QString::number(id); + if (!children.contains(childKey)) continue; + const QVariantList childBlocks = children.value(childKey).toMap().value("blocks").toList(); + qreal sum = 0.0; + for (const QVariant& vcb : childBlocks) { + const QVariantMap cb = vcb.toMap(); + if (cb.contains("price") && !cb.value("price").isNull()) sum += cb.value("price").toDouble(); + } + const qreal parentPrice = b.value("price").toDouble(); + if (sum > parentPrice + 1e-6) { + warnings << tr("The total cost of process %1 is lower than the total cost of its subprocesses. Use Tools >> Calculate Prices.") + .arg(b.value("number").toString()); + } + } + } + + QString report; + report += tr("Errors:") + "\n"; + if (errors.isEmpty()) report += tr("None.") + "\n"; + else for (int i = 0; i < errors.size(); ++i) report += QString::number(i + 1) + ". " + errors[i] + "\n"; + report += "\n" + tr("Warnings:") + "\n"; + if (warnings.isEmpty()) report += tr("None.") + "\n"; + else for (int i = 0; i < warnings.size(); ++i) report += QString::number(i + 1) + ". " + warnings[i] + "\n"; + + auto* dlg = new QDialog(this); + dlg->setWindowTitle(tr("Validation")); + auto* layout = new QVBoxLayout(dlg); + auto* txt = new QTextEdit(dlg); + txt->setReadOnly(true); + txt->setPlainText(report); + layout->addWidget(txt); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept); + layout->addWidget(buttons); + dlg->resize(860, 640); + dlg->exec(); + dlg->deleteLater(); +} + +void MainWindow::calculatePrices() { + if (!m_scene) return; + QVariantMap exported = m_scene->exportToVariant(); + QVariantMap rootDiagram = exported.value("diagram").toMap(); + QVariantMap children = exported.value("children").toMap(); + + int updatedBlocks = 0; + std::function recalc; + recalc = [&](const QString& key, QVariantMap& diagram) { + QVariantList blocks = diagram.value("blocks").toList(); + for (int i = 0; i < blocks.size(); ++i) { + QVariantMap b = blocks[i].toMap(); + const int id = b.value("id", -1).toInt(); + if (id < 0) continue; + const QString childKey = key.isEmpty() ? QString::number(id) : key + "/" + QString::number(id); + if (!children.contains(childKey)) continue; + QVariantMap child = children.value(childKey).toMap(); + recalc(childKey, child); + children[childKey] = child; + const QVariantList childBlocks = child.value("blocks").toList(); + qreal sum = 0.0; + bool hasAny = false; + for (const QVariant& vcb : childBlocks) { + const QVariantMap cb = vcb.toMap(); + if (cb.contains("price") && !cb.value("price").isNull()) { + sum += cb.value("price").toDouble(); + hasAny = true; + } + } + if (!hasAny) continue; + const qreal oldPrice = b.contains("price") && !b.value("price").isNull() ? b.value("price").toDouble() : std::numeric_limits::quiet_NaN(); + if (std::isnan(oldPrice) || std::abs(oldPrice - sum) > 1e-6) { + b["price"] = sum; + blocks[i] = b; + updatedBlocks += 1; + } + } + diagram["blocks"] = blocks; + }; + + recalc(QString(), rootDiagram); + exported["diagram"] = rootDiagram; + exported["children"] = children; + exported["meta"] = stripPerDiagramState(m_welcomeState); + + if (!m_scene->importFromVariant(exported)) { + QMessageBox::warning(this, tr("Calculate Prices"), tr("Failed to apply calculated prices.")); + return; + } + m_scene->applyMeta(mergeWithDiagramState(m_welcomeState, m_scene->meta())); + markDirty(true); + QMessageBox::information(this, tr("Calculate Prices"), tr("Updated prices for %1 block(s).").arg(updatedBlocks)); +} + void MainWindow::closeEvent(QCloseEvent* e) { if (!m_dirty) { e->accept(); @@ -973,5 +1461,5 @@ void MainWindow::markDirty(bool dirty) { void MainWindow::updateWindowTitle() { QString name = m_currentFile.isEmpty() ? tr("Untitled") : QFileInfo(m_currentFile).fileName(); if (m_dirty) name += " *"; - setWindowTitle(name + tr(" — IDEF0 editor")); + setWindowTitle(name + tr(" — erlu IDEF0 editor")); } diff --git a/src/MainWindow.h b/src/MainWindow.h index ef6a95c..b3d82f4 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -41,6 +41,9 @@ private: bool promptStartup(); bool loadDiagramFromPath(const QString& path); bool exportPdf(bool allDiagrams, bool numberPages, bool forceLightTheme); + bool exportMarkdownExplanation(); + void runValidation(); + void calculatePrices(); bool effectiveDarkMode() const; void applyAppPalette(bool darkMode); void autoInitialsFromAuthor(QLineEdit* author, QLineEdit* initials); diff --git a/src/items/DiagramScene.cpp b/src/items/DiagramScene.cpp index a8b8d9e..fb9f659 100644 --- a/src/items/DiagramScene.cpp +++ b/src/items/DiagramScene.cpp @@ -26,6 +26,11 @@ DiagramScene::DiagramScene(QObject* parent) : QGraphicsScene(parent) { m_currentPrefix.clear(); + m_meta["state"] = QStringLiteral("working"); + m_meta["working"] = true; + m_meta["draft"] = false; + m_meta["recommended"] = false; + m_meta["publication"] = false; // Ограничиваем рабочую область A4 (210x297 мм) в пикселях при 96 dpi (~793x1122). // По умолчанию используем альбомную ориентацию (ширина больше высоты). const qreal w = 1122; @@ -284,6 +289,10 @@ bool DiagramScene::tryStartArrowDrag(const QPointF& scenePos, Qt::KeyboardModifi ArrowItem::Endpoint from; from.scenePos = edgePoint; from.port = BlockItem::Port::Input; // ориентация не важна для свободной точки + if (m_currentBlockId >= 0) { + // Non-root subdiagram frame endpoints without interface stub are tunneled at source. + from.tunneled = true; + } a->setFrom(from); a->setTempEndPoint(scenePos); m_dragArrow = a; @@ -298,14 +307,6 @@ bool DiagramScene::tryStartArrowDrag(const QPointF& scenePos, Qt::KeyboardModifi bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { if (!m_dragArrow) return false; - auto markSubdiagramOriginTunnel = [this](ArrowItem* arrow) { - if (!arrow || m_currentBlockId < 0) return; - auto from = arrow->from(); - if (!from.tunneled) { - from.tunneled = true; - arrow->setFrom(from); - } - }; const auto itemsUnder = items(scenePos); // попадание в интерфейсный круг @@ -345,6 +346,7 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { BlockItem::Port port{}; QPointF localPos; if (!b->hitTestPort(scenePos, &port, &localPos)) continue; + if (port == BlockItem::Port::Output) continue; // end of arrow cannot connect to Output port // запретим соединять в тот же самый порт того же блока (упрощение) if (b == m_dragFromBlock && port == m_dragFromPort) continue; @@ -365,7 +367,6 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { m_dragArrow->setFrom(stubEp); m_dragArrow->setTo(blockEp); } - markSubdiagramOriginTunnel(m_dragArrow); m_dragArrow->setLabelLocked(true); m_dragArrow->finalize(); } @@ -375,7 +376,6 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { to.port = port; to.localPos = localPos; m_dragArrow->setTo(to); - markSubdiagramOriginTunnel(m_dragArrow); m_dragArrow->finalize(); } @@ -408,7 +408,6 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { m_dragArrow->setFrom(stubEp); m_dragArrow->setTo(jun); } - markSubdiagramOriginTunnel(m_dragArrow); m_dragArrow->setLabelLocked(true); m_dragArrow->finalize(); } @@ -417,7 +416,6 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { to.junction = j; to.port = BlockItem::Port::Input; m_dragArrow->setTo(to); - markSubdiagramOriginTunnel(m_dragArrow); m_dragArrow->finalize(); } @@ -474,7 +472,6 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { m_dragArrow->setLabelHidden(hideInherited); m_dragArrow->setLabelInherited(true); m_dragArrow->setLabelSource(sourceRoot); - markSubdiagramOriginTunnel(m_dragArrow); m_dragArrow->finalize(); m_dragArrow = nullptr; @@ -497,8 +494,11 @@ bool DiagramScene::tryFinishArrowDrag(const QPointF& scenePos) { ArrowItem::Endpoint to; to.scenePos = edgePoint; to.port = BlockItem::Port::Input; + if (m_currentBlockId >= 0 && edge == Edge::Right) { + // In subdiagrams, right-frame endings without interface stub are tunneled at target. + to.tunneled = true; + } m_dragArrow->setTo(to); - markSubdiagramOriginTunnel(m_dragArrow); m_dragArrow->finalize(); m_dragArrow = nullptr; m_dragFromBlock.clear(); @@ -833,8 +833,12 @@ void DiagramScene::updateCallMechanismLabels() { void DiagramScene::purgeBrokenCallMechanisms() { QSet blocks; + QHash blockById; for (QGraphicsItem* it : items()) { - if (auto* b = qgraphicsitem_cast(it)) blocks.insert(b); + if (auto* b = qgraphicsitem_cast(it)) { + blocks.insert(b); + blockById.insert(b->id(), b); + } } QVector toRemove; for (QGraphicsItem* it : items()) { @@ -845,6 +849,7 @@ void DiagramScene::purgeBrokenCallMechanisms() { bool ok = true; if (from.block && !blocks.contains(from.block)) ok = false; if (to.block && !blocks.contains(to.block)) ok = false; + if (!blockById.contains(a->callRefId())) ok = false; // referenced block was deleted if (!ok) toRemove.push_back(a); } } @@ -860,6 +865,12 @@ void DiagramScene::undo() { restoreSnapshot(m_history[m_historyIndex], false); } +void DiagramScene::redo() { + if (m_historyIndex + 1 >= m_history.size()) return; + m_historyIndex += 1; + restoreSnapshot(m_history[m_historyIndex], false); +} + bool DiagramScene::goDownIntoSelected() { const auto sel = selectedItems(); if (sel.size() != 1) return false; @@ -991,14 +1002,7 @@ bool DiagramScene::goDownIntoBlock(BlockItem* b) { } child.arrows = std::move(preserved); } else { - QString state = m_meta.value("state").toString(); - if (state.isEmpty()) { - if (m_meta.value("publication", false).toBool()) state = "publication"; - else if (m_meta.value("recommended", false).toBool()) state = "recommended"; - else if (m_meta.value("draft", false).toBool()) state = "draft"; - else state = "working"; - } - child.state = state; + child.state = QStringLiteral("working"); child.hasState = true; } @@ -1138,12 +1142,28 @@ void DiagramScene::mousePressEvent(QGraphicsSceneMouseEvent* e) { } if (e->button() == Qt::LeftButton) { + const auto itemsUnder = items(e->scenePos()); if (e->modifiers().testFlag(Qt::ControlModifier)) { if (tryBranchAtArrow(e->scenePos())) { e->accept(); return; } } + if (e->modifiers().testFlag(Qt::ShiftModifier)) { + for (QGraphicsItem* it : itemsUnder) { + auto* j = qgraphicsitem_cast(it); + if (!j || !j->hitTest(e->scenePos(), 8.0)) continue; + if (!j->isSelected()) { + clearSelection(); + j->setSelected(true); + } + m_pressPositions.clear(); + m_pressPositions.insert(j, j->pos()); + m_itemDragActive = true; + QGraphicsScene::mousePressEvent(e); + return; + } + } // если кликнули на порт — начинаем протягивание стрелки if (tryStartArrowDrag(e->scenePos(), e->modifiers())) { e->accept(); @@ -1249,6 +1269,11 @@ void DiagramScene::keyPressEvent(QKeyEvent* e) { e->accept(); return; } + if (e->matches(QKeySequence::Redo)) { + redo(); + e->accept(); + return; + } if ((e->key() == Qt::Key_Down || e->key() == Qt::Key_PageDown) && e->modifiers().testFlag(Qt::ControlModifier)) { if (goDownIntoSelected()) { e->accept(); @@ -1348,6 +1373,44 @@ void DiagramScene::deleteSelection() { } } + // If deleting a pre-branch arrow (ending at junction), delete all downstream branches too. + QVector selectedArrows; + for (QGraphicsItem* it : toDelete) { + if (auto* a = qgraphicsitem_cast(it)) selectedArrows.push_back(a); + } + QSet branchStarts; + for (ArrowItem* a : selectedArrows) { + if (!a) continue; + const auto t = a->to(); + if (t.junction) branchStarts.insert(t.junction); + } + if (!branchStarts.isEmpty()) { + QSet visitedJunctions = branchStarts; + QVector queue(branchStarts.begin(), branchStarts.end()); + int qi = 0; + while (qi < queue.size()) { + JunctionItem* j = queue[qi++]; + if (!j) continue; + for (QGraphicsItem* it : items()) { + auto* a = qgraphicsitem_cast(it); + if (!a) continue; + const auto f = a->from(); + const auto t = a->to(); + // downstream from branching junction + if (f.junction == j) { + toDelete.insert(a); + if (t.junction && !visitedJunctions.contains(t.junction)) { + visitedJunctions.insert(t.junction); + queue.push_back(t.junction); + } + } + } + } + for (JunctionItem* j : visitedJunctions) { + toDelete.insert(j); + } + } + QVector arrowsToDelete; QVector othersToDelete; for (QGraphicsItem* it : toDelete) { diff --git a/src/items/DiagramScene.h b/src/items/DiagramScene.h index 2a89bd7..865b288 100644 --- a/src/items/DiagramScene.h +++ b/src/items/DiagramScene.h @@ -115,6 +115,7 @@ private: void pushSnapshot(); void scheduleSnapshot(); void undo(); + void redo(); void maybeSnapshotMovedItems(); void resetHistory(const Snapshot& base); void ensureFrame(); diff --git a/src/main.cpp b/src/main.cpp index 19d2edf..4f51be8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include #include #include #include @@ -33,6 +35,19 @@ public: int main(int argc, char** argv) { IdefApplication app(argc, argv); + const QString resIcon = QStringLiteral(":/icons/erlu.svg"); + if (QFile::exists(resIcon)) { + app.setWindowIcon(QIcon(resIcon)); + } else { + const QString appDir = QCoreApplication::applicationDirPath(); + const QString fallback1 = appDir + QStringLiteral("/../share/icons/hicolor/scalable/apps/erlu.svg"); + const QString fallback2 = QDir::currentPath() + QStringLiteral("/assets/icons/erlu.svg"); + if (QFile::exists(fallback1)) { + app.setWindowIcon(QIcon(fallback1)); + } else if (QFile::exists(fallback2)) { + app.setWindowIcon(QIcon(fallback2)); + } + } const QLocale systemLocale = QLocale::system(); QLocale::setDefault(systemLocale); diff --git a/translations/idef0_en.ts b/translations/idef0_en.ts index 362de9c..f4d05e3 100644 --- a/translations/idef0_en.ts +++ b/translations/idef0_en.ts @@ -560,8 +560,8 @@ Untitled - — IDEF0 editor - — IDEF0 editor + — erlu IDEF0 editor + — erlu IDEF0 editor diff --git a/translations/idef0_fr.ts b/translations/idef0_fr.ts index 39ed64d..592d8b3 100644 --- a/translations/idef0_fr.ts +++ b/translations/idef0_fr.ts @@ -255,6 +255,10 @@ Export to PDF… Exporter en PDF… + + Export to Markdown… + Exporter en Markdown… + &Edit &Édition @@ -363,6 +367,14 @@ Export to PDF Exporter en PDF + + Export to Markdown + Exporter en Markdown + + + Markdown (*.md) + Markdown (*.md) + Current diagram Diagramme actuel @@ -459,6 +471,10 @@ Currency: Devise : + + person-hours + heures-personne + Symbol placement: Position du symbole : @@ -560,8 +576,155 @@ Sans titre - — IDEF0 editor - — éditeur IDEF0 + — erlu IDEF0 editor + — erlu éditeur IDEF0 + + + &Plugins + &Plugins + + + &Tools + &Outils + + + Validation + Validation + + + Calculate Prices + Calculer les coûts + + + Use a light theme regardless of the document theme + Utiliser un thème clair indépendamment du thème du document + + + Node tree + Arborescence + + + Node tree… + Arborescence… + + + Model + Modèle + + + There are several blocks in the context (root) diagram (there should be only one). + Le diagramme de contexte (racine) contient plusieurs blocs (il ne doit y en avoir qu'un). + + + The decomposition diagram (%1) contains fewer than 3 blocks (there should be 3–8). + Le diagramme de décomposition (%1) contient moins de 3 blocs (il doit y en avoir 3–8). + + + The decomposition diagram (%1) contains more than 8 blocks (there should be 3–8). + Le diagramme de décomposition (%1) contient plus de 8 blocs (il doit y en avoir 3–8). + + + The decomposition of block (%1) is incomplete; there are hanging white connector circles. + La décomposition du bloc (%1) est incomplète ; il existe des connecteurs blancs en suspens. + + + The mechanism of diagram %1 acts as an input for block %2. + Le mécanisme du diagramme %1 agit comme une entrée pour le bloc %2. + + + The mechanism of diagram %1 acts as a control for block %2. + Le mécanisme du diagramme %1 agit comme un contrôle pour le bloc %2. + + + The control of diagram %1 acts as a mechanism for block %2. + Le contrôle du diagramme %1 agit comme un mécanisme pour le bloc %2. + + + The input of diagram %1 acts as a mechanism for block %2. + L'entrée du diagramme %1 agit comme un mécanisme pour le bloc %2. + + + The output of block %1 acts as a mechanism for block %2. + La sortie du bloc %1 agit comme un mécanisme pour le bloc %2. + + + The block name should reflect what it does. Do not leave block %1 with a default name. + Le nom du bloc doit refléter ce qu'il fait. Ne laissez pas le bloc %1 avec un nom par défaut. + + + The block name should reflect what it does. Do not leave block %1 with an empty name. + Le nom du bloc doit refléter ce qu'il fait. Ne laissez pas le bloc %1 avec un nom vide. + + + Each block must have at least one input or control: %1. + Chaque bloc doit avoir au moins une entrée ou un contrôle : %1. + + + Each block must have at least one output: %1. + Chaque bloc doit avoir au moins une sortie : %1. + + + Input and output names are identical (%1) in process %2. + Les noms d'entrée et de sortie sont identiques (%1) dans le processus %2. + + + Block %1 is a black box (all connected endpoints are tunneled). + Le bloc %1 est une « boîte noire » (toutes les extrémités connectées sont tunnelisées). + + + Block %1 is not related to other blocks in decomposition %2. + Le bloc %1 n'est pas lié aux autres blocs de la décomposition %2. + + + The same arrow enters the same block several times in diagram %1 (%2). + La même flèche entre plusieurs fois dans le même bloc sur le diagramme %1 (%2). + + + The total cost of process %1 is lower than the total cost of its subprocesses. Use Tools >> Calculate Prices. + Le coût total du processus %1 est inférieur au coût total de ses sous-processus. Utilisez Outils >> Calculer les coûts. + + + Errors: + Erreurs : + + + None. + Aucun. + + + none + aucun + + + Warnings: + Avertissements : + + + Failed to apply calculated prices. + Impossible d'appliquer les coûts calculés. + + + Updated prices for %1 block(s). + Coûts mis à jour pour %1 bloc(s). + + + Call mechanism already exists for this block or selection is invalid. + Un Call Mechanism existe déjà pour ce bloc, ou la sélection est invalide. + + + + ColorsPlugin + + Select item color + Sélectionner la couleur de l'élément + + + Set item color… + Définir la couleur de l'élément… + + + Clear item colors + Effacer les couleurs des éléments diff --git a/translations/idef0_ru.ts b/translations/idef0_ru.ts index c6d7ad7..603325c 100644 --- a/translations/idef0_ru.ts +++ b/translations/idef0_ru.ts @@ -255,6 +255,10 @@ Export to PDF… Экспорт в PDF… + + Export to Markdown… + Экспорт в Markdown… + &Edit &Правка @@ -367,6 +371,14 @@ Export to PDF Экспорт в PDF + + Export to Markdown + Экспорт в Markdown + + + Markdown (*.md) + Markdown (*.md) + Current diagram Текущая диаграмма @@ -463,6 +475,10 @@ Currency: Валюта: + + person-hours + человеко-часы + Symbol placement: Расположение символа: @@ -564,8 +580,151 @@ Без имени - — IDEF0 editor - — редактор IDEF0 + — erlu IDEF0 editor + — erlu редактор IDEF0 + + + &Tools + &Инструменты + + + Validation + Проверка + + + Calculate Prices + Рассчитать стоимости + + + Use a light theme regardless of the document theme + Использовать светлую тему независимо от темы документа + + + Node tree + Дерево узлов + + + Node tree… + Дерево узлов… + + + Model + Модель + + + There are several blocks in the context (root) diagram (there should be only one). + В контекстной (корневой) диаграмме несколько блоков (должен быть только один). + + + The decomposition diagram (%1) contains fewer than 3 blocks (there should be 3–8). + Диаграмма декомпозиции (%1) содержит менее 3 блоков (должно быть 3–8). + + + The decomposition diagram (%1) contains more than 8 blocks (there should be 3–8). + Диаграмма декомпозиции (%1) содержит более 8 блоков (должно быть 3–8). + + + The decomposition of block (%1) is incomplete; there are hanging white connector circles. + Декомпозиция блока (%1) неполная: есть висящие белые кружки-коннекторы. + + + The mechanism of diagram %1 acts as an input for block %2. + Механизм диаграммы %1 выступает входом для блока %2. + + + The mechanism of diagram %1 acts as a control for block %2. + Механизм диаграммы %1 выступает управлением для блока %2. + + + The control of diagram %1 acts as a mechanism for block %2. + Управление диаграммы %1 выступает механизмом для блока %2. + + + The input of diagram %1 acts as a mechanism for block %2. + Вход диаграммы %1 выступает механизмом для блока %2. + + + The output of block %1 acts as a mechanism for block %2. + Выход блока %1 выступает механизмом для блока %2. + + + The block name should reflect what it does. Do not leave block %1 with a default name. + Имя блока должно отражать его действие. Не оставляйте блоку %1 имя по умолчанию. + + + The block name should reflect what it does. Do not leave block %1 with an empty name. + Имя блока должно отражать его действие. Не оставляйте блок %1 без имени. + + + Each block must have at least one input or control: %1. + У каждого блока должен быть хотя бы один вход или контроль: %1. + + + Each block must have at least one output: %1. + У каждого блока должен быть хотя бы один выход: %1. + + + Input and output names are identical (%1) in process %2. + Имена входа и выхода совпадают (%1) в процессе %2. + + + Block %1 is a black box (all connected endpoints are tunneled). + Блок %1 является «черным ящиком» (все подключенные концы затуннелированы). + + + Block %1 is not related to other blocks in decomposition %2. + Блок %1 не связан с другими блоками в декомпозиции %2. + + + The same arrow enters the same block several times in diagram %1 (%2). + Одна и та же стрелка входит в один и тот же блок несколько раз на диаграмме %1 (%2). + + + The total cost of process %1 is lower than the total cost of its subprocesses. Use Tools >> Calculate Prices. + Общая стоимость процесса %1 ниже суммарной стоимости его подпроцессов. Используйте Инструменты >> Рассчитать стоимости. + + + Errors: + Ошибки: + + + None. + Нет. + + + none + нет + + + Warnings: + Предупреждения: + + + Failed to apply calculated prices. + Не удалось применить рассчитанные стоимости. + + + Updated prices for %1 block(s). + Обновлены стоимости для %1 блока(ов). + + + Call mechanism already exists for this block or selection is invalid. + Call Mechanism уже существует для этого блока, либо выбор недопустим. + + + + ColorsPlugin + + Select item color + Выбрать цвет элемента + + + Set item color… + Установить цвет элемента… + + + Clear item colors + Очистить цвета элементов