• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thanos / codecs / 30231a79d2c418774e5718ceaf6db4c17b621aa9

16 Jul 2026 11:42PM UTC coverage: 98.7% (+3.8%) from 94.891%
30231a79d2c418774e5718ceaf6db4c17b621aa9

push

github

web-flow
Merge pull request #33 from thanos/v0.2.0/Spatial_Codecs

V0.2.0/spatial codecs

651 of 660 new or added lines in 22 files covered. (98.64%)

759 of 769 relevant lines covered (98.7%)

70.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.51
/lib/ex_codecs/spatial/codec/binary.ex
1
defmodule ExCodecs.Spatial.Codec.Binary do
2
  @moduledoc """
3
  Compact little-endian binary format for point clouds.
4

5
  ## Layout
6

7
      magic:   "EXCP" (4 bytes)
8
      version: u16 LE = 1
9
      flags:   u16 LE  (bit0=color, bit1=alpha, bit2=normal)
10
      count:   u64 LE
11
      records: count × record
12

13
  Each record always has `x,y,z` as `f32`. Optional `rgb`/`rgba` as `u8`
14
  and normals as `f32` follow according to flags.
15

16
  Generic attributes are not stored in this format — use PLY for that.
17
  """
18

19
  alias ExCodecs.Error
20
  alias ExCodecs.Spatial.{Metadata, Point, PointCloud}
21

22
  @magic "EXCP"
23
  @version 1
24

25
  @flag_color 0b001
26
  @flag_alpha 0b010
27
  @flag_normal 0b100
28

29
  @doc """
30
  Encodes a point cloud in the EXCP version 1 binary format.
31

32
  The 16-byte header is `"EXCP"`, version `u16` little-endian, global flags
33
  `u16` little-endian, and point count `u64` little-endian. Each record starts
34
  with three little-endian `f32` coordinates. Global flag bits then add RGB
35
  (3 bytes), RGBA (4 bytes; alpha takes precedence), and/or three little-endian
36
  `f32` normal components to every record.
37

38
  Flags are selected from the whole cloud. Consequently, points missing an
39
  optional field are zero-filled (missing alpha is 255). Point attributes,
40
  cloud bounds, transform, and metadata are not represented.
41

42
  ## Arguments
43

44
    * `data` (`PointCloud.t()`) — a cloud whose `points` are valid `%Point{}`
45
      structs with numeric coordinates, optional `{r, g, b}` or
46
      `{r, g, b, a}` color, and optional `{nx, ny, nz}` normal.
47
    * `opts` (`keyword()`) — reserved and currently ignored.
48

49
  ## Returns
50

51
    * `{:ok, payload}` where `payload` is an EXCP `binary()`.
52
    * `{:error, %ExCodecs.Error{reason: :invalid_data,
53
      codec: :spatial_binary}}` when `data` is not a `%PointCloud{}`.
54

55
  ## Raises / exceptions
56

57
  A wrong top-level type is returned as `:invalid_data`. Because `opts` is
58
  ignored, even a non-keyword value does not raise. Manually malformed cloud or
59
  point structs can raise `FunctionClauseError`, `MatchError`, `ArgumentError`,
60
  or a bitstring construction exception when a point, tuple shape, channel
61
  range, or numeric field violates the struct contract.
62

63
  ## Examples
64

65
      iex> alias ExCodecs.Spatial.{Point, PointCloud}
66
      iex> cloud = PointCloud.new([Point.new(1, 2, 3, color: {10, 20, 30})])
67
      iex> {:ok, <<"EXCP", 1::little-16, flags::little-16, 1::little-64, _::binary>>} =
68
      ...>   ExCodecs.Spatial.Codec.Binary.encode(cloud)
69
      iex> Bitwise.band(flags, 0b001)
70
      1
71
  """
72
  @spec encode(PointCloud.t(), keyword()) :: {:ok, binary()} | {:error, Error.t()}
73
  def encode(data, opts \\ [])
7✔
74

75
  def encode(%PointCloud{points: points}, _opts) do
76
    has_color? = Enum.any?(points, &Point.colored?/1)
12✔
77
    has_alpha? = Enum.any?(points, fn p -> match?({_, _, _, _}, p.color) end)
12✔
78
    has_normal? = Enum.any?(points, &Point.has_normal?/1)
12✔
79

80
    flags =
12✔
81
      0
82
      |> then(fn f -> if has_color?, do: Bitwise.bor(f, @flag_color), else: f end)
12✔
83
      |> then(fn f -> if has_alpha?, do: Bitwise.bor(f, @flag_alpha), else: f end)
12✔
84
      |> then(fn f -> if has_normal?, do: Bitwise.bor(f, @flag_normal), else: f end)
12✔
85

86
    header =
12✔
87
      <<@magic::binary, @version::little-unsigned-16, flags::little-unsigned-16,
88
        length(points)::little-unsigned-64>>
89

90
    body =
12✔
91
      IO.iodata_to_binary(
92
        Enum.map(points, fn p ->
93
          encode_point(p, flags)
17✔
94
        end)
95
      )
96

97
    {:ok, header <> body}
98
  end
99

100
  def encode(_, _) do
1✔
101
    {:error,
102
     Error.new(:invalid_data,
103
       codec: :spatial_binary,
104
       message: "Binary encode expects a PointCloud"
105
     )}
106
  end
107

108
  @doc """
109
  Decodes an EXCP version 1 payload into a point cloud.
110

111
  The decoder reads the global color/alpha/normal flags and the declared number
112
  of fixed-shape records described by `encode/2`. Trailing bytes are currently
113
  ignored. Decoded metadata contains `%{"format" => "excp", "version" => 1}`;
114
  attributes and other cloud-level fields use their constructor defaults.
115

116
  ## Arguments
117

118
    * `data` (`binary()`) — a complete EXCP payload.
119
    * `opts` (`keyword()`) — reserved and currently ignored.
120

121
  ## Returns
122

123
    * `{:ok, %PointCloud{}}`
124
    * `{:error, %ExCodecs.Error{reason: :invalid_data,
125
      codec: :spatial_binary}}` when the magic/header is invalid or too short,
126
      the version is not 1, or a declared coordinate, RGB/RGBA, or normal field
127
      is truncated.
128

129
  ## Raises / exceptions
130

131
  Corrupt/truncated payloads covered above are returned, and `opts` is ignored.
132
  This function has a catch-all data clause, so non-binary input also returns
133
  `:invalid_data`; it does not intentionally raise for external payloads.
134

135
  ## Examples
136

137
      iex> alias ExCodecs.Spatial.{Point, PointCloud}
138
      iex> {:ok, bin} = ExCodecs.Spatial.Codec.Binary.encode(PointCloud.new([Point.new(1.0, 2.0, 3.0)]))
139
      iex> {:ok, %PointCloud{points: [p]}} = ExCodecs.Spatial.Codec.Binary.decode(bin)
140
      iex> p.x
141
      1.0
142
  """
143
  @spec decode(binary(), keyword()) :: {:ok, PointCloud.t()} | {:error, Error.t()}
144
  def decode(data, opts \\ [])
11✔
145

146
  def decode(
147
        <<@magic::binary, version::little-unsigned-16, flags::little-unsigned-16,
148
          count::little-unsigned-64, rest::binary>>,
149
        _opts
150
      ) do
151
    if version != @version do
16✔
152
      {:error,
153
       Error.new(:invalid_data,
154
         codec: :spatial_binary,
155
         message: "Unsupported binary point format version #{version}"
1✔
156
       )}
157
    else
158
      with {:ok, points, _} <- decode_points(rest, count, flags) do
15✔
159
        meta = Metadata.new(entries: %{"format" => "excp", "version" => version})
11✔
160
        {:ok, PointCloud.new(points, metadata: meta)}
161
      end
162
    end
163
  end
164

165
  def decode(_, _) do
2✔
166
    {:error,
167
     Error.new(:invalid_data,
168
       codec: :spatial_binary,
169
       message: "Invalid ExCodecs binary point cloud"
170
     )}
171
  end
172

173
  @doc """
174
  Returns an enumerable over points in an EXCP payload.
175

176
  ## Arguments
177

178
    * `data` (`binary()`) — a complete EXCP payload.
179
    * `opts` (`keyword()`) — reserved and currently ignored.
180

181
  ## Returns
182

183
  An `Enumerable.t()` that yields decoded `%Point{}` values after the complete
184
  cloud has been materialized. If `decode/2` fails, it yields exactly one
185
  `{:error, %ExCodecs.Error{reason: :invalid_data,
186
  codec: :spatial_binary}}` element.
187

188
  ## Raises / exceptions
189

190
  Raises `FunctionClauseError` when `data` is not a binary because this public
191
  function is guarded. `opts` is ignored. Binary validation failures are
192
  delayed as the single error element rather than raised.
193

194
  ## Examples
195

196
      iex> alias ExCodecs.Spatial.{Point, PointCloud}
197
      iex> {:ok, bin} = ExCodecs.Spatial.Codec.Binary.encode(PointCloud.new([Point.new(0, 0, 0)]))
198
      iex> [%Point{x: 0.0, y: 0.0, z: 0.0}] =
199
      ...>   ExCodecs.Spatial.Codec.Binary.stream_decode(bin) |> Enum.to_list()
200
  """
201
  @spec stream_decode(binary(), keyword()) :: Enumerable.t()
202
  def stream_decode(data, opts \\ []) when is_binary(data) do
203
    case decode(data, opts) do
6✔
204
      {:ok, %PointCloud{points: points}} ->
205
        Stream.map(points, & &1)
5✔
206

207
      {:error, error} ->
208
        Stream.resource(
1✔
209
          fn -> {:error, error} end,
1✔
210
          fn
211
            {:error, e} -> {[{:error, e}], :done}
1✔
212
            :done -> {:halt, :done}
1✔
213
          end,
214
          fn _ -> :ok end
1✔
215
        )
216
    end
217
  end
218

219
  defp encode_point(%Point{} = p, flags) do
220
    xyz = <<p.x::little-float-32, p.y::little-float-32, p.z::little-float-32>>
17✔
221

222
    color =
17✔
223
      cond do
224
        Bitwise.band(flags, @flag_alpha) != 0 ->
225
          {r, g, b, a} = normalize_rgba(p.color)
4✔
226
          <<r::8, g::8, b::8, a::8>>
4✔
227

228
        Bitwise.band(flags, @flag_color) != 0 ->
13✔
229
          {r, g, b} = normalize_rgb(p.color)
4✔
230
          <<r::8, g::8, b::8>>
4✔
231

232
        true ->
9✔
233
          <<>>
9✔
234
      end
235

236
    normal =
17✔
237
      if Bitwise.band(flags, @flag_normal) != 0 do
238
        {nx, ny, nz} = p.normal || {0.0, 0.0, 0.0}
4✔
239
        <<nx::little-float-32, ny::little-float-32, nz::little-float-32>>
4✔
240
      else
241
        <<>>
13✔
242
      end
243

244
    xyz <> color <> normal
17✔
245
  end
246

247
  defp normalize_rgb(nil), do: {0, 0, 0}
1✔
248
  defp normalize_rgb({r, g, b}), do: {trunc(r), trunc(g), trunc(b)}
3✔
NEW
249
  defp normalize_rgb({r, g, b, _a}), do: {trunc(r), trunc(g), trunc(b)}
×
250

251
  defp normalize_rgba(nil), do: {0, 0, 0, 255}
1✔
252
  defp normalize_rgba({r, g, b}), do: {trunc(r), trunc(g), trunc(b), 255}
1✔
253
  defp normalize_rgba({r, g, b, a}), do: {trunc(r), trunc(g), trunc(b), trunc(a)}
2✔
254

255
  defp decode_points(bin, 0, _flags), do: {:ok, [], bin}
1✔
256

257
  defp decode_points(bin, count, flags) do
258
    Enum.reduce_while(1..count, {:ok, [], bin}, fn _, {:ok, acc, rest} ->
259
      case decode_point(rest, flags) do
19✔
260
        {:ok, point, next} -> {:cont, {:ok, [point | acc], next}}
15✔
261
        {:error, _} = err -> {:halt, err}
4✔
262
      end
263
    end)
264
    |> case do
14✔
265
      {:ok, points, rest} -> {:ok, Enum.reverse(points), rest}
10✔
266
      other -> other
4✔
267
    end
268
  end
269

270
  defp decode_point(
271
         <<x::little-float-32, y::little-float-32, z::little-float-32, rest::binary>>,
272
         flags
273
       ) do
274
    with {:ok, color, rest} <- take_color(rest, flags),
18✔
275
         {:ok, normal, rest} <- take_normal(rest, flags) do
16✔
276
      {:ok, Point.new(x, y, z, color: color, normal: normal), rest}
15✔
277
    end
278
  end
279

280
  defp decode_point(_, _),
1✔
281
    do:
282
      {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated point record")}
283

284
  defp take_color(bin, flags) when Bitwise.band(flags, @flag_alpha) != 0 do
285
    case bin do
5✔
286
      <<r::8, g::8, b::8, a::8, rest::binary>> -> {:ok, {r, g, b, a}, rest}
4✔
287
      _ -> {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated RGBA")}
1✔
288
    end
289
  end
290

291
  defp take_color(bin, flags) when Bitwise.band(flags, @flag_color) != 0 do
292
    case bin do
5✔
293
      <<r::8, g::8, b::8, rest::binary>> -> {:ok, {r, g, b}, rest}
4✔
294
      _ -> {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated RGB")}
1✔
295
    end
296
  end
297

298
  defp take_color(bin, _), do: {:ok, nil, bin}
8✔
299

300
  defp take_normal(bin, flags) when Bitwise.band(flags, @flag_normal) != 0 do
301
    case bin do
3✔
302
      <<nx::little-float-32, ny::little-float-32, nz::little-float-32, rest::binary>> ->
303
        {:ok, {nx, ny, nz}, rest}
2✔
304

305
      _ ->
1✔
306
        {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated normal")}
307
    end
308
  end
309

310
  defp take_normal(bin, _), do: {:ok, nil, bin}
13✔
311
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc