Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6a1898d
add ladder diagram renderer for PLCopen XML exports
Aug 4, 2026
67d5e55
add FBD renderer and Structured Text emitter
Aug 4, 2026
ee39f4a
draw the diagrams with Unicode box characters
Aug 5, 2026
bde8cf2
render graphical POUs during Export To Files
Aug 5, 2026
23e8dd4
add a script to diagnose PLCopen parsing inside CODESYS
Aug 5, 2026
fcc94ef
strip the byte order mark before parsing PLCopen xml
Aug 5, 2026
d36f7f9
escape non-ASCII before handing PLCopen xml to the parser
Aug 5, 2026
7b0244f
stop silently dropping jumps, inline ST and negated inputs
Aug 5, 2026
4ad2b20
silence the xmllib warning and report what rendering costs
Aug 5, 2026
b5ba0f3
ignore Claude Code's local tool permissions
Aug 5, 2026
d006924
stop dropping and inverting logic in LD and FBD renderings
gsokoll Aug 5, 2026
d612fb4
hold the rendering-failure barrier around its own scaffolding
gsokoll Aug 5, 2026
c185983
honour negation bubbles on block pins and LD outVariable stores
gsokoll Aug 5, 2026
e57380d
keep the NOT on flattened block outputs and spaceless compounds
gsokoll Aug 5, 2026
67086e9
Merge branch 'main' into feat/ladder-renderer
gsokoll Aug 5, 2026
5325a94
group a fan-out into one network instead of splitting it
Aug 6, 2026
886f471
write only the diagram, not an ST rendering alongside it
Aug 6, 2026
58bce55
split the cost report into parsing and drawing
Aug 6, 2026
7562558
parse XML through .NET where it is available
Aug 6, 2026
bbe3052
keep whitespace so both XML backends agree
Aug 6, 2026
e245375
report where the two XML backends disagree
Aug 6, 2026
c664eb5
normalise line endings in the .NET backend's text
Aug 6, 2026
4f40894
stop walking the whole document to find two POUs
Aug 6, 2026
c8cebb2
keep comments, pragmas and attributes in the declaration
Aug 6, 2026
e8aef6f
try every export_xml shape, and say when the declaration is rebuilt
Aug 6, 2026
892d1cb
probe a real POU in the declaration diagnostic
Aug 6, 2026
d8a8fb7
look for the plaintext declaration outside <interface> too
Aug 6, 2026
9765856
walk the whole addData subtree for the plaintext declaration
Aug 6, 2026
0b1524a
preserve graphical POU declarations
Aug 20, 2026
c860635
harden graphical renderer review output
Aug 20, 2026
5b33acb
document graphical declaration sources
Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
- uses: actions/checkout@v4
- name: Fail on non-ASCII bytes in src/*.py
run: python3 tools/ci/check_ascii.py
- name: Compile src files with Python 3
run: python3 tools/ci/compile_python3.py

ironpython:
name: ironpython
Expand All @@ -30,3 +32,33 @@ jobs:
- name: Import smoke test with stubbed scriptengine
shell: pwsh
run: .\ipy\net45\ipy.exe tools\ci\import_smoke.py

# The renderers are destined for src/, so they have to pass under the
# same interpreter CODESYS embeds - not just under CI's Python 3.
- name: Renderer tests under IronPython 2.7
shell: pwsh
run: |
# A native exe's exit code does not halt a pwsh script, so a failure
# in the first suite would otherwise be masked by the second passing.
.\ipy\net45\ipy.exe tools\ladder\tests\test_ladder.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
.\ipy\net45\ipy.exe tools\ladder\tests\test_fbd.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
.\ipy\net45\ipy.exe tools\ladder\tests\test_export.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# The only host with both XML backends, so the only place their
# equivalence can actually be checked.
.\ipy\net45\ipy.exe tools\ladder\tests\test_xmlbackend.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

ladder:
name: ladder
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Renderer tests under Python 3
run: |
python3 tools/ladder/tests/test_ladder.py
python3 tools/ladder/tests/test_fbd.py
python3 tools/ladder/tests/test_export.py
python3 tools/ladder/tests/test_xmlbackend.py
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
.vscode/

# Claude Code's per-developer tool permissions. Machine-specific paths, and
# permission grants that should not be inherited by whoever clones the repo.
.claude/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,35 @@ Items are exported in formatted structured text (`.st`) where possible, and in n

Actions and Transitions export as `.st` with the kind encoded in the filename (`MyPou.MyAction.action.st`, `MyPou.MyTransition.transition.st`). The file contains the implementation text only, as these objects have no textual declaration.

### Reading graphical POUs

Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the declaration and a diagram of each network:

```
(* Network 2 *)
│ TON_0 : TON CTU_0 : CTU
│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff
├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤
│ │PT := T#5S ET│ │RESET := PowerOff CV│
│ └───────────────┘ │PV := 10 │
│ └──────────────────────┘
```

The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface and the export summary warns that comments, pragmas, or exact formatting may be missing.

This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff.

SFC and CFC POUs are not yet rendered; they export as native xml alone.

Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor.

To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to see the equivalent Structured Text (which the export does not write, since showing each network twice in two notations reads worse than showing it once):

```
python tools/ladder/render.py --charset ascii MyPou.xml
python tools/ladder/render.py --format st MyPou.xml
```

Visualisations export as `<name>.vis.xml`, so a `Main` visualisation cannot collide with a `Main` POU.

Exports made with older versions of CODESCRIBE use different filenames for some of these objects; they still import correctly, and re-exporting once migrates the tracked files. See [CHANGELOG.md](CHANGELOG.md) for the details.
Expand Down
71 changes: 71 additions & 0 deletions src/charset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3.
"""Drawing characters for the graphical renderers.

The glyphs are written as \\u escapes rather than literal box-drawing
characters on purpose: CODESYS runs these scripts under IronPython 2.7, which
enforces PEP 263 and refuses to load a source file containing a non-ASCII byte
without an encoding declaration. Escapes keep the source pure ASCII while the
output is Unicode.

The rendered text is written as UTF-8, matching the .st files CODESCRIBE
already exports.

An ASCII set is kept alongside for terminals, diff viewers and pasted-into-
email situations where box drawing turns to mojibake.
"""

from __future__ import unicode_literals

UNICODE = {
"H": "\u2500", # horizontal wire
"V": "\u2502", # vertical wire
"TL": "\u250c", # box corners
"TR": "\u2510",
"BL": "\u2514",
"BR": "\u2518",
"T_DOWN": "\u252c", # branch leaves downward
"T_UP": "\u2534",
"T_RIGHT": "\u251c", # wire joins and continues right
"T_LEFT": "\u2524", # wire arrives from the left
# A ladder contact is a pair of bars the wire runs between.
"CONTACT_L": "\u2524",
"CONTACT_R": "\u251c",
# Box edges at a pin: the tee marks a real connection, so an unwired pin
# stays a plain wall and is visibly different.
"PIN_L": "\u2524",
"PIN_R": "\u251c",
}

ASCII = {
"H": "-",
"V": "|",
"TL": "+",
"TR": "+",
"BL": "+",
"BR": "+",
"T_DOWN": "+",
"T_UP": "+",
"T_RIGHT": "+",
"T_LEFT": "+",
"CONTACT_L": "|",
"CONTACT_R": "|",
"PIN_L": "|",
"PIN_R": "|",
}

SETS = {"unicode": UNICODE, "ascii": ASCII}

_active = UNICODE


def use(name):
"""Select the character set by name. Returns the set now in use."""
global _active
if name not in SETS:
raise ValueError("unknown charset %r, expected one of %s" % (name, ", ".join(sorted(SETS))))
_active = SETS[name]
return _active


def active():
return _active
Loading
Loading