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

thanos / codecs / 75339ba0a33797cf027c853bdfb9defb7f4317ab

18 Jul 2026 07:45PM UTC coverage: 95.392% (-3.3%) from 98.7%
75339ba0a33797cf027c853bdfb9defb7f4317ab

push

github

thanos
Merge branch 'livebookfixes'

1242 of 1302 relevant lines covered (95.39%)

588.2 hits per line

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

96.33
/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.Accel
21
  alias ExCodecs.Spatial.Codec.StreamSource
22
  alias ExCodecs.Spatial.{Metadata, Point, PointCloud}
23

24
  @magic "EXCP"
25
  @version 1
26

27
  @flag_color 0b001
28
  @flag_alpha 0b010
29
  @flag_normal 0b100
30

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

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

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

44
  ## Arguments
45

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

51
  ## Returns
52

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

57
  ## Raises / exceptions
58

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

65
  ## Examples
66

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

77
  def encode(%PointCloud{points: points}, opts) do
78
    {flags, count} =
422✔
79
      Enum.reduce(points, {0, 0}, fn p, {f, n} ->
80
        f =
5,962✔
81
          case p.color do
5,962✔
82
            {_, _, _, _} -> Bitwise.bor(f, Bitwise.bor(@flag_color, @flag_alpha))
1,268✔
83
            {_, _, _} -> Bitwise.bor(f, @flag_color)
2,318✔
84
            nil -> f
2,376✔
85
          end
86

87
        f = if Point.has_normal?(p), do: Bitwise.bor(f, @flag_normal), else: f
5,962✔
88
        {f, n + 1}
89
      end)
90

91
    header =
422✔
92
      <<@magic::binary, @version::little-unsigned-16, flags::little-unsigned-16,
93
        count::little-unsigned-64>>
94

95
    body = encode_points_body(points, flags, opts)
422✔
96
    {:ok, header <> body}
97
  end
98

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

107
  @doc """
108
  Streams `%Point{}` values to an EXCP file using an explicit schema.
109

110
  Writes a placeholder header, encodes each point as it arrives, then seeks
111
  back to patch the final count. Peak memory is O(one point), not the cloud.
112

113
  ## Arguments
114

115
    * `enumerable` (`Enumerable.t()`)  -  `%Point{}` elements.
116
    * `path` (`Path.t()`)  -  destination file path.
117
    * `opts` (`keyword()`)  -  requires `:schema`, a list such as `[]`,
118
      `[:color]`, `[:color, :alpha]`, `[:normal]`, or `[:color, :normal]`.
119
      Map form `%{color: true, alpha: false, normal: true}` is also accepted.
120
      `:alpha` implies color bytes (RGBA).
121

122
  ## Returns
123

124
    * `:ok`
125
    * `{:error, %ExCodecs.Error{reason: :invalid_options}}` when `:schema` is
126
      missing or invalid
127
    * `{:error, %ExCodecs.Error{reason: :invalid_data}}` when an element is not
128
      a `%Point{}`
129
    * `{:error, %ExCodecs.Error{reason: :io_error}}` on file failures
130

131
  ## Raises / exceptions
132

133
  Schema and non-`%Point{}` element failures are returned. Invalid path terms or
134
  non-keyword `opts` can raise `FunctionClauseError` or `ArgumentError` in the
135
  path/file/keyword APIs. Malformed point field shapes can raise during
136
  bitstring construction (same class of exceptions as `encode/2`).
137

138
  ## Examples
139

140
      iex> alias ExCodecs.Spatial.{Point, Codec.Binary}
141
      iex> path = Path.join(System.tmp_dir!(), "excp_enc_#{System.unique_integer([:positive])}.excp")
142
      iex> :ok = Binary.stream_encode_to_file([Point.new(1, 2, 3, color: {1, 2, 3})], path, schema: [:color])
143
      iex> {:ok, <<"EXCP", _::binary>>} = File.read(path)
144
      iex> File.rm!(path)
145
      :ok
146
  """
147
  @spec stream_encode_to_file(Enumerable.t(), Path.t(), keyword()) :: :ok | {:error, Error.t()}
148
  def stream_encode_to_file(enumerable, path, opts \\ []) do
149
    with {:ok, flags} <- fetch_schema_flags(opts),
19✔
150
         {:ok, io} <- open_write(path) do
14✔
151
      try do
13✔
152
        with :ok <- binwrite(io, excp_header(flags, 0)),
13✔
153
             {:ok, count} <- write_point_chunks(enumerable, io, flags, opts),
13✔
154
             {:ok, _} <- :file.position(io, 0),
12✔
155
             :ok <- binwrite(io, excp_header(flags, count)) do
12✔
156
          :ok
157
        else
158
          {:error, %Error{}} = err -> err
×
159
          {:error, reason} -> io_error(reason)
×
160
        end
161
      catch
162
        {:bad_point, other} ->
1✔
163
          {:error,
164
           Error.new(:invalid_data,
165
             codec: :spatial_binary,
166
             message: "EXCP stream encode expects Point structs, got: #{inspect(other)}"
167
           )}
168
      after
169
        File.close(io)
13✔
170
      end
171
    end
172
  end
173

174
  defp fetch_schema_flags(opts) do
175
    case Keyword.fetch(opts, :schema) do
19✔
176
      :error ->
1✔
177
        {:error,
178
         Error.new(:invalid_options,
179
           codec: :spatial_binary,
180
           message: "EXCP stream_encode_to_file requires schema: (e.g. schema: [:color])"
181
         )}
182

183
      {:ok, schema} ->
184
        schema_to_flags(schema)
18✔
185
    end
186
  end
187

188
  defp schema_to_flags(schema) when is_list(schema) do
189
    alpha? = schema_flag?(schema, :alpha)
17✔
190
    color? = alpha? or schema_flag?(schema, :color)
17✔
191
    normal? = schema_flag?(schema, :normal)
17✔
192

193
    unknown = Enum.reject(schema, &known_excp_schema_entry?/1)
17✔
194

195
    if unknown != [] do
17✔
196
      {:error,
197
       Error.new(:invalid_options,
198
         codec: :spatial_binary,
199
         message: "Unknown EXCP schema entries: #{inspect(unknown)}"
200
       )}
201
    else
202
      {:ok, flags_from_schema(color?, alpha?, normal?)}
203
    end
204
  end
205

206
  defp schema_to_flags(schema) when is_map(schema) do
207
    schema_to_flags(Map.to_list(schema))
1✔
208
  end
209

210
  defp schema_to_flags(other) do
1✔
211
    {:error,
212
     Error.new(:invalid_options,
213
       codec: :spatial_binary,
214
       message: "EXCP schema must be a list or map, got: #{inspect(other)}"
215
     )}
216
  end
217

218
  defp known_excp_schema_entry?(entry) when entry in [:color, :alpha, :normal], do: true
13✔
219
  defp known_excp_schema_entry?({key, _}) when key in [:color, :alpha, :normal], do: true
8✔
220
  defp known_excp_schema_entry?(_), do: false
3✔
221

222
  defp flags_from_schema(color?, alpha?, normal?) do
223
    0
224
    |> maybe_flag(color?, @flag_color)
225
    |> maybe_flag(alpha?, @flag_alpha)
226
    |> maybe_flag(normal?, @flag_normal)
14✔
227
  end
228

229
  defp maybe_flag(flags, true, bit), do: Bitwise.bor(flags, bit)
18✔
230
  defp maybe_flag(flags, false, _bit), do: flags
24✔
231

232
  defp schema_flag?(schema, key) when is_list(schema) do
233
    key in schema or Keyword.get(schema, key, false) == true
47✔
234
  end
235

236
  defp excp_header(flags, count) do
237
    <<@magic::binary, @version::little-unsigned-16, flags::little-unsigned-16,
25✔
238
      count::little-unsigned-64>>
239
  end
240

241
  defp open_write(path) do
242
    case File.open(path, [:write, :binary, :raw, :read]) do
14✔
243
      {:ok, io} -> {:ok, io}
13✔
244
      {:error, reason} -> io_error(reason)
1✔
245
    end
246
  end
247

248
  @doc """
249
  Decodes an EXCP version 1 payload into a point cloud.
250

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

256
  ## Arguments
257

258
    * `data` (`binary()`)  -  a complete EXCP payload.
259
    * `opts` (`keyword()`)  -  reserved and currently ignored.
260

261
  ## Returns
262

263
    * `{:ok, %PointCloud{}}`
264
    * `{:error, %ExCodecs.Error{reason: :invalid_data,
265
      codec: :spatial_binary}}` when the magic/header is invalid or too short,
266
      the version is not 1, or a declared coordinate, RGB/RGBA, or normal field
267
      is truncated.
268

269
  ## Raises / exceptions
270

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

275
  ## Examples
276

277
      iex> alias ExCodecs.Spatial.{Point, PointCloud}
278
      iex> {:ok, bin} = ExCodecs.Spatial.Codec.Binary.encode(PointCloud.new([Point.new(1.0, 2.0, 3.0)]))
279
      iex> {:ok, %PointCloud{points: [p]}} = ExCodecs.Spatial.Codec.Binary.decode(bin)
280
      iex> p.x
281
      1.0
282
  """
283
  @spec decode(binary(), keyword()) :: {:ok, PointCloud.t()} | {:error, Error.t()}
284
  def decode(data, opts \\ [])
16✔
285

286
  def decode(
287
        <<@magic::binary, version::little-unsigned-16, flags::little-unsigned-16,
288
          count::little-unsigned-64, rest::binary>>,
289
        opts
290
      ) do
291
    if version != @version do
320✔
292
      {:error,
293
       Error.new(:invalid_data,
294
         codec: :spatial_binary,
295
         message: "Unsupported binary point format version #{version}"
1✔
296
       )}
297
    else
298
      decode_v1(flags, count, rest, version, opts)
319✔
299
    end
300
  end
301

302
  def decode(_, _) do
2✔
303
    {:error,
304
     Error.new(:invalid_data,
305
       codec: :spatial_binary,
306
       message: "Invalid ExCodecs binary point cloud"
307
     )}
308
  end
309

310
  defp decode_v1(flags, count, rest, version, opts) do
311
    known_flags = Bitwise.bor(@flag_color, Bitwise.bor(@flag_alpha, @flag_normal))
319✔
312

313
    if Bitwise.band(flags, Bitwise.bnot(known_flags)) != 0 do
319✔
314
      {:error,
315
       Error.new(:invalid_data,
316
         codec: :spatial_binary,
317
         message: "Unknown EXCP flag bits: 0x#{Integer.to_string(flags, 16)}"
×
318
       )}
319
    else
320
      with {:ok, points, _} <- decode_points(rest, count, flags, opts) do
319✔
321
        meta = Metadata.new(entries: %{"format" => "excp", "version" => version})
315✔
322
        {:ok, PointCloud.new(points, metadata: meta)}
323
      end
324
    end
325
  end
326

327
  @doc """
328
  Returns an enumerable over points in an EXCP payload or file.
329

330
  ## Streaming behavior
331

332
    * `source: :file` (or `:auto` path detection): header then chunked
333
      records  -  Rust mmap + DirtyCpu unpack when available, otherwise
334
      `IO.binread/2` per record.
335
    * `source: :binary`: chunked Rust unpack when available; otherwise
336
      materializes through `decode/2`.
337

338
  Prefer `source: :file` for multi-MB / multi-GB `.excp` paths.
339
  Pass `accel: false` to force the pure-Elixir path.
340

341
  ## Arguments
342

343
    * `source` (`Path.t() | binary()`)  -  filesystem path or complete EXCP
344
      payload. Both are binaries, so `:source` controls resolution.
345
    * `opts` (`keyword()`)  -  `:source` may be `:binary` (default), `:file`, or
346
      `:auto`. Prefer `:file` for paths and `:binary` for payloads; `:auto` is
347
      opt-in path sniffing (see `docs/spatial_formats.md`).
348
      `:binary`.
349

350
  ## Returns
351

352
  An `Enumerable.t()` yielding `%Point{}` values. Failures are delayed until
353
  enumeration as exactly one `{:error, %ExCodecs.Error{}}`:
354

355
    * `reason: :io_error`  -  the file cannot be opened or read
356
    * `reason: :invalid_data`  -  bad magic/version, truncated header, or a
357
      truncated record mid-stream (`codec: :spatial_binary`)
358

359
  ## Raises / exceptions
360

361
  Raises `FunctionClauseError` when `source` is not a binary. Unsupported
362
  `:source` values raise `CaseClauseError`.
363

364
  ## Examples
365

366
      iex> alias ExCodecs.Spatial.{Point, PointCloud}
367
      iex> {:ok, bin} = ExCodecs.Spatial.Codec.Binary.encode(PointCloud.new([Point.new(0, 0, 0)]))
368
      iex> [%Point{x: 0.0, y: 0.0, z: 0.0}] =
369
      ...>   ExCodecs.Spatial.Codec.Binary.stream_decode(bin) |> Enum.to_list()
370
  """
371
  @spec stream_decode(Path.t() | binary(), keyword()) :: Enumerable.t()
372
  def stream_decode(source, opts \\ [])
1✔
373

374
  def stream_decode(data, opts) when is_binary(data) do
375
    case StreamSource.resolve(data, opts, :spatial_binary, &path_like?/1) do
136✔
376
      {:ok, :binary, bin} ->
377
        stream_from_binary(bin, opts)
10✔
378

379
      {:ok, :file, path} ->
380
        Stream.resource(
126✔
381
          fn -> open_excp_file(path, opts) end,
126✔
382
          &next_excp_item/1,
383
          &close_excp_file/1
384
        )
385

386
      {:error, error} ->
387
        error_stream(error)
×
388
    end
389
  end
390

391
  defp stream_from_binary(bin, opts) do
392
    if accel?(opts) do
10✔
393
      stream_from_binary_accel(bin)
8✔
394
    else
395
      case decode(bin, Keyword.put(opts, :accel, false)) do
2✔
396
        {:ok, %PointCloud{points: points}} -> Stream.map(points, & &1)
1✔
397
        {:error, error} -> error_stream(error)
1✔
398
      end
399
    end
400
  end
401

402
  defp stream_from_binary_accel(
403
         <<@magic::binary, version::little-unsigned-16, flags::little-unsigned-16,
404
           count::little-unsigned-64, body::binary>>
405
       ) do
406
    if version != @version do
6✔
407
      error_stream(
1✔
408
        Error.new(:invalid_data,
409
          codec: :spatial_binary,
410
          message: "Unsupported binary point format version #{version}"
1✔
411
        )
412
      )
413
    else
414
      Stream.resource(
5✔
415
        fn -> {:bin, body, flags, count, 0, 0} end,
5✔
416
        &next_excp_accel/1,
417
        fn _ -> :ok end
5✔
418
      )
419
    end
420
  end
421

422
  defp stream_from_binary_accel(_) do
423
    error_stream(
2✔
424
      Error.new(:invalid_data,
425
        codec: :spatial_binary,
426
        message: "Invalid ExCodecs binary point cloud"
427
      )
428
    )
429
  end
430

431
  defp path_like?(bin) do
432
    StreamSource.path_like?(bin, &String.starts_with?(&1, @magic), [".excp", ".bin"])
1✔
433
  end
434

435
  defp open_excp_file(path, opts) do
436
    if accel?(opts) do
126✔
437
      open_excp_mmap(path)
118✔
438
    else
439
      open_excp_io(path)
8✔
440
    end
441
  end
442

443
  defp open_excp_io(path) do
444
    case File.open(path, [:read, :binary, :raw]) do
8✔
445
      {:ok, io} -> parse_excp_header(io, IO.binread(io, 16))
7✔
446
      {:error, reason} -> io_error(reason)
1✔
447
    end
448
  end
449

450
  defp open_excp_mmap(path) do
451
    with {:ok, header} <- read_file_prefix(path, 16),
118✔
452
         {:ok, ref} <- Accel.mmap_open(path),
116✔
453
         {:ok, state} <- mmap_state_from_header(ref, header) do
116✔
454
      state
112✔
455
    else
456
      # coveralls-ignore-start
457
      {:error, :nif_not_loaded} ->
458
        open_excp_io(path)
459

460
      # coveralls-ignore-stop
461
      {:error, reason} when is_atom(reason) ->
462
        io_error(reason)
2✔
463

464
      {:error, %Error{}} = err ->
465
        err
4✔
466

467
      # coveralls-ignore-start
468
      {:error, other} ->
469
        io_error(other)
470
        # coveralls-ignore-stop
471
    end
472
  end
473

474
  defp mmap_state_from_header(
475
         ref,
476
         <<@magic::binary, version::little-unsigned-16, flags::little-unsigned-16,
477
           count::little-unsigned-64>>
478
       ) do
479
    if version != @version do
113✔
480
      {:error,
481
       Error.new(:invalid_data,
482
         codec: :spatial_binary,
483
         message: "Unsupported binary point format version #{version}"
1✔
484
       )}
485
    else
486
      {:ok, {:mmap, ref, flags, count, 16, 0}}
487
    end
488
  end
489

490
  defp mmap_state_from_header(_ref, _header) do
3✔
491
    {:error,
492
     Error.new(:invalid_data,
493
       codec: :spatial_binary,
494
       message: "Invalid ExCodecs binary point cloud"
495
     )}
496
  end
497

498
  defp read_file_prefix(path, n) do
499
    case File.open(path, [:read, :binary, :raw]) do
118✔
500
      {:ok, io} ->
501
        data = IO.binread(io, n)
116✔
502
        File.close(io)
116✔
503

504
        case data do
116✔
505
          bin when is_binary(bin) ->
115✔
506
            {:ok, bin}
507

508
          :eof ->
1✔
509
            {:ok, <<>>}
510

511
          # coveralls-ignore-start
512
          {:error, reason} ->
513
            {:error, reason}
514
            # coveralls-ignore-stop
515
        end
516

517
      {:error, reason} ->
2✔
518
        {:error, reason}
519
    end
520
  end
521

522
  defp parse_excp_header(
523
         io,
524
         <<@magic::binary, version::little-unsigned-16, flags::little-unsigned-16,
525
           count::little-unsigned-64>>
526
       ) do
527
    if version != @version do
5✔
528
      File.close(io)
1✔
529

530
      {:error,
531
       Error.new(:invalid_data,
532
         codec: :spatial_binary,
533
         message: "Unsupported binary point format version #{version}"
1✔
534
       )}
535
    else
536
      {:ok, io, flags, count, record_stride(flags), 0}
4✔
537
    end
538
  end
539

540
  defp parse_excp_header(io, :eof) do
541
    File.close(io)
1✔
542

543
    {:error,
544
     Error.new(:invalid_data,
545
       codec: :spatial_binary,
546
       message: "Invalid ExCodecs binary point cloud"
547
     )}
548
  end
549

550
  defp parse_excp_header(io, data) when is_binary(data) do
551
    File.close(io)
1✔
552

553
    {:error,
554
     Error.new(:invalid_data,
555
       codec: :spatial_binary,
556
       message: "Invalid ExCodecs binary point cloud"
557
     )}
558
  end
559

560
  # coveralls-ignore-start
561
  defp parse_excp_header(io, {:error, reason}) do
562
    File.close(io)
563
    io_error(reason)
564
  end
565

566
  # coveralls-ignore-stop
567

568
  defp next_excp_item({:ok, io, _flags, count, _stride, i}) when i >= count do
1✔
569
    {:halt, {:done, io}}
570
  end
571

572
  defp next_excp_item({:ok, io, flags, count, stride, i}) do
573
    case IO.binread(io, stride) do
6✔
574
      data when is_binary(data) and byte_size(data) == stride ->
575
        case decode_point(data, flags) do
4✔
576
          {:ok, point, _} ->
4✔
577
            {[point], {:ok, io, flags, count, stride, i + 1}}
578

579
          # coveralls-ignore-start
580
          {:error, error} ->
581
            {[{:error, error}], {:done, io}}
582
            # coveralls-ignore-stop
583
        end
584

585
      :eof ->
1✔
586
        {[
587
           {:error,
588
            Error.new(:invalid_data,
589
              codec: :spatial_binary,
590
              message: "Truncated point record"
591
            )}
592
         ], {:done, io}}
593

594
      data when is_binary(data) ->
1✔
595
        {[
596
           {:error,
597
            Error.new(:invalid_data,
598
              codec: :spatial_binary,
599
              message: "Truncated point record"
600
            )}
601
         ], {:done, io}}
602

603
      # coveralls-ignore-start
604
      {:error, reason} ->
605
        {[{:error, io_error_struct(reason)}], {:done, io}}
606
        # coveralls-ignore-stop
607
    end
608
  end
609

610
  defp next_excp_item({:mmap, _, _, count, _, i}) when i >= count, do: {:halt, :done_mmap}
108✔
611

612
  defp next_excp_item({:mmap, ref, flags, count, offset, i}) do
613
    next_excp_accel({:mmap, ref, flags, count, offset, i})
114✔
614
  end
615

616
  defp next_excp_item({:error, error}), do: {[{:error, error}], :done}
10✔
617
  defp next_excp_item({:done, _io}), do: {:halt, :done}
2✔
618
  defp next_excp_item(:done), do: {:halt, :done}
10✔
619
  # coveralls-ignore-start
620
  defp next_excp_item(:done_mmap), do: {:halt, :done}
621

622
  defp next_excp_accel(:done), do: {:halt, :done}
623
  defp next_excp_accel(:done_mmap), do: {:halt, :done}
624
  # coveralls-ignore-stop
625

626
  defp next_excp_accel({:bin, _body, _flags, count, _offset, i}) when i >= count do
4✔
627
    {:halt, :done}
628
  end
629

630
  defp next_excp_accel({:bin, body, flags, count, offset, i}) do
631
    want = min(Accel.chunk_size(), count - i)
5✔
632

633
    case Accel.excp_unpack(body, flags, offset, want) do
5✔
634
      {:ok, {[], _}} when want > 0 ->
1✔
635
        {[
636
           {:error,
637
            Error.new(:invalid_data,
638
              codec: :spatial_binary,
639
              message: "Truncated point record"
640
            )}
641
         ], :done}
642

643
      {:ok, {points, next}} ->
4✔
644
        {points, {:bin, body, flags, count, next, i + length(points)}}
645

646
      # coveralls-ignore-start
647
      {:error, :nif_not_loaded} ->
648
        {[
649
           {:error,
650
            Error.new(:nif_not_loaded,
651
              codec: :spatial_binary,
652
              message: "Spatial NIF unavailable mid-stream"
653
            )}
654
         ], :done}
655

656
      {:error, _} ->
657
        {[
658
           {:error,
659
            Error.new(:invalid_data,
660
              codec: :spatial_binary,
661
              message: "Truncated point record"
662
            )}
663
         ], :done}
664

665
        # coveralls-ignore-stop
666
    end
667
  end
668

669
  # coveralls-ignore-start
670
  defp next_excp_accel({:mmap, _ref, _flags, count, _offset, i}) when i >= count do
671
    {:halt, :done_mmap}
672
  end
673

674
  # coveralls-ignore-stop
675

676
  defp next_excp_accel({:mmap, ref, flags, count, offset, i}) do
677
    want = min(Accel.chunk_size(), count - i)
114✔
678

679
    case Accel.excp_unpack_mmap(ref, flags, offset, want) do
114✔
680
      {:ok, {[], _}} when want > 0 ->
3✔
681
        {[
682
           {:error,
683
            Error.new(:invalid_data,
684
              codec: :spatial_binary,
685
              message: "Truncated point record"
686
            )}
687
         ], :done_mmap}
688

689
      {:ok, {points, next}} ->
111✔
690
        {points, {:mmap, ref, flags, count, next, i + length(points)}}
691

692
      # coveralls-ignore-start
693
      {:error, _} ->
694
        {[
695
           {:error,
696
            Error.new(:invalid_data,
697
              codec: :spatial_binary,
698
              message: "Truncated point record"
699
            )}
700
         ], :done_mmap}
701

702
        # coveralls-ignore-stop
703
    end
704
  end
705

706
  defp close_excp_file({:ok, io, _, _, _, _}), do: File.close(io)
1✔
707
  defp close_excp_file({:done, io}), do: File.close(io)
1✔
708
  defp close_excp_file(_), do: :ok
124✔
709

710
  defp record_stride(flags) do
711
    color =
4✔
712
      cond do
713
        Bitwise.band(flags, @flag_alpha) != 0 -> 4
2✔
714
        Bitwise.band(flags, @flag_color) != 0 -> 3
2✔
715
        true -> 0
2✔
716
      end
717

718
    normal = if Bitwise.band(flags, @flag_normal) != 0, do: 12, else: 0
4✔
719
    12 + color + normal
4✔
720
  end
721

722
  defp io_error(reason) do
4✔
723
    {:error, io_error_struct(reason)}
724
  end
725

726
  defp io_error_struct(reason) do
727
    Error.new(:io_error,
4✔
728
      codec: :spatial_binary,
729
      message: "Failed to read EXCP file: #{inspect(reason)}",
730
      details: reason
731
    )
732
  end
733

734
  defp error_stream(error) do
735
    Stream.resource(
4✔
736
      fn -> {:error, error} end,
4✔
737
      fn
738
        {:error, e} -> {[{:error, e}], :done}
4✔
739
        :done -> {:halt, :done}
4✔
740
      end,
741
      fn _ -> :ok end
4✔
742
    )
743
  end
744

745
  defp encode_point(%Point{} = p, flags) do
746
    xyz = <<p.x::little-float-32, p.y::little-float-32, p.z::little-float-32>>
4,271✔
747

748
    color =
4,271✔
749
      cond do
750
        Bitwise.band(flags, @flag_alpha) != 0 ->
751
          {r, g, b, a} = normalize_rgba(p.color)
4,088✔
752
          <<r::8, g::8, b::8, a::8>>
4,088✔
753

754
        Bitwise.band(flags, @flag_color) != 0 ->
183✔
755
          {r, g, b} = normalize_rgb(p.color)
174✔
756
          <<r::8, g::8, b::8>>
174✔
757

758
        true ->
9✔
759
          <<>>
9✔
760
      end
761

762
    normal =
4,271✔
763
      if Bitwise.band(flags, @flag_normal) != 0 do
764
        {nx, ny, nz} = p.normal || {0.0, 0.0, 0.0}
4,237✔
765
        <<nx::little-float-32, ny::little-float-32, nz::little-float-32>>
4,237✔
766
      else
767
        <<>>
34✔
768
      end
769

770
    xyz <> color <> normal
4,271✔
771
  end
772

773
  defp normalize_rgb(color) do
774
    case color do
174✔
775
      nil -> {0, 0, 0}
81✔
776
      {r, g, b} -> {clamp_byte(r), clamp_byte(g), clamp_byte(b)}
92✔
777
      {r, g, b, _a} -> {clamp_byte(r), clamp_byte(g), clamp_byte(b)}
1✔
778
    end
779
  end
780

781
  defp normalize_rgba(color) do
782
    case color do
4,088✔
783
      nil -> {0, 0, 0, 255}
1,600✔
784
      {r, g, b} -> {clamp_byte(r), clamp_byte(g), clamp_byte(b), 255}
1,579✔
785
      {r, g, b, a} -> {clamp_byte(r), clamp_byte(g), clamp_byte(b), clamp_byte(a)}
909✔
786
    end
787
  end
788

789
  defp clamp_byte(v), do: min(max(trunc(v), 0), 255)
8,652✔
790

791
  defp encode_points_body(points, flags, opts) do
792
    if accel?(opts) do
422✔
793
      case Accel.excp_pack(points, flags) do
118✔
794
        {:ok, bin} ->
795
          bin
118✔
796

797
        # coveralls-ignore-start
798
        _ ->
799
          encode_points_body_elixir(points, flags)
800
          # coveralls-ignore-stop
801
      end
802
    else
803
      encode_points_body_elixir(points, flags)
304✔
804
    end
805
  end
806

807
  defp encode_points_body_elixir(points, flags) do
808
    IO.iodata_to_binary(Enum.map(points, &encode_point(&1, flags)))
304✔
809
  end
810

811
  defp write_point_chunks(enumerable, io, flags, opts) do
812
    chunk_size = if accel?(opts), do: Accel.chunk_size(), else: 1
13✔
813

814
    enumerable
815
    |> Stream.chunk_every(chunk_size)
816
    |> Enum.reduce_while({:ok, 0}, fn chunk, {:ok, n} ->
13✔
817
      points = assert_points!(chunk)
15✔
818

819
      case write_points_chunk(io, points, flags, opts) do
14✔
820
        :ok -> {:cont, {:ok, n + length(points)}}
14✔
821
        {:error, _} = err -> {:halt, err}
×
822
      end
823
    end)
824
  end
825

826
  defp assert_points!(chunk) do
827
    Enum.map(chunk, fn
15✔
828
      %Point{} = p -> p
17✔
829
      other -> throw({:bad_point, other})
1✔
830
    end)
831
  end
832

833
  defp write_points_chunk(io, points, flags, opts) do
834
    if accel?(opts) do
14✔
835
      case Accel.excp_pack(points, flags) do
9✔
836
        {:ok, bin} ->
837
          binwrite(io, bin)
9✔
838

839
        # coveralls-ignore-start
840
        _ ->
841
          write_points_elixir(io, points, flags)
842
          # coveralls-ignore-stop
843
      end
844
    else
845
      write_points_elixir(io, points, flags)
5✔
846
    end
847
  end
848

849
  defp write_points_elixir(io, points, flags) do
850
    Enum.reduce_while(points, :ok, fn p, :ok ->
5✔
851
      case binwrite(io, encode_point(p, flags)) do
5✔
852
        :ok -> {:cont, :ok}
5✔
853
        {:error, _} = err -> {:halt, err}
×
854
      end
855
    end)
856
  end
857

858
  defp binwrite(io, data) do
859
    case :file.write(io, data) do
39✔
860
      :ok -> :ok
39✔
861
      {:error, reason} -> io_error(reason)
×
862
    end
863
  end
864

865
  defp accel?(opts), do: StreamSource.accel?(opts)
892✔
866

867
  defp decode_points(bin, 0, _flags, _opts), do: {:ok, [], bin}
12✔
868

869
  defp decode_points(bin, count, flags, opts) do
870
    if accel?(opts) do
307✔
871
      decode_points_accel(bin, count, flags, 0, 0, [])
109✔
872
    else
873
      decode_points_elixir(bin, count, flags)
198✔
874
    end
875
  end
876

877
  defp decode_points_accel(_bin, count, _flags, _offset, i, acc) when i >= count do
878
    {:ok, Enum.reverse(acc), <<>>}
105✔
879
  end
880

881
  defp decode_points_accel(bin, count, flags, offset, i, acc) do
882
    want = min(Accel.chunk_size(), count - i)
109✔
883

884
    case Accel.excp_unpack(bin, flags, offset, want) do
109✔
885
      {:ok, {points, next}} when length(points) == want ->
886
        decode_points_accel(bin, count, flags, next, i + want, Enum.reverse(points, acc))
105✔
887

888
      # Incomplete / failed Accel unpack: Elixir path preserves field-specific
889
      # truncation messages (RGB / RGBA / normal), resuming at remaining bytes.
890
      _ ->
891
        rest = binary_part(bin, offset, byte_size(bin) - offset)
4✔
892

893
        case decode_points_elixir(rest, count - i, flags) do
4✔
894
          {:ok, points, leftover} -> {:ok, Enum.reverse(acc, points), leftover}
×
895
          {:error, _} = err -> err
4✔
896
        end
897
    end
898
  end
899

900
  defp decode_points_elixir(bin, count, flags) do
901
    Enum.reduce_while(1..count, {:ok, [], bin}, fn _, {:ok, acc, rest} ->
902
      case decode_point(rest, flags) do
2,596✔
903
        {:ok, point, next} -> {:cont, {:ok, [point | acc], next}}
2,592✔
904
        {:error, _} = err -> {:halt, err}
4✔
905
      end
906
    end)
907
    |> case do
202✔
908
      {:ok, points, rest} -> {:ok, Enum.reverse(points), rest}
198✔
909
      other -> other
4✔
910
    end
911
  end
912

913
  defp decode_point(
914
         <<x::little-float-32, y::little-float-32, z::little-float-32, rest::binary>>,
915
         flags
916
       ) do
917
    with {:ok, color, rest} <- take_color(rest, flags),
2,599✔
918
         {:ok, normal, rest} <- take_normal(rest, flags) do
2,597✔
919
      {:ok, Point.new(x, y, z, color: color, normal: normal), rest}
2,596✔
920
    end
921
  end
922

923
  defp decode_point(_, _),
1✔
924
    do:
925
      {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated point record")}
926

927
  defp take_color(bin, flags) when Bitwise.band(flags, @flag_alpha) != 0 do
928
    case bin do
2,456✔
929
      <<r::8, g::8, b::8, a::8, rest::binary>> -> {:ok, {r, g, b, a}, rest}
2,455✔
930
      _ -> {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated RGBA")}
1✔
931
    end
932
  end
933

934
  defp take_color(bin, flags) when Bitwise.band(flags, @flag_color) != 0 do
935
    case bin do
136✔
936
      <<r::8, g::8, b::8, rest::binary>> -> {:ok, {r, g, b}, rest}
135✔
937
      _ -> {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated RGB")}
1✔
938
    end
939
  end
940

941
  defp take_color(bin, _), do: {:ok, nil, bin}
7✔
942

943
  defp take_normal(bin, flags) when Bitwise.band(flags, @flag_normal) != 0 do
944
    case bin do
2,570✔
945
      <<nx::little-float-32, ny::little-float-32, nz::little-float-32, rest::binary>> ->
946
        {:ok, {nx, ny, nz}, rest}
2,569✔
947

948
      _ ->
1✔
949
        {:error, Error.new(:invalid_data, codec: :spatial_binary, message: "Truncated normal")}
950
    end
951
  end
952

953
  defp take_normal(bin, _), do: {:ok, nil, bin}
27✔
954
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