Auto-generate packet dataclasses with Jinja (#1374)

This commit is contained in:
Matt
2024-08-31 13:44:19 -04:00
committed by GitHub
parent c19d54c633
commit 169595e56e
140 changed files with 4445 additions and 2097 deletions

24
photon-serde/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Photon Serde Autocode
Like Rosmsg. But worse.
![](https://private-user-images.githubusercontent.com/29715865/350732914-ab8026ad-2861-49ad-b5b2-0fe7cf920d44.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjIyMjY1NTIsIm5iZiI6MTcyMjIyNjI1MiwicGF0aCI6Ii8yOTcxNTg2NS8zNTA3MzI5MTQtYWI4MDI2YWQtMjg2MS00OWFkLWI1YjItMGZlN2NmOTIwZDQ0LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA3MjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNzI5VDA0MTA1MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWI2YmQwZDQ3ZGQ3ODc5NWE0YTRhYTJkMmVmNmU4MTY2M2RiZTQ4NDIwNzQyMDdiOWJkZmMxNzQxNTgwYjE2MDYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.dhfk3QkC04gIF_MKxFGKaYUNY__AmhB6wMHSZsQadZ4)
## Goals
- As fast as possible (only slightly slower than packed structs, ideally)
- Support for variable length arrays and optional types
- Allow deserialization into user-defined, possibly nested, types. See [ResultList](src/targeting/resultlist.h) for an example of this.
## Design
The code for a single type is split across 3 files. Let's look at PnpResult:
- [The struct definition](src/struct/pnpresult_struct.h): This is the data the object holds. Auto-generated. The data this object holds can be primitives or other, fully-deserialized types (like Vec2)
- [The user class](src/targeting/pnpresult_struct.h): This is the fully-deserialized PnpResult type. This contains extra functions users might need to expose like `Amgiguity`, or other computed helper things.
- [The serde interface](src/serde/pnpresult_struct.h): This is a template specilization for converting the user class to/from bytes
## Prior art
- Protobuf: slow on embedded platforms (at least quickbuf is)
- Wpi's struct: no VLAs/optionals
- Rosmsg: I'm not using ros, but I'm stealing their message hash idea

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env python3
###############################################################################
## Copyright (C) Photon Vision.
###############################################################################
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see <https://www.gnu.org/licenses/>.
###############################################################################
import argparse
import copy
import hashlib
import os
import sys
from pathlib import Path
from typing import List, TypedDict, cast
import yaml
from jinja2 import Environment, FileSystemLoader
class SerdeField(TypedDict):
name: str
type: str
# optional extra args
optional: bool
vla: bool
class MessageType(TypedDict):
name: str
fields: List[SerdeField]
# will be 'shim' if shimmed, and the shims will be set
shimmed: bool
java_decode_shim: str
java_encode_shim: str
# C++ helpers
cpp_include: str
# python shim types
python_decode_shim: str
def yaml_to_dict(path: str):
script_dir = os.path.dirname(os.path.abspath(__file__))
yaml_file_path = os.path.join(script_dir, path)
with open(yaml_file_path, "r") as file:
file_dict: dict = yaml.safe_load(file)
return file_dict
data_types = yaml_to_dict("message_data_types.yaml")
# Helper to check if we need to use our own decoder
def is_intrinsic_type(type_str: str):
ret = type_str in data_types.keys()
return ret
# Deal with shimmed types
def get_shimmed_filter(message_db):
def is_shimmed(message_name: str):
# We don't (yet) support shimming intrinsic types
if is_intrinsic_type(message_name):
return False
message = get_message_by_name(message_db, message_name)
return "shimmed" in message and message["shimmed"] == True
return is_shimmed
def get_qualified_cpp_name(
message_db: List[MessageType], data_types, field: SerdeField
):
"""
Get the full name of the type encoded. Eg:
std::optional<photon::TargetCorner>
std::array<frc::Transform3d>
"""
if get_shimmed_filter(message_db)(field["type"]):
base_type = get_message_by_name(message_db, field["type"])["cpp_type"]
else:
base_type = data_types[field["type"]]["cpp_type"]
if "optional" in field and field["optional"] == True:
typestr = f"std::optional<{base_type}>"
elif "vla" in field and field["vla"] == True:
typestr = f"std::vector<{base_type}>"
else:
typestr = base_type
return typestr
def get_message_by_name(message_db: List[MessageType], message_name: str):
try:
return next(
message for message in message_db if message["name"] == message_name
)
except StopIteration as e:
raise Exception("Could not find " + message_name) from e
def get_field_by_name(message: MessageType, field_name: str):
return next(f for f in message["fields"] if f["name"] == field_name)
def get_message_hash(message_db: List[MessageType], message: MessageType):
"""
Calculate a unique message hash via MD5 sum. This is a very similar approach to rosmsg, documented:
http://wiki.ros.org/ROS/Technical%20Overview#Message_serialization_and_msg_MD5_sums
For non-intrinsic (user-defined) types, replace its type-string with the md5sum of the submessage definition
"""
# replace the non-intrinsic typename with its hash
modified_message = copy.deepcopy(message)
fields_to_hash = [
field
for field in modified_message["fields"]
if not is_intrinsic_type(field["type"])
]
for field in fields_to_hash:
sub_message = get_message_by_name(message_db, field["type"])
subhash = get_message_hash(message_db, sub_message)
# change the type to be our new md5sum
field["type"] = subhash.hexdigest()
# base case: message is all intrinsic types
# Hash a comments-stripped version for message integrity checking
cleaned_yaml = yaml.dump(modified_message, default_flow_style=False).strip()
message_hash = hashlib.md5(cleaned_yaml.encode("ascii"))
return message_hash
def get_includes(db, message: MessageType) -> str:
includes = []
for field in message["fields"]:
if not is_intrinsic_type(field["type"]):
field_msg = get_message_by_name(db, field["type"])
if "shimmed" in field_msg and field_msg["shimmed"] == True:
includes.append(field_msg["cpp_include"])
else:
# must be a photon type.
includes.append(f"\"photon/targeting/{field_msg['name']}.h\"")
if "optional" in field and field["optional"] == True:
includes.append("<optional>")
if "vla" in field and field["vla"] == True:
includes.append("<vector>")
# stdint types
includes.append("<stdint.h>")
return sorted(set(includes))
def parse_yaml():
Path(__file__).resolve().parent
config = yaml_to_dict("messages.yaml")
return config
def get_struct_schema_str(message: MessageType):
ret = ""
for field in message["fields"]:
typestr = field["type"]
if "optional" in field and field["optional"] == True:
typestr += "?"
if "vla" in field and field["vla"] == True:
typestr += "[?]"
ret += f"{typestr} {field['name']};"
return ret
def generate_photon_messages(cpp_java_root, py_root, template_root):
messages = parse_yaml()
env = Environment(
loader=FileSystemLoader(str(template_root)),
# autoescape=False,
# keep_trailing_newline=False,
)
env.filters["is_intrinsic"] = is_intrinsic_type
env.filters["is_shimmed"] = get_shimmed_filter(messages)
# add our custom types
extended_data_types = data_types.copy()
for message in messages:
name = message["name"]
extended_data_types[name] = {
"len": -1,
"java_type": name,
"cpp_type": "photon::" + name,
}
java_output_dir = Path(cpp_java_root) / "main/java/org/photonvision/struct"
java_output_dir.mkdir(parents=True, exist_ok=True)
cpp_serde_header_dir = Path(cpp_java_root) / "main/native/include/photon/serde/"
cpp_serde_header_dir.mkdir(parents=True, exist_ok=True)
cpp_serde_source_dir = Path(cpp_java_root) / "main/native/cpp/photon/serde/"
cpp_serde_source_dir.mkdir(parents=True, exist_ok=True)
cpp_struct_header_dir = Path(cpp_java_root) / "main/native/include/photon/struct/"
cpp_struct_header_dir.mkdir(parents=True, exist_ok=True)
py_serde_source_dir = Path(py_root)
py_serde_source_dir.mkdir(parents=True, exist_ok=True)
env.filters["get_qualified_name"] = lambda field: get_qualified_cpp_name(
messages, extended_data_types, field
)
for message in messages:
# don't generate shimmed types
if get_shimmed_filter(messages)(message["name"]):
continue
message = cast(MessageType, message)
java_name = f"{message['name']}Serde.java"
cpp_serde_header_name = f"{message['name']}Serde.h"
cpp_serde_source_name = f"{message['name']}Serde.cpp"
cpp_struct_header_name = f"{message['name']}Struct.h"
py_name = f"{message['name']}Serde.py"
java_template = env.get_template("Message.java.jinja")
cpp_serde_header_template = env.get_template("ThingSerde.h.jinja")
cpp_serde_source_template = env.get_template("ThingSerde.cpp.jinja")
cpp_struct_header_template = env.get_template("ThingStruct.h.jinja")
py_template = env.get_template("ThingSerde.py.jinja")
message_hash = get_message_hash(messages, message)
for output_name, template, output_folder in [
[java_name, java_template, java_output_dir],
[cpp_serde_header_name, cpp_serde_header_template, cpp_serde_header_dir],
[cpp_serde_source_name, cpp_serde_source_template, cpp_serde_source_dir],
[cpp_struct_header_name, cpp_struct_header_template, cpp_struct_header_dir],
[py_name, py_template, py_serde_source_dir],
]:
# Hack in our message getter
template.globals["get_message_by_name"] = lambda name: get_message_by_name(
messages, name
)
output_file = output_folder / output_name
output_file.write_text(
template.render(
message,
type_map=extended_data_types,
message_fmt=get_struct_schema_str(message),
message_hash=message_hash.hexdigest(),
cpp_includes=get_includes(messages, message),
),
encoding="utf-8",
)
def main(argv):
script_path = Path(__file__).resolve()
dirname = script_path.parent
parser = argparse.ArgumentParser()
parser.add_argument(
"--cpp_java_output_dir",
help="Optional. If set, will output the generated files to this directory, otherwise it will use a path relative to the script",
default=dirname.parent / "photon-targeting/src/generated",
type=Path,
)
parser.add_argument(
"--py_output_dir",
help="Optional. If set, will spit Python serde files here",
default=dirname.parent / "photon-lib/py/photonlibpy/generated",
type=Path,
)
parser.add_argument(
"--template_root",
help="Optional. If set, will use this directory as the root for the jinja templates",
default=dirname / "templates",
type=Path,
)
args = parser.parse_args(argv)
generate_photon_messages(
args.cpp_java_output_dir, args.py_output_dir, args.template_root
)
if __name__ == "__main__":
main(sys.argv[1:])

View File

@@ -0,0 +1,33 @@
---
bool:
# length in bytes
len: 1
java_type: bool
cpp_type: bool
java_decode_method: decodeBoolean
int16:
len: 2
java_type: short
cpp_type: int16_t
java_decode_method: decodeShort
java_list_decode_method: decodeShortList
int32:
len: 4
java_type: int
cpp_type: int32_t
java_decode_method: decodeInt
int64:
len: 8
java_type: long
cpp_type: int64_t
java_decode_method: decodeLong
float32:
len: 4
java_type: float
cpp_type: float
java_decode_method: decodeFloat
float64:
len: 8
java_type: double
cpp_type: double
java_decode_method: decodeDouble

View File

@@ -0,0 +1,90 @@
---
- name: PhotonPipelineMetadata
fields:
- name: sequenceID
type: int64
- name: captureTimestampMicros
type: int64
- name: publishTimestampMicros
type: int64
- name: Transform3d
shimmed: True
java_decode_shim: PacketUtils.unpackTransform3d
java_encode_shim: PacketUtils.packTransform3d
cpp_type: frc::Transform3d
cpp_include: "<frc/geometry/Transform3d.h>"
python_decode_shim: packet.decodeTransform
# shim since we expect fields to at least exist
fields: []
- name: TargetCorner
fields:
- name: x
type: float64
- name: y
type: float64
- name: PhotonTrackedTarget
fields:
- name: yaw
type: float64
- name: pitch
type: float64
- name: area
type: float64
- name: skew
type: float64
- name: fiducialId
type: int32
- name: objDetectId
type: int32
- name: objDetectConf
type: float32
- name: bestCameraToTarget
type: Transform3d
- name: altCameraToTarget
type: Transform3d
- name: poseAmbiguity
type: float64
- name: minAreaRectCorners
type: TargetCorner
vla: True
- name: detectedCorners
type: TargetCorner
vla: True
- name: PnpResult
fields:
- name: best
type: Transform3d
comment: "This is a comment"
- name: alt
type: Transform3d
- name: bestReprojErr
type: float64
- name: altReprojErr
type: float64
- name: ambiguity
type: float64
- name: MultiTargetPNPResult
fields:
- name: estimatedPose
type: PnpResult
- name: fiducialIDsUsed
type: int16
vla: True
- name: PhotonPipelineResult
fields:
- name: metadata
type: PhotonPipelineMetadata
- name: targets
type: PhotonTrackedTarget
vla: True
- name: multitagResult
type: MultiTargetPNPResult
optional: True

View File

@@ -0,0 +1,103 @@
/*
* Copyright (C) Photon Vision.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// THIS FILE WAS AUTO-GENERATED BY ./photon-serde/generate_messages.py. DO NOT MODIFY
package org.photonvision.struct;
import org.photonvision.common.dataflow.structures.Packet;
import org.photonvision.common.dataflow.structures.PacketSerde;
import org.photonvision.utils.PacketUtils;
// Assume that the base class lives here and we can import it
import org.photonvision.targeting.*;
/**
* Auto-generated serialization/deserialization helper for {{name}}
*/
public class {{ name }}Serde implements PacketSerde<{{name}}> {
// Message definition md5sum. See photon_packet.adoc for details
public static final String MESSAGE_VERSION = "{{ message_hash }}";
public static final String MESSAGE_FORMAT = "{{ message_fmt }}";
public final String getTypeString() { return MESSAGE_FORMAT; }
public final String getInterfaceUUID() { return MESSAGE_VERSION; }
@Override
public int getMaxByteSize() {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'getMaxByteSize'");
}
@Override
public void pack(Packet packet, {{ name }} value) {
{%- for field in fields -%}
{%- if field.type | is_shimmed %}
// field is shimmed!
{{ get_message_by_name(field.type).java_encode_shim }}(packet, value.{{ field.name }});
{%- elif field.optional == True %}
// {{ field.name }} is optional! it better not be a VLA too
packet.encodeOptional(value.{{ field.name }});
{%- elif field.vla == True and field.type | is_intrinsic %}
// {{ field.name }} is a intrinsic VLA!
packet.encode(value.{{ field.name }});
{%- elif field.vla == True %}
// {{ field.name }} is a custom VLA!
packet.encodeList(value.{{ field.name }});
{%- elif field.type | is_intrinsic %}
// field {{ field.name }} is of intrinsic type {{ field.type }}
packet.encode(({{ type_map[field.type].java_type }}) value.{{ field.name }});
{%- else %}
// field {{ field.name }} is of non-intrinsic type {{ field.type }}
{{ field.type }}.photonStruct.pack(packet, value.{{ field.name }});
{%- endif %}
{%- if not loop.last %}
{% endif -%}
{% endfor%}
}
@Override
public {{ name }} unpack(Packet packet) {
var ret = new {{ name }}();
{% for field in fields -%}
{%- if field.type | is_shimmed %}
// field is shimmed!
ret.{{ field.name }} = {{ get_message_by_name(field.type).java_decode_shim }}(packet);
{%- elif field.optional == True %}
// {{ field.name }} is optional! it better not be a VLA too
ret.{{ field.name }} = packet.decodeOptional({{ field.type }}.photonStruct);
{%- elif field.vla == True and not field.type | is_intrinsic %}
// {{ field.name }} is a custom VLA!
ret.{{ field.name }} = packet.decodeList({{ field.type }}.photonStruct);
{%- elif field.vla == True and field.type | is_intrinsic %}
// {{ field.name }} is a custom VLA!
ret.{{ field.name }} = packet.decode{{ type_map[field.type].java_type.title() }}List();
{%- elif field.type | is_intrinsic %}
// {{ field.name }} is of intrinsic type {{ field.type }}
ret.{{field.name}} = packet.{{ type_map[field.type].java_decode_method }}();
{%- else %}
// {{ field.name }} is of non-intrinsic type {{ field.type }}
ret.{{field.name}} = {{ field.type }}.photonStruct.unpack(packet);
{%- endif %}
{%- if not loop.last %}
{% endif -%}
{% endfor%}
return ret;
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) Photon Vision.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// THIS FILE WAS AUTO-GENERATED BY ./photon-serde/generate_messages.py. DO NOT MODIFY
#include "photon/serde/{{ name }}Serde.h"
namespace photon {
using StructType = SerdeType<{{ name }}>;
void StructType::Pack(Packet& packet, const {{ name }}& value) {
{% for field in fields -%}
packet.Pack<{{ field | get_qualified_name }}>(value.{{ field.name }});
{%- if not loop.last %}
{% endif -%}
{% endfor %}
}
{{ name }} StructType::Unpack(Packet& packet) {
return {{ name }}{ {{ name }}_PhotonStruct{
{% for field in fields -%}
.{{ field.name}} = packet.Unpack<{{ field | get_qualified_name }}>(),
{%- if not loop.last %}
{% endif -%}
{% endfor %}
}};
}
} // namespace photon

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Photon Vision.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
// THIS FILE WAS AUTO-GENERATED BY ./photon-serde/generate_messages.py. DO NOT MODIFY
#include <wpi/SymbolExports.h>
// Include myself
#include "photon/dataflow/structures/Packet.h"
#include "photon/targeting/{{ name }}.h"
// Includes for dependant types
{% for include in cpp_includes -%}
#include {{ include }}
{% endfor %}
namespace photon {
template <>
struct WPILIB_DLLEXPORT SerdeType<{{ name }}> {
static constexpr std::string_view GetSchemaHash() {
return "{{ message_hash }}";
}
static constexpr std::string_view GetSchema() {
return "{{ message_fmt }}";
}
static photon::{{ name }} Unpack(photon::Packet& packet);
static void Pack(photon::Packet& packet, const photon::{{ name }}& value);
};
static_assert(photon::PhotonStructSerializable<photon::{{ name }}>);
} // namespace photon

View File

@@ -0,0 +1,62 @@
###############################################################################
## Copyright (C) Photon Vision.
###############################################################################
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see <https://www.gnu.org/licenses/>.
###############################################################################
###############################################################################
## THIS FILE WAS AUTO-GENERATED BY ./photon-serde/generate_messages.py.
## --> DO NOT MODIFY <--
###############################################################################
from ..targeting import *
class {{ name }}Serde:
# Message definition md5sum. See photon_packet.adoc for details
MESSAGE_VERSION = "{{ message_hash }}"
MESSAGE_FORMAT = "{{ message_fmt }}"
@staticmethod
def unpack(packet: 'Packet') -> '{{ name }}':
ret = {{ name }}()
{% for field in fields -%}
{%- if field.type | is_shimmed %}
# field is shimmed!
ret.{{ field.name }} = {{ get_message_by_name(field.type).python_decode_shim }}()
{%- elif field.optional == True %}
# {{ field.name }} is optional! it better not be a VLA too
ret.{{ field.name }} = packet.decodeOptional({{ field.type }}.photonStruct)
{%- elif field.vla == True and not field.type | is_intrinsic %}
# {{ field.name }} is a custom VLA!
ret.{{ field.name }} = packet.decodeList({{ field.type }}.photonStruct)
{%- elif field.vla == True and field.type | is_intrinsic %}
# {{ field.name }} is a custom VLA!
ret.{{ field.name }} = packet.decode{{ type_map[field.type].java_type.title() }}List()
{%- elif field.type | is_intrinsic %}
# {{ field.name }} is of intrinsic type {{ field.type }}
ret.{{field.name}} = packet.{{ type_map[field.type].java_decode_method }}()
{%- else %}
# {{ field.name }} is of non-intrinsic type {{ field.type }}
ret.{{field.name}} = {{ field.type }}.photonStruct.unpack(packet)
{%- endif %}
{%- if not loop.last %}
{% endif -%}
{% endfor%}
return ret
# Hack ourselves into the base class
{{ name }}.photonStruct = {{ name }}Serde()

View File

@@ -0,0 +1,39 @@
/*
* Copyright (C) Photon Vision.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
// THIS FILE WAS AUTO-GENERATED BY ./photon-serde/generate_messages.py. DO NOT MODIFY
// Includes for dependant types
{% for include in cpp_includes -%}
#include {{ include }}
{% endfor %}
namespace photon {
struct {{ name }}_PhotonStruct {
{% for field in fields -%}
{{ field | get_qualified_name }} {{ field.name }};
{%- if not loop.last %}
{% endif -%}
{% endfor %}
friend bool operator==({{ name }}_PhotonStruct const&, {{ name }}_PhotonStruct const&) = default;
};
} // namespace photon