@ifc-lite/viewer 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,51 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `useForkExtension` — wraps the fork-an-installed-extension flow.
7
+ *
8
+ * Looks up the bundle from the loader, formats it as a chat-prompt
9
+ * fenced bundle via `formatBundleForPrompt`, queues the prompt and
10
+ * opens the chat. Pure side-effects; UI calls `fork(id)` and reacts
11
+ * to toasts.
12
+ */
13
+
14
+ import { useCallback } from 'react';
15
+ import { formatBundleForPrompt } from '@ifc-lite/extensions';
16
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
17
+ import { useViewerStore } from '@/store';
18
+ import { toast } from '@/components/ui/toast';
19
+ import * as toastText from '@/components/extensions/toast-helpers';
20
+
21
+ export function useForkExtension(): (id: string) => void {
22
+ const host = useExtensionHost();
23
+ const queueChatPrompt = useViewerStore((s) => s.queueChatPrompt);
24
+ const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible);
25
+
26
+ return useCallback(
27
+ (id: string) => {
28
+ try {
29
+ const bundle = host.loader.getBundle(id);
30
+ if (!bundle) {
31
+ toast.error(`Bundle for ${id} not loaded.`);
32
+ return;
33
+ }
34
+ const formatted = formatBundleForPrompt(bundle);
35
+ const prompt = [
36
+ `Fork the installed extension ${bundle.manifest.id} (v${bundle.manifest.version}).`,
37
+ '',
38
+ formatted.text,
39
+ '',
40
+ 'What would you like to change?',
41
+ ].join('\n');
42
+ queueChatPrompt(prompt);
43
+ setChatPanelVisible(true);
44
+ toast.success(`Routed ${bundle.manifest.id} to chat for editing`);
45
+ } catch (err) {
46
+ toast.error(toastText.failed('Fork', err));
47
+ }
48
+ },
49
+ [host, queueChatPrompt, setChatPanelVisible],
50
+ );
51
+ }
@@ -908,7 +908,13 @@ export function useIfcFederation() {
908
908
  // top-level fields. When subsequent models are added, activeModelId
909
909
  // stays on the first model — writing here would alias the new model's
910
910
  // data into the active (first) model's per-model entry and cause both
911
- // viewport slots to render the same mesh (issue #661).
911
+ // viewport slots to render the same mesh (issue #661, PR #792).
912
+ //
913
+ // An earlier draft of this branch called `setActiveModel(modelId)`
914
+ // here, which also fixed #661 but had the side-effect of stealing
915
+ // focus to every added model — confusing UX. The main-branch fix
916
+ // (drop the legacy calls; keep activeModelId on the first model)
917
+ // is preferred and was kept on merge.
912
918
 
913
919
  setProgress({ phase: 'Complete', percent: 100 });
914
920
  setLoading(false);
@@ -0,0 +1,43 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { useEffect, useState } from 'react';
6
+ import type { InstalledExtensionRecord } from '@ifc-lite/extensions';
7
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider.js';
8
+
9
+ /**
10
+ * Live list of installed extensions. Refreshes whenever the host
11
+ * service's "anything changed" signal fires (install/uninstall/
12
+ * enable/disable).
13
+ */
14
+ export function useInstalledExtensions(): InstalledExtensionRecord[] {
15
+ const host = useOptionalExtensionHost();
16
+ const [records, setRecords] = useState<InstalledExtensionRecord[]>([]);
17
+
18
+ useEffect(() => {
19
+ if (!host) {
20
+ setRecords([]);
21
+ return;
22
+ }
23
+ let cancelled = false;
24
+ const refresh = async () => {
25
+ try {
26
+ const next = await host.listInstalled();
27
+ if (!cancelled) setRecords(next);
28
+ } catch (err) {
29
+ console.error('[useInstalledExtensions] listInstalled failed:', err);
30
+ }
31
+ };
32
+ void refresh();
33
+ const off = host.onChange(() => {
34
+ void refresh();
35
+ });
36
+ return () => {
37
+ cancelled = true;
38
+ off();
39
+ };
40
+ }, [host]);
41
+
42
+ return records;
43
+ }
@@ -0,0 +1,48 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `usePrivacyDisclosure` — show the privacy disclosure once per browser.
7
+ *
8
+ * Fires a one-time toast on first launch after the extensions
9
+ * subsystem (action log + miner) is alive. Persists the
10
+ * acknowledgement under a localStorage flag so users only see the
11
+ * disclosure once. The toast points at the Privacy panel for the
12
+ * full controls.
13
+ *
14
+ * The disclosure is required by RFC §06 §7 — users must be told what
15
+ * gets stored locally before the miner / memory loops start.
16
+ */
17
+
18
+ import { useEffect } from 'react';
19
+ import { toast } from '@/components/ui/toast';
20
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
21
+
22
+ const STORAGE_KEY = 'ifclite.extensions.privacy-disclosure.v1';
23
+
24
+ export function usePrivacyDisclosure(): void {
25
+ const host = useOptionalExtensionHost();
26
+ useEffect(() => {
27
+ if (!host) return;
28
+ if (typeof window === 'undefined') return;
29
+ try {
30
+ if (window.localStorage.getItem(STORAGE_KEY)) return;
31
+ } catch {
32
+ // Privacy modes can block localStorage — skip silently.
33
+ return;
34
+ }
35
+ // Defer slightly so the toast doesn't fight with the splash UI.
36
+ const handle = window.setTimeout(() => {
37
+ toast.info(
38
+ 'IFClite keeps a local, content-free action log to suggest one-click tools. Manage or delete it in Extensions → Privacy.',
39
+ );
40
+ try {
41
+ window.localStorage.setItem(STORAGE_KEY, new Date().toISOString());
42
+ } catch {
43
+ // Best effort — re-firing on every load is annoying but not harmful.
44
+ }
45
+ }, 3500);
46
+ return () => window.clearTimeout(handle);
47
+ }, [host]);
48
+ }
@@ -0,0 +1,67 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `useRunExtensionTests` — wraps the per-extension test run.
7
+ *
8
+ * Tracks a per-id "running" set so repeated clicks don't queue
9
+ * concurrent runs. The UI uses `isRunning(id)` to gate the trigger
10
+ * button + show a pulsing icon.
11
+ *
12
+ * Returns `{ runTests, isRunning }`.
13
+ */
14
+
15
+ import { useCallback, useState } from 'react';
16
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
17
+ import { toast } from '@/components/ui/toast';
18
+ import * as toastText from '@/components/extensions/toast-helpers';
19
+
20
+ export interface RunExtensionTestsApi {
21
+ runTests(id: string): void;
22
+ isRunning(id: string): boolean;
23
+ }
24
+
25
+ export function useRunExtensionTests(): RunExtensionTestsApi {
26
+ const host = useExtensionHost();
27
+ const [running, setRunning] = useState<ReadonlySet<string>>(new Set());
28
+
29
+ const runTests = useCallback(
30
+ (id: string) => {
31
+ if (running.has(id)) return;
32
+ setRunning((prev) => {
33
+ const next = new Set(prev);
34
+ next.add(id);
35
+ return next;
36
+ });
37
+ toast.info(`Running tests for ${id}…`);
38
+ host.runTests(id)
39
+ .then((summary) => {
40
+ if (summary.results.length === 0) {
41
+ toast.info(toastText.testsNotDeclared(id));
42
+ } else if (summary.failed === 0) {
43
+ toast.success(toastText.testsPassed(id, summary.passed, summary.results.length));
44
+ } else {
45
+ const firstError = summary.results.find((r) => !r.passed)?.error ?? 'see console';
46
+ toast.error(toastText.testsFailed(id, summary.failed, firstError));
47
+ console.warn('[ext-host] test failures:', summary);
48
+ }
49
+ })
50
+ .catch((err) => {
51
+ toast.error(toastText.failed(`Tests for ${id}`, err));
52
+ })
53
+ .finally(() => {
54
+ setRunning((prev) => {
55
+ const next = new Set(prev);
56
+ next.delete(id);
57
+ return next;
58
+ });
59
+ });
60
+ },
61
+ [host, running],
62
+ );
63
+
64
+ const isRunning = useCallback((id: string) => running.has(id), [running]);
65
+
66
+ return { runTests, isRunning };
67
+ }
@@ -0,0 +1,38 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Subscribe a React component to a slot in the extension SlotRegistry.
7
+ *
8
+ * Re-renders whenever an extension registers or unregisters
9
+ * contributions for the named slot. Returns the current list,
10
+ * snapshot-style, in registration order.
11
+ */
12
+
13
+ import { useEffect, useState } from 'react';
14
+ import type { SlotContribution } from '@ifc-lite/extensions';
15
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider.js';
16
+
17
+ export function useSlotContributions<T = unknown>(slot: string): SlotContribution<T>[] {
18
+ const host = useOptionalExtensionHost();
19
+ const [items, setItems] = useState<SlotContribution<T>[]>(
20
+ () => host?.getSlotContributions<T>(slot) ?? [],
21
+ );
22
+
23
+ useEffect(() => {
24
+ if (!host) {
25
+ setItems([]);
26
+ return;
27
+ }
28
+ // Refresh snapshot synchronously before subscribing — otherwise
29
+ // switching slots can leave old contributions visible until the
30
+ // next registry event arrives.
31
+ setItems(host.getSlotContributions<T>(slot));
32
+ return host.subscribeSlot<T>(slot, (next) => {
33
+ setItems(next);
34
+ });
35
+ }, [host, slot]);
36
+
37
+ return items;
38
+ }
@@ -0,0 +1,124 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Unit tests for the symbolic-annotation segment helpers.
7
+ *
8
+ * The full React hook is exercised at integration time (covered by the
9
+ * #653 manual smoke test); here we lock in the geometry conversion that
10
+ * turns WASM-returned polylines/arcs into DrawingLine2D pairs.
11
+ */
12
+
13
+ import { describe, it } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import type { DrawingLine2D } from '@ifc-lite/renderer';
16
+ import { polylineToSegments, circleToSegments, liftTo3DLineList } from './useSymbolicAnnotations.js';
17
+
18
+ describe('polylineToSegments', () => {
19
+ it('emits N-1 segments for an open polyline', () => {
20
+ // 3 points -> 2 segments
21
+ const points = new Float32Array([0, 0, 1, 0, 1, 1]);
22
+ const out: DrawingLine2D[] = [];
23
+ polylineToSegments(points, 3, false, out);
24
+ assert.equal(out.length, 2);
25
+ assert.deepEqual(out[0].line.start, { x: 0, y: 0 });
26
+ assert.deepEqual(out[0].line.end, { x: 1, y: 0 });
27
+ assert.deepEqual(out[1].line.start, { x: 1, y: 0 });
28
+ assert.deepEqual(out[1].line.end, { x: 1, y: 1 });
29
+ assert.equal(out[0].category, 'annotation');
30
+ });
31
+
32
+ it('adds a closing segment when isClosed is true', () => {
33
+ const points = new Float32Array([0, 0, 2, 0, 2, 2]);
34
+ const out: DrawingLine2D[] = [];
35
+ polylineToSegments(points, 3, true, out);
36
+ // 2 open + 1 closing = 3
37
+ assert.equal(out.length, 3);
38
+ assert.deepEqual(out[2].line.start, { x: 2, y: 2 });
39
+ assert.deepEqual(out[2].line.end, { x: 0, y: 0 });
40
+ });
41
+
42
+ it('skips the closing segment for two-point polylines even when isClosed', () => {
43
+ const points = new Float32Array([0, 0, 1, 0]);
44
+ const out: DrawingLine2D[] = [];
45
+ polylineToSegments(points, 2, true, out);
46
+ assert.equal(out.length, 1);
47
+ });
48
+
49
+ it('appends to an existing output array without clearing it', () => {
50
+ const points = new Float32Array([0, 0, 1, 0]);
51
+ const out: DrawingLine2D[] = [
52
+ { line: { start: { x: 9, y: 9 }, end: { x: 8, y: 8 } }, category: 'preset' },
53
+ ];
54
+ polylineToSegments(points, 2, false, out);
55
+ assert.equal(out.length, 2);
56
+ assert.equal(out[0].category, 'preset');
57
+ assert.equal(out[1].category, 'annotation');
58
+ });
59
+ });
60
+
61
+ describe('circleToSegments', () => {
62
+ it('tessellates a full circle into 32 segments', () => {
63
+ const out: DrawingLine2D[] = [];
64
+ circleToSegments(0, 0, 1, 0, Math.PI * 2, true, out);
65
+ assert.equal(out.length, 32);
66
+ });
67
+
68
+ it('tessellates an arc into 16 segments', () => {
69
+ const out: DrawingLine2D[] = [];
70
+ circleToSegments(0, 0, 1, 0, Math.PI, false, out);
71
+ assert.equal(out.length, 16);
72
+ });
73
+
74
+ it('places points on the circle within numerical tolerance', () => {
75
+ const out: DrawingLine2D[] = [];
76
+ circleToSegments(5, 7, 3, 0, Math.PI * 2, true, out);
77
+ for (const seg of out) {
78
+ const dx = seg.line.start.x - 5;
79
+ const dy = seg.line.start.y - 7;
80
+ const r = Math.sqrt(dx * dx + dy * dy);
81
+ assert.ok(Math.abs(r - 3) < 1e-5, `expected radius 3, got ${r}`);
82
+ }
83
+ });
84
+
85
+ it('marks output with category "annotation"', () => {
86
+ const out: DrawingLine2D[] = [];
87
+ circleToSegments(0, 0, 1, 0, Math.PI / 2, false, out);
88
+ assert.ok(out.every((seg) => seg.category === 'annotation'));
89
+ });
90
+ });
91
+
92
+ describe('liftTo3DLineList', () => {
93
+ it('emits six floats per segment with the storey Y in the middle slot', () => {
94
+ const lines: DrawingLine2D[] = [
95
+ { line: { start: { x: 1, y: 2 }, end: { x: 3, y: 4 } }, category: 'annotation' },
96
+ { line: { start: { x: 5, y: 6 }, end: { x: 7, y: 8 } }, category: 'annotation' },
97
+ ];
98
+ const out: number[] = [];
99
+ liftTo3DLineList(lines, 5.45, out);
100
+ assert.deepEqual(out, [
101
+ 1, 5.45, 2, 3, 5.45, 4,
102
+ 5, 5.45, 6, 7, 5.45, 8,
103
+ ]);
104
+ });
105
+
106
+ it('appends to an existing output array', () => {
107
+ const out: number[] = [99, 99, 99];
108
+ liftTo3DLineList(
109
+ [{ line: { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, category: 'annotation' }],
110
+ 2,
111
+ out,
112
+ );
113
+ assert.equal(out.length, 9);
114
+ assert.equal(out[0], 99);
115
+ assert.equal(out[3], 0);
116
+ assert.equal(out[4], 2);
117
+ });
118
+
119
+ it('does nothing for an empty input', () => {
120
+ const out: number[] = [];
121
+ liftTo3DLineList([], 1, out);
122
+ assert.equal(out.length, 0);
123
+ });
124
+ });