Table of Contents

About XML IO

The XML IO design pattern in HiNc Framework is based on IMakeXmlSource interface and XFactory class. This pattern provides a standardized way to serialize and deserialize objects to and from XML format.

Don't serialize the runtime member object like Func<TResult> or Action either cache object. The runtime objects can be optionally sent by the res part on the XFactory Registration or set by the other host or dependent object. If it is set by the other object, then there is nothing can do to it in the XML IO procedure.

Core Components

IMakeXmlSource Interface

The IMakeXmlSource interface defines the contract for objects that can be serialized to XML format. It contains a single method MakeXmlSource.

XFactory

XFactory is an instance class with a process-wide Default singleton (XFactory.Default). The instance form exists for test isolation and parallel pipelines that need disjoint generator registries; the static Gen<T> / GenByChild<T> / GenByFile<T> entry points always read from Default.

Each instance owns its own Generators dictionary (XML element name → generator delegate) and its TypedGenerators dictionary ((XML element name, type asked for) → generator delegate, taken before Generators for exactly that type). Types add themselves via a Reg(factory) call (see below); a custom factory is read as the static entry points read Default, TypedGenerators[(xname, typeof(T))] first, then Generators[xname].

Explicit Registration via Reg(XFactory factory = null)

Every class that XFactory reads back exposes a public static Reg method:

public static void Reg(XFactory factory = null)
{
    factory ??= XFactory.Default;
    factory.Generators.TryAdd(XName, (xml, baseDirectory, relFile, progress, res)
        => new MyClass(xml, baseDirectory, relFile, progress));
}

Key properties:

  • Explicit. Callers see registration happen — no hidden side effect from accessing a static member or constructing a type.
  • Idempotent. Uses TryAdd, so the same Reg may be invoked any number of times from any number of boot paths.
  • Composable. Custom factory instances are supported via the optional factory parameter; default usage (MyClass.Reg();) populates XFactory.Default.

A run-time-only type needs neither XName nor Reg: its MakeXmlSource returns null, and a holder that allows that leaves the entry out of the file. DictionaryColorGuide(API) is such a holder, which is how FuncDiscreteColorGuide(API) stays out of a project; see Color Guide System.

For example, see BallApt:

/// <summary>
/// Registers this type's deserializer with the given <see cref="XFactory"/>
/// (or <see cref="XFactory.Default"/> when <paramref name="factory"/> is
/// <c>null</c>). Idempotent.
/// </summary>
public static void Reg(XFactory factory = null)
{
    factory ??= XFactory.Default;
    factory.Generators.TryAdd(XName, (xml,baseDirectory, relFile, progress, res) => new BallApt(xml));
}

Composite types chain Reg(factory) on dependents

When a class deserializes child elements via XFactory.Gen<T> / XFactory.GenByChild<T>, its Reg(factory) must chain Reg(factory) on each concrete child type so the whole dependency graph is reachable from a single root call:

public static void Reg(XFactory factory = null)
{
    factory ??= XFactory.Default;

    DependentA.Reg(factory);
    DependentB.Reg(factory);

    factory.Generators.TryAdd(XName, (xml, baseDirectory, relFile, progress, res)
        => new MyComposite(xml, baseDirectory, relFile, progress));
}

For polymorphic deserialization (GenByChild<IInterface>), the composite must chain every concrete implementation that may appear in the XML. The largest composite, SoftNcRunner, chains roughly 130 dependents (every dependency, initializer, segmenter, syntax, and semantic the NC pipeline may deserialize).

Multi-name registration (legacy aliases)

When the XML payload may carry an old element name for backward compatibility, register the current XName first and group legacy aliases under a //legacy aliases comment:

public static void Reg(XFactory factory = null)
{
    factory ??= XFactory.Default;

    XFactory.XGeneratorDelegate gen = (xml, baseDirectory, relFile, progress, res)
        => new MachiningProject(xml, baseDirectory, progress);

    factory.Generators.TryAdd(XName, gen);

    //legacy aliases
    factory.Generators.TryAdd("MachiningCourse", gen);
    factory.Generators.TryAdd("MillingCourse", gen);
}

IProgress Threading

The IProgress<IMessage> parameter is threaded through the entire deserialization chain. When a class constructor calls XFactory to deserialize child objects, it passes the same progress instance:

public MyClass(XElement src, string baseDirectory, string relFile,
    IProgress<IMessage> progress)
{
    Child = XFactory.GenByChild<IChild>(
        src.Element(nameof(Child)), subBaseDirectory, progress);
}

Parsing errors are reported to the caller-provided IProgress<IMessage> handler.

What a failing child does

XFactory does not swallow a failing generator: a generator that throws propagates, and the load fails with it. The two failures it absorbs itself are an <XmlSource> file that cannot be read and an element read as another type than the one asked for (both below); every other tolerance is opted into per type, and is visible at the place that chose it.

  • A type that owns an external file catches its own read failure and reports it, then comes up without that content. StlFile reports the resolved path rather than the relative SourceFile, because the base directory is gone by the time the message is read anywhere else — a load response, a message panel — and the folder is what says which machine or part file is missing.
  • An <XmlSource> reference whose file cannot be read — missing, locked or malformed — makes XFactory return null instead of throwing: it hands the relative path back through relFile and reports XmlSource-Read--Failed, with the resolved path and the exception in its detail, to the progress sink when one is passed (with none, the null is silent). A holder that keeps a …File for the member keeps the reference, and MakeXmlSourceToFileRef writes such a null member back as the bare <XmlSource> without touching the disk, so a save does not wash the reference out; the code that reads the member skips what needs it with a message of its own. A reference a type resolves by itself through XmlUtil.LoadFromFileRef still throws, and so does the generator of a file that is read.
  • The element's name, not the type asked for, picks the generator, and the generator decides the type of the object read — unless a generator is registered for the element name together with the type asked for (TypedGenerators), which every entry point takes first, GenListSkippingUnloadable included; that is how an equipment file, read as a SetupEquipment, still answers a MachiningEquipment when asked for one. When the object read cannot be assigned to the type asked for, XFactory returns null for it and reports XFactory-Load--TypeMismatch to the progress sink, naming the element, the type it is read as and the type asked for; read the element as that type, a base class of it or an interface it implements. Reading an equipment file (root element <MachiningEquipment>, read as a SetupEquipment) as a Workpiece is such a case.
  • GenListSkippingUnloadable drops the list entries whose element name is unregistered, whose generator throws or whose element is read as another type than the list's, reporting each as an XFactory-Load--SkippedUnloadable warning; an <XmlSource> whose file cannot be read is left out with its XmlSource-Read--Failed alone. It is for lists whose schema drifts while a feature is in development — the NC pipeline's dependency, initializer and syntax lists — where a partially loadable list beats aborting the whole load.

Writing

MakeXmlSource produces the element; the file layer around it is MakeXmlSourceToFileRef, which either hands that element back inline — when no relative file is named for it — or writes it to a file of its own and returns a file-reference element in its place. That is what makes a project file a document of references instead of one large document.

Children first, parent last

MakeXmlSourceToFileRef prepares only the directory up front. The children's own side files — nested XML, STL copies — are written from inside MakeXmlSource, and the parent file itself is written by SaveToFileRef only once every child has succeeded. A child that fails — a locked or read-only side file, a full disk, a serialization bug — therefore leaves the previous version of the parent intact, rather than an empty file that points nowhere.

An existing parent is probed for write access before the children are touched, by opening it without truncating. A read-only or locked parent fails there, instead of leaving a folder of freshly written children beside an unchanged old parent.

Copy-on-save, and the file that must stay missing

StlFile serializes as its path and copies its mesh beside the new document, but only when nothing is at that target path already — the mechanism that lets one project be written under several base directories without rewriting meshes that are there. SaveStlToFile owns the directory creation, from the resolved path, and stays away from the file system entirely when CacheStl is null.

That last condition is the contract, not a guard. An STL whose source was missing when the project was read has no cached mesh and must stay missing: a zero-length file in its place reads back as an opaque loading failure instead of an exception naming the path, and it is a new untracked file in someone's project folder.

Important

exhibitionOnly suppresses the XML writes; it does not reach the copy. MakeXmlSourceToFileRef and SaveToFileRef both honour it and produce the reference elements without creating anything, but StlFile's copy-on-save does not consult it at all. So a purely in-memory round trip still copies cached meshes into a base directory that lacks them — which is what a project load does when it materialises the runtime equipment from the authored one. Writing is not confined to saving.

Boot path

An application's entry point (web service Program.cs, the App class of a WPF host, test fixture, etc.) must call the appropriate top-level Reg() once at startup, before any project XML is deserialized. For the simulation pipeline this is:

LocalProjectService.Reg();

LocalProjectService.Reg() chains MachiningProject.Reg(), which in turn chains every type the simulation pipeline may deserialize. After this single call returns, XFactory.Default.Generators and XFactory.Default.TypedGenerators carry the full deserialization graph.

Implementation Patterns

Simple Value Objects

See BallApt implementation:

/// <summary>
/// Name for XML IO.
/// </summary>
public static string XName => nameof(BallApt);
/// <summary>
/// Ctor.
/// </summary>
/// <param name="src">XML</param>
public BallApt(XElement src)
{
    Diameter_mm = XmlConvert.ToDouble(src.Element("D").Value);
    FluteHeight_mm = XmlConvert.ToDouble(src.Element("FluteH").Value);
}
/// <inheritdoc/>
public XElement MakeXmlSource(string baseDirectory, string relFile, bool exhibitionOnly) => ToXElement();

/// <inheritdoc/>
public XElement ToXElement()
{
    return new XElement(XName,
        new XElement("D", Diameter_mm),
        new XElement("FluteH", FluteHeight_mm)
        );
}

Complex Data Structures

See SpindleCapability implementation:

/// <summary>
/// Name for XML IO.
/// </summary>
public static string XName => nameof(SpindleCapability);
/// <summary>
/// Initializes a new instance of the <see cref="SpindleCapability"/> class.
/// </summary>
/// <param name="src">The XML element containing spindle data.</param>
/// <param name="baseDirectory">The base directory for resolving relative paths.</param>
/// <param name="res">Additional resolution parameters.</param>
public SpindleCapability(XElement src, string baseDirectory, params object[] res)
{
    //a note the file lacks is none. SetNameNote leaves Note as it was when
    //<Note> is missing, and the class default (TMV-720A-STD-8000RPM since
    //2025-03-25, naming the default curves) was read as this spindle's and
    //saved into the file: a spindle written before 2024-08-15 (the 1.3
    //line's single-curve format) has neither <Name> nor <Note>.
    Note = null;
    this.SetNameNote(src);
    if (src.Element(nameof(EnergyEfficiency)) != null)
        EnergyEfficiency = XmlConvert.ToDouble(
            src.Element(nameof(EnergyEfficiency)).Value);
    src.Element(nameof(WorkingTemperatureUpperBoundary_C))?.SelfInvoke(
        e => WorkingTemperatureUpperBoundary_C = XmlConvert.ToDouble(e.Value));
    src.Element(nameof(GearShiftSpindleSpeed_rpm))?.Value?.SelfInvoke(
        s => GearShiftSpindleSpeed_rpm = string.IsNullOrEmpty(s)
            ? null : XmlConvert.ToDouble(s));
    //the "D" spellings are legacy: the shipped FANUC-aT12 file carried them,
    //and XML names match case-sensitively, so its two values were ignored
    (src.Element(nameof(DryRunFrictionPowerCoefficient_mWdrpm))
        ?? src.Element("DryRunFrictionPowerCoefficient_mWDrpm"))
        ?.SelfInvoke(e => DryRunFrictionPowerCoefficient_mWdrpm =
            XmlConvert.ToDouble(e.Value));
    (src.Element(nameof(DryRunWindagePowerCoefficient_pWdrpm3))
        ?? src.Element("DryRunWindagePowerCoefficient_pWDrpm3"))
        ?.SelfInvoke(e => DryRunWindagePowerCoefficient_pWdrpm3 =
            XmlConvert.ToDouble(e.Value));

    if (src.Element("SpindleSpeedToPowerContours") != null) //for legacy
        WorkableDurationToSpindleSpeedPowerContoursDictionary_min_cycleDs_kW =
            src.Element("SpindleSpeedToPowerContours").Elements("Contour")
            .ToDictionary(
                GetLegacyContourWorkableDuration_min,
                contourElem => contourElem.Elements("SpindleSpeedToPower").Select(
                    elem => new vec2d_t(
                        XmlConvert.ToDouble(elem.Element("SpindleSpeed-RPM").Value) / 60,
                        XmlConvert.ToDouble(elem.Element("Power-kW").Value)))
                .ToList());
    src.Element("WorkableDurationToSpindleSpeedPowerContoursDictionary")
        ?.SelfInvoke(dicElem =>
        {
            WorkableDurationToSpindleSpeedPowerContoursDictionary_min_cycleDs_kW
            = dicElem.Elements("Contour")
            .ToDictionary(
                contourElem => XmlConvert.ToDouble(
                    contourElem.Attribute("WorkableDuration-min")?.Value),
                contourElem => contourElem.Elements("SpindleSpeedToPower").Select(
                    elem => new vec2d_t(
                        XmlConvert.ToDouble(elem.Element("SpindleSpeed-RPM").Value) / 60,
                        XmlConvert.ToDouble(elem.Element("Power-kW").Value)))
                .ToList());
        });

    if (src.Element("SpindleSpeedToTorqueContours") != null) //for legacy
        WorkableDurationToSpindleSpeedTorqueContoursDictionary_min_cycleDs_Nm =
            src.Element("SpindleSpeedToTorqueContours").Elements("Contour")
            .ToDictionary(
                GetLegacyContourWorkableDuration_min,
                contourElem => contourElem.Elements("SpindleSpeedToTorque").Select(
                    elem => new vec2d_t(
                        XmlConvert.ToDouble(elem.Element("SpindleSpeed-RPM").Value) / 60,
                        XmlConvert.ToDouble(elem.Element("Torque-Nm").Value)))
                .ToList());
    src.Element("WorkableDurationToSpindleSpeedTorqueContoursDictionary")
        ?.SelfInvoke(dicElem =>
        {
            //MessageUtil.WriteLine($"dicElem: {dicElem}");
            WorkableDurationToSpindleSpeedTorqueContoursDictionary_min_cycleDs_Nm =
            dicElem.Elements("Contour").ToDictionary(
                contourElem => XmlConvert.ToDouble(
                    contourElem.Attribute("WorkableDuration-min")?.Value),
                contourElem => contourElem.Elements("SpindleSpeedToTorque").Select(
                    elem => new vec2d_t(
                        XmlConvert.ToDouble(elem.Element("SpindleSpeed-RPM").Value) / 60,
                        XmlConvert.ToDouble(elem.Element("Torque-Nm").Value)))
                .ToList());
            //MessageUtil.WriteLine($"keys: {string.Join(',',WorkableDurationToSpindleSpeedTorqueContoursDictionary_min_cycleDs_Nm.Select(e=>e.Key))}");
        });

    //for legacy compatible: the single-curve format (written 2024-06-02 to
    //2025-03-04) held one curve per quantity, the continuous rating, and no
    //other contour, so the curve is the whole dictionary. Until 2026-10-15 it
    //was set into the class default as its continuous contour, beside the
    //default's 15 and 60 minute contours (TMV-720A-STD-8000RPM's, since
    //2025-03-25), and the next save wrote those as the spindle's own.
    if (src.Element("SpindleSpeedToPower--RPM-to-kW") != null)
        WorkableDurationToSpindleSpeedPowerContoursDictionary_min_cycleDs_kW = new()
        {
            [double.PositiveInfinity] =
                src.Element("SpindleSpeedToPower--RPM-to-kW").Elements()
                .Select(elem => new vec2d_t(XmlConvert.ToDouble(elem.Attribute(
                    "SpindleSpeed-RPM").Value) / 60,
                    XmlConvert.ToDouble(elem.Value))).ToList()
        };
    //for legacy compatible: the torque curve of the same format, read the same way.
    if (src.Element("SpindleSpeedToTorque--RPM-to-Nm") != null)
        WorkableDurationToSpindleSpeedTorqueContoursDictionary_min_cycleDs_Nm = new()
        {
            [double.PositiveInfinity] =
                src.Element("SpindleSpeedToTorque--RPM-to-Nm").Elements()
                .Select(elem => new vec2d_t(XmlConvert.ToDouble(elem.Attribute(
                    "SpindleSpeed-RPM").Value) / 60,
                    XmlConvert.ToDouble(elem.Value))).ToList()
        };
}
/// <summary>
/// The workable duration in minutes of a legacy contour
/// (<c>SpindleSpeedToPowerContours</c> / <c>SpindleSpeedToTorqueContours</c>),
/// whose <c>InsistentRatio</c> r is the duty ratio of a 10-minute cycle, as
/// in an S3 60% rating: r × 10 minutes, and continuous for r = 1.
/// </summary>
//The key was r × 600 from 2025-03-25 to 2026-10-06, the 600 s of the cycle
//taken for minutes (the heat capacity had taken 600 s × r before the keys
//became minutes): the shipped FANUC-aT12's S3 60% contour was read as
//360 minutes, not 6. Only the heat capacity reads the duration, so the
//spindle's thermal time constant came out 60 times too long. A file saved
//after such a read says r × 600 (360 for S3 60%) and is read as written.
static double GetLegacyContourWorkableDuration_min(XElement contourElem)
{
    double r = XmlConvert.ToDouble(contourElem.Attribute("InsistentRatio")?.Value);
    return r == 1 ? double.PositiveInfinity : r * LegacyDutyCycle_min;
}
/// <summary>
/// The duty cycle a legacy <c>InsistentRatio</c> is a ratio of, in minutes:
/// the 10 minutes of an S3 rating (IEC 60034-1).
/// </summary>
const double LegacyDutyCycle_min = 10;
/// <inheritdoc/>
public XElement MakeXmlSource(string baseDirectory, string relFile, bool exhibitionOnly)
{
    return new XElement(XName,
        this.GetNameNoteXElementList(),
        new XElement(nameof(EnergyEfficiency), EnergyEfficiency),
        //the constructor reads this element; without the matching write a
        //save/load round trip through the web API silently reverted a
        //non-default bound to the 65 C default.
        new XElement(nameof(WorkingTemperatureUpperBoundary_C),
            WorkingTemperatureUpperBoundary_C),
        new XElement(nameof(GearShiftSpindleSpeed_rpm), GearShiftSpindleSpeed_rpm),
        new XElement(nameof(DryRunFrictionPowerCoefficient_mWdrpm),
            DryRunFrictionPowerCoefficient_mWdrpm),
        new XElement(nameof(DryRunWindagePowerCoefficient_pWdrpm3),
            DryRunWindagePowerCoefficient_pWdrpm3),
        new XElement("WorkableDurationToSpindleSpeedPowerContoursDictionary",
            WorkableDurationToSpindleSpeedPowerContoursDictionary_min_cycleDs_kW.OrderBy(entry => entry.Key)
            .Select(entry => new XElement("Contour",
                new XAttribute("WorkableDuration-min", entry.Key),
                entry.Value.Select(entry
                => new XElement("SpindleSpeedToPower",
                new XElement("SpindleSpeed-RPM", entry.X * 60),
                new XElement("Power-kW", entry.Y)))))
            ),
        new XElement("WorkableDurationToSpindleSpeedTorqueContoursDictionary",
            WorkableDurationToSpindleSpeedTorqueContoursDictionary_min_cycleDs_Nm.OrderBy(entry => entry.Key)
            .Select(entry => new XElement("Contour",
                new XAttribute("WorkableDuration-min", entry.Key),
                entry.Value.Select(entry
                => new XElement("SpindleSpeedToTorque",
                new XElement("SpindleSpeed-RPM", entry.X * 60),
                new XElement("Torque-Nm", entry.Y)))))
            )
        );
}

Best Practices

  1. XName: Always define static XName property matching the class name — on every type that is read back; a run-time-only type, whose MakeXmlSource returns null, has none.
  2. Registration: Expose public static void Reg(XFactory factory = null); first line is factory ??= XFactory.Default; then factory.Generators.TryAdd(XName, …).
  3. Chain dependents: For every concrete type T that the ctor reads via XFactory.Gen<T> / XFactory.GenByChild<T>, add T.Reg(factory); to the chain. For polymorphic GenByChild<IInterface>, chain every implementation that the XML may carry.
  4. Idempotent: Use TryAdd, never Add. The same Reg is called from many boot paths.
  5. Progress Threading: Pass the IProgress<IMessage> parameter through all nested XFactory calls. See Message Management for the rationale.
  6. Legacy Support: Register the canonical XName first, then group aliases under a //legacy aliases comment.
  7. Derived class registration: When a derived class needs its own Reg, mark it public new static void Reg(XFactory factory = null) so the C# compiler does not warn about hiding the base method.
  8. Side files from inside MakeXmlSource, never ahead of it: an implementation that owns an external file writes it from within MakeXmlSource, which is what keeps the framework's children-first ordering true. It must not create a file for content it does not hold — a source that was missing at load time stays missing, because an empty file is harder to diagnose than an absent one.

See Also