Build a complete, solver-ready FERS model from an arbitrary node/member graph: 3-D frames, trusses, pitched roofs, bracing, towers — anything create_beam/create_frame cannot express. Returns a full FERS JSON document ready for solve_model.
GEOMETRY (1-BASED). `nodes` is a JSON array of [X, Y, Z] triples in METRES, Y up (gravity is −Y). The index you use to refer to a node IS its solver node id: nodes[0] is node 1, so results (displacement_nodes, reaction_nodes) come back under exactly the numbers you wrote. Index 0 is rejected. `members` is a JSON array of [startNode, endNode] / [startNode, endNode, "SectionName"] / [startNode, endNode, "SectionName", {options}]; member ids are 1..N in the order given. Member options: rotation_angle (DEGREES, rolls the section about the member axis), type ("Truss" for a pin-jointed axial-only bar; also Tension/Compression/Cable/Rigid), hinge ("start"|"end"|"both" — releases both bending moments there; at a joint only ONE of the members may be released, because a node whose every member end is released has no rotational stiffness left and the solve then fails outright), classification (free-text group name; one member_set per distinct value, and unity checks can target it).
COLUMN ORIENTATION — READ THIS. FERS's strong bending axis is the LOCAL z axis (i_z > i_y for an I-section), and the member local frame is built from a global-Y reference vector. A HORIZONTAL member therefore bends strong-axis under gravity automatically, but a VERTICAL member gets local y = global Z and local z = global X, so it resists sway along global X with its WEAK axis i_y. Verified: a 6 m cantilever column pushed 20 kN in +X deflects 1.1357 m at rotation_angle 0 (= PL³/3E·i_y) and 0.0820 m at rotation_angle 90 (= PL³/3E·i_z) — a 13.8× difference the solver will never warn about. In plane:"XY" this tool sets rotation_angle 90 on vertical bending members for you (reported in info.auto_rotation), because sway there is along X; in "3D" it only warns, since which axis you want depends on the structure — set rotation_angle explicitly.
SUPPORTS. `supports` maps a 1-based node number to a preset or an explicit DOF object: "fixed" (all 6 DOFs), "pinned" (X,Y,Z fixed, rotations free), "roller_x"/"roller_y"/"roller_z" (free to slide along that global axis), "free". For anything else pass {"X":"fixed","Y":"fixed","Z":"free","RX":"free","RY":"free","RZ":2.5e6} — a NUMBER is a spring stiffness (N/m for X/Y/Z, N·m/rad for RX/RY/RZ), and that node then settles by exactly force/stiffness. Watch the sign when you read results: a SPRING reaction comes back with the OPPOSITE sign to a rigid support carrying the same force (measured: +30 kN at the pin and −30 kN at the spring of one beam), so do not add the two up. Any DOF you leave out of an explicit object is written as Free (never omitted: in the raw JSON an absent DOF means FIXED, which is the opposite of what everyone expects).
PLANE. `plane` is "3D" (default) or "XY". "XY" requires every node to have Z = 0 and pins Z, RX and RY at EVERY node, which is what makes a planar model well-posed — essential for a planar TRUSS, where the bars carry exactly zero out-of-plane stiffness. Note that analysis.options.dimensionality is cosmetic: the solver never reads it, so only supports can make a model planar. An under-restrained model whose loads reach the free direction is reported, not solved: that load case's errors_and_warnings carries singular_model and its converged flag is false. Read errors_and_warnings before using any number.
SECTIONS. Give a library name ("IPE300", "HEA200", "CHS 114.3x5", "RHS 100x50x5", "UB 457x191x67", …) in the member row and it is looked up and inserted with all EC3 moduli. For a rectangle or your own numbers, declare it in `sections` (same object shape as create_section) and reference it by name. A member naming no section gets the first declared section, else `default_section` (IPE300).
LOADS are in kN, kN/m and kNm and are converted to newtons internally. `loads` is a list of load cases: {"name":"G","udl":[[member, w]],"nodal_loads":[[node, Fx, Fy, Fz]],"nodal_moments":[[node, Mx, My, Mz]],"point_loads":[[member, position0to1, Fx, Fy, Fz]]}. A 2-entry udl row [member, w] is w kN/m acting DOWN (−Y); a 4-entry row [member, wx, wy, wz] is a full vector. Combine them with `load_combinations`: [{"name":"ULS","factors":{"G":1.35,"W":1.5}}], referencing load cases BY NAME; omit it for two automatic ones over every case: combination 1 "SLS characteristic (auto)" at factor 1.0 and combination 2 "ULS (auto)" at 1.35. A combination you name gets limit_state "ULS" unless you give one, because a null limit_state makes every later unity check silently evaluate nothing and report a pass.
LIMITS: 100 members on the Free tier (hard "LimitExceeded" above that), so keep element counts modest.