@ifc-lite/viewer 1.0.0 → 1.1.1

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.
Binary file
package/dist/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>IFC-Lite Viewer</title>
7
- <script type="module" crossorigin src="/assets/index-Dzz3WVwq.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CvYpbxNF.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DKe9Oy-s.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifc-lite/viewer",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -27,13 +27,14 @@
27
27
  "tailwind-merge": "^3.4.0",
28
28
  "tailwindcss": "^4.1.18",
29
29
  "zustand": "^4.4.0",
30
- "@ifc-lite/data": "1.0.0",
31
- "@ifc-lite/geometry": "1.0.0",
32
- "@ifc-lite/parser": "1.0.0",
33
- "@ifc-lite/export": "1.0.0",
34
- "@ifc-lite/query": "1.0.0",
35
- "@ifc-lite/renderer": "1.0.0",
36
- "@ifc-lite/spatial": "1.0.0"
30
+ "@ifc-lite/cache": "1.1.1",
31
+ "@ifc-lite/data": "1.1.1",
32
+ "@ifc-lite/geometry": "1.1.1",
33
+ "@ifc-lite/parser": "1.1.1",
34
+ "@ifc-lite/export": "1.1.1",
35
+ "@ifc-lite/renderer": "1.1.1",
36
+ "@ifc-lite/query": "1.1.1",
37
+ "@ifc-lite/spatial": "1.1.1"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@tailwindcss/postcss": "^4.1.18",
@@ -45,7 +46,6 @@
45
46
  },
46
47
  "scripts": {
47
48
  "dev": "vite",
48
- "prebuild": "cp ../../packages/wasm/ifc_lite_wasm_bg.wasm public/ifc_lite_wasm_bg.wasm",
49
49
  "build": "(tsc || true) && vite build",
50
50
  "preview": "vite preview"
51
51
  }
Binary file
@@ -389,7 +389,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
389
389
  const y = e.clientY - rect.top;
390
390
 
391
391
  // Pick at cursor position - orbit around what user is clicking on
392
- const pickedId = await renderer.pick(x, y);
392
+ const currentProgress = useViewerStore.getState().progress;
393
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
394
+ const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
393
395
  if (pickedId !== null) {
394
396
  const center = getEntityCenter(geometryRef.current, pickedId);
395
397
  if (center) {
@@ -477,7 +479,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
477
479
  const now = Date.now();
478
480
  if (now - lastHoverCheckRef.current > hoverThrottleMs) {
479
481
  lastHoverCheckRef.current = now;
480
- const pickedId = await renderer.pick(x, y);
482
+ const currentProgress = useViewerStore.getState().progress;
483
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
484
+ const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
481
485
  if (pickedId) {
482
486
  setHoverState({ entityId: pickedId, screenX: e.clientX, screenY: e.clientY });
483
487
  } else {
@@ -548,7 +552,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
548
552
 
549
553
  // Handle measure tool clicks
550
554
  if (tool === 'measure') {
551
- const pickedId = await renderer.pick(x, y);
555
+ const currentProgress = useViewerStore.getState().progress;
556
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
557
+ const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
552
558
  if (pickedId) {
553
559
  // Get 3D position from mesh vertices (simplified - uses center of clicked entity)
554
560
  // In a full implementation, you'd use ray-triangle intersection
@@ -656,7 +662,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
656
662
  Math.abs(clickPos.x - lastClickPosRef.current.x) < 5 &&
657
663
  Math.abs(clickPos.y - lastClickPosRef.current.y) < 5) {
658
664
  // Double-click - isolate element
659
- const pickedId = await renderer.pick(x, y);
665
+ const currentProgress = useViewerStore.getState().progress;
666
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
667
+ const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
660
668
  if (pickedId) {
661
669
  setSelectedEntityId(pickedId);
662
670
  }
@@ -664,7 +672,10 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
664
672
  lastClickPosRef.current = null;
665
673
  } else {
666
674
  // Single click
667
- const pickedId = await renderer.pick(x, y);
675
+ // Get current progress state (not from closure)
676
+ const currentProgress = useViewerStore.getState().progress;
677
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
678
+ const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
668
679
 
669
680
  // Multi-selection with Ctrl/Cmd
670
681
  if (e.ctrlKey || e.metaKey) {
@@ -709,7 +720,9 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
709
720
  const x = touchState.touches[0].clientX - rect.left;
710
721
  const y = touchState.touches[0].clientY - rect.top;
711
722
 
712
- const pickedId = await renderer.pick(x, y);
723
+ const currentProgress = useViewerStore.getState().progress;
724
+ const currentIsStreaming = currentProgress !== null && currentProgress.percent < 100;
725
+ const pickedId = await renderer.pick(x, y, { isStreaming: currentIsStreaming });
713
726
  if (pickedId !== null) {
714
727
  const center = getEntityCenter(geometryRef.current, pickedId);
715
728
  if (center) {
@@ -947,6 +960,13 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
947
960
  const lastGeometryLengthRef = useRef<number>(0);
948
961
  const lastGeometryRef = useRef<MeshData[] | null>(null);
949
962
  const cameraFittedRef = useRef<boolean>(false);
963
+ const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
964
+
965
+ // Render throttling during streaming
966
+ const lastRenderTimeRef = useRef<number>(0);
967
+ const RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
968
+ const progress = useViewerStore((state) => state.progress);
969
+ const isStreaming = progress !== null && progress.percent < 100;
950
970
 
951
971
  useEffect(() => {
952
972
  const renderer = rendererRef.current;
@@ -958,46 +978,52 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
958
978
 
959
979
  const scene = renderer.getScene();
960
980
  const currentLength = geometry.length;
961
- const geometryChanged = lastGeometryRef.current !== geometry;
981
+ const lastLength = lastGeometryLengthRef.current;
962
982
 
963
- if (geometryChanged && lastGeometryRef.current !== null) {
964
- // New file loaded - reset camera and bounds
983
+ // Use length-based detection instead of reference comparison
984
+ // React creates new array references on every appendGeometryBatch call,
985
+ // so reference comparison would always trigger scene.clear()
986
+ const isIncremental = currentLength > lastLength && lastLength > 0;
987
+ const isNewFile = currentLength > 0 && lastLength === 0 && lastGeometryRef.current !== null;
988
+ const isCleared = currentLength === 0;
989
+
990
+ if (isCleared) {
991
+ // Geometry cleared - reset camera and bounds
965
992
  scene.clear();
966
993
  processedMeshIdsRef.current.clear();
967
994
  cameraFittedRef.current = false;
995
+ finalBoundsRefittedRef.current = false;
968
996
  lastGeometryLengthRef.current = 0;
969
- lastGeometryRef.current = geometry;
970
- // Reset camera state (clear orbit pivot, stop inertia, cancel animations)
997
+ lastGeometryRef.current = null;
998
+ // Reset camera state
971
999
  renderer.getCamera().reset();
972
1000
  // Reset geometry bounds to default
973
1001
  geometryBoundsRef.current = {
974
1002
  min: { x: -100, y: -100, z: -100 },
975
1003
  max: { x: 100, y: 100, z: 100 },
976
1004
  };
977
- } else if (currentLength > lastGeometryLengthRef.current) {
978
- lastGeometryRef.current = geometry;
979
- } else if (currentLength === 0) {
980
- // Geometry cleared - reset camera and bounds
1005
+ return;
1006
+ } else if (isNewFile) {
1007
+ // New file loaded - reset camera and bounds
981
1008
  scene.clear();
982
1009
  processedMeshIdsRef.current.clear();
983
1010
  cameraFittedRef.current = false;
1011
+ finalBoundsRefittedRef.current = false;
984
1012
  lastGeometryLengthRef.current = 0;
985
- lastGeometryRef.current = null;
986
- // Reset camera state
1013
+ lastGeometryRef.current = geometry;
1014
+ // Reset camera state (clear orbit pivot, stop inertia, cancel animations)
987
1015
  renderer.getCamera().reset();
988
1016
  // Reset geometry bounds to default
989
1017
  geometryBoundsRef.current = {
990
1018
  min: { x: -100, y: -100, z: -100 },
991
1019
  max: { x: 100, y: 100, z: 100 },
992
1020
  };
993
- return;
994
- } else if (currentLength === lastGeometryLengthRef.current && !geometryChanged) {
995
- return;
996
- } else {
997
- // Length changed or other scenario - reset camera and bounds
1021
+ } else if (!isIncremental && currentLength !== lastLength) {
1022
+ // Length decreased (shouldn't happen during streaming) - reset
998
1023
  scene.clear();
999
1024
  processedMeshIdsRef.current.clear();
1000
1025
  cameraFittedRef.current = false;
1026
+ finalBoundsRefittedRef.current = false;
1001
1027
  lastGeometryLengthRef.current = 0;
1002
1028
  lastGeometryRef.current = geometry;
1003
1029
  // Reset camera state
@@ -1007,58 +1033,130 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
1007
1033
  min: { x: -100, y: -100, z: -100 },
1008
1034
  max: { x: 100, y: 100, z: 100 },
1009
1035
  };
1036
+ } else if (currentLength === lastLength) {
1037
+ // No geometry change - but check if we need to update bounds when streaming completes
1038
+ if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
1039
+ const shiftedBounds = coordinateInfo.shiftedBounds;
1040
+ const newMaxSize = Math.max(
1041
+ shiftedBounds.max.x - shiftedBounds.min.x,
1042
+ shiftedBounds.max.y - shiftedBounds.min.y,
1043
+ shiftedBounds.max.z - shiftedBounds.min.z
1044
+ );
1045
+
1046
+ if (newMaxSize > 0 && Number.isFinite(newMaxSize)) {
1047
+ // Only refit camera for LARGE models (>1000 meshes) where geometry streamed in multiple batches
1048
+ // Small models complete in one batch, so their initial camera fit is already correct
1049
+ const isLargeModel = geometry.length > 1000;
1050
+
1051
+ if (isLargeModel) {
1052
+ const oldBounds = geometryBoundsRef.current;
1053
+ const oldMaxSize = Math.max(
1054
+ oldBounds.max.x - oldBounds.min.x,
1055
+ oldBounds.max.y - oldBounds.min.y,
1056
+ oldBounds.max.z - oldBounds.min.z
1057
+ );
1058
+
1059
+ // Refit camera if bounds expanded significantly (>10% larger)
1060
+ // This handles skyscrapers where upper floors arrive in later batches
1061
+ const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
1062
+
1063
+ if (boundsExpanded) {
1064
+ console.log('[Viewport] Refitting camera after streaming complete - bounds expanded:', {
1065
+ oldMaxSize: oldMaxSize.toFixed(1),
1066
+ newMaxSize: newMaxSize.toFixed(1),
1067
+ expansion: ((newMaxSize / oldMaxSize - 1) * 100).toFixed(0) + '%'
1068
+ });
1069
+ renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
1070
+ }
1071
+ }
1072
+
1073
+ // Always update bounds for accurate zoom-to-fit, home view, etc.
1074
+ geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
1075
+ finalBoundsRefittedRef.current = true;
1076
+ }
1077
+ }
1078
+ return;
1010
1079
  }
1011
1080
 
1012
- if (lastGeometryRef.current === null) {
1081
+ // For incremental batches: update reference and continue to add new meshes
1082
+ if (isIncremental) {
1083
+ lastGeometryRef.current = geometry;
1084
+ } else if (lastGeometryRef.current === null) {
1013
1085
  lastGeometryRef.current = geometry;
1014
1086
  }
1015
1087
 
1016
1088
  const startIndex = lastGeometryLengthRef.current;
1017
1089
  const meshesToAdd = geometry.slice(startIndex);
1018
1090
 
1091
+ // Filter out already processed meshes
1092
+ const newMeshes: MeshData[] = [];
1019
1093
  for (const meshData of meshesToAdd) {
1020
- if (processedMeshIdsRef.current.has(meshData.expressId)) continue;
1021
-
1022
- const vertexCount = meshData.positions.length / 3;
1023
- const interleaved = new Float32Array(vertexCount * 6);
1024
- for (let i = 0; i < vertexCount; i++) {
1025
- const base = i * 6;
1026
- const posBase = i * 3;
1027
- interleaved[base] = meshData.positions[posBase];
1028
- interleaved[base + 1] = meshData.positions[posBase + 1];
1029
- interleaved[base + 2] = meshData.positions[posBase + 2];
1030
- interleaved[base + 3] = meshData.normals[posBase];
1031
- interleaved[base + 4] = meshData.normals[posBase + 1];
1032
- interleaved[base + 5] = meshData.normals[posBase + 2];
1094
+ if (!processedMeshIdsRef.current.has(meshData.expressId)) {
1095
+ newMeshes.push(meshData);
1096
+ processedMeshIdsRef.current.add(meshData.expressId);
1033
1097
  }
1098
+ }
1034
1099
 
1035
- const vertexBuffer = device.createBuffer({
1036
- size: interleaved.byteLength,
1037
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1038
- });
1039
- device.queue.writeBuffer(vertexBuffer, 0, interleaved);
1100
+ if (newMeshes.length > 0) {
1101
+ // Batch meshes by color for efficient rendering (reduces draw calls from N to ~100-500)
1102
+ // This dramatically improves performance for large models (50K+ meshes)
1103
+ const pipeline = renderer.getPipeline();
1104
+ if (pipeline) {
1105
+ // Use batched rendering - groups meshes by color into single draw calls
1106
+ (scene as any).appendToBatches(newMeshes, device, pipeline);
1107
+
1108
+ // Store mesh data for on-demand selection rendering
1109
+ // We DON'T create GPU buffers here during streaming - that's 2x the overhead!
1110
+ // Instead, store MeshData references and create buffers lazily when selected
1111
+ for (const meshData of newMeshes) {
1112
+ // Store minimal mesh data for picker and lazy selection buffer creation
1113
+ scene.addMeshData(meshData);
1114
+ }
1115
+ } else {
1116
+ // Fallback: add individual meshes if pipeline not ready
1117
+ for (const meshData of newMeshes) {
1118
+ const vertexCount = meshData.positions.length / 3;
1119
+ const interleaved = new Float32Array(vertexCount * 6);
1120
+ for (let i = 0; i < vertexCount; i++) {
1121
+ const base = i * 6;
1122
+ const posBase = i * 3;
1123
+ interleaved[base] = meshData.positions[posBase];
1124
+ interleaved[base + 1] = meshData.positions[posBase + 1];
1125
+ interleaved[base + 2] = meshData.positions[posBase + 2];
1126
+ interleaved[base + 3] = meshData.normals[posBase];
1127
+ interleaved[base + 4] = meshData.normals[posBase + 1];
1128
+ interleaved[base + 5] = meshData.normals[posBase + 2];
1129
+ }
1040
1130
 
1041
- const indexBuffer = device.createBuffer({
1042
- size: meshData.indices.byteLength,
1043
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1044
- });
1045
- device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
1046
-
1047
- scene.addMesh({
1048
- expressId: meshData.expressId,
1049
- vertexBuffer,
1050
- indexBuffer,
1051
- indexCount: meshData.indices.length,
1052
- transform: MathUtils.identity(),
1053
- color: meshData.color,
1054
- });
1131
+ const vertexBuffer = device.createBuffer({
1132
+ size: interleaved.byteLength,
1133
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1134
+ });
1135
+ device.queue.writeBuffer(vertexBuffer, 0, interleaved);
1055
1136
 
1056
- processedMeshIdsRef.current.add(meshData.expressId);
1137
+ const indexBuffer = device.createBuffer({
1138
+ size: meshData.indices.byteLength,
1139
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1140
+ });
1141
+ device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
1142
+
1143
+ scene.addMesh({
1144
+ expressId: meshData.expressId,
1145
+ vertexBuffer,
1146
+ indexBuffer,
1147
+ indexCount: meshData.indices.length,
1148
+ transform: MathUtils.identity(),
1149
+ color: meshData.color,
1150
+ });
1151
+ }
1152
+ }
1057
1153
  }
1058
1154
 
1059
1155
  lastGeometryLengthRef.current = currentLength;
1060
1156
 
1061
1157
  // Fit camera and store bounds
1158
+ // IMPORTANT: Fit camera immediately when we have valid bounds to avoid starting inside model
1159
+ // The default camera position (50, 50, 100) is inside most models that are shifted to origin
1062
1160
  if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
1063
1161
  const shiftedBounds = coordinateInfo.shiftedBounds;
1064
1162
  const maxSize = Math.max(
@@ -1066,12 +1164,18 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
1066
1164
  shiftedBounds.max.y - shiftedBounds.min.y,
1067
1165
  shiftedBounds.max.z - shiftedBounds.min.z
1068
1166
  );
1167
+ // Fit camera immediately when we have valid bounds
1168
+ // For streaming: the first batch already has complete bounds from coordinate handler
1169
+ // (bounds are calculated from ALL geometry before streaming starts)
1170
+ // Waiting for streaming to complete causes the camera to start inside the model
1069
1171
  if (maxSize > 0 && Number.isFinite(maxSize)) {
1070
1172
  renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
1071
1173
  geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
1072
1174
  cameraFittedRef.current = true;
1073
1175
  }
1074
- } else if (!cameraFittedRef.current && geometry.length > 0) {
1176
+ } else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
1177
+ // Fallback: calculate bounds from geometry array (only when streaming is complete)
1178
+ // This ensures we have complete bounds before fitting camera
1075
1179
  const fallbackBounds = {
1076
1180
  min: { x: Infinity, y: Infinity, z: Infinity },
1077
1181
  max: { x: -Infinity, y: -Infinity, z: -Infinity },
@@ -1093,21 +1197,49 @@ export function Viewport({ geometry, coordinateInfo }: ViewportProps) {
1093
1197
  }
1094
1198
  }
1095
1199
 
1096
- if (fallbackBounds.min.x !== Infinity) {
1200
+ const maxSize = Math.max(
1201
+ fallbackBounds.max.x - fallbackBounds.min.x,
1202
+ fallbackBounds.max.y - fallbackBounds.min.y,
1203
+ fallbackBounds.max.z - fallbackBounds.min.z
1204
+ );
1205
+
1206
+ if (fallbackBounds.min.x !== Infinity && maxSize > 0 && Number.isFinite(maxSize)) {
1097
1207
  renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
1098
1208
  geometryBoundsRef.current = fallbackBounds;
1099
1209
  cameraFittedRef.current = true;
1100
1210
  }
1101
1211
  }
1102
1212
 
1103
- renderer.render({
1104
- hiddenIds: hiddenEntitiesRef.current,
1105
- isolatedIds: isolatedEntitiesRef.current,
1106
- selectedId: selectedEntityIdRef.current,
1107
- clearColor: clearColorRef.current,
1108
- });
1109
- // Note: visibility states are NOT in dependencies - they use refs and trigger re-render via separate effect
1110
- }, [geometry, isInitialized, coordinateInfo]);
1213
+ // Note: Background instancing conversion removed
1214
+ // Regular MeshData meshes are rendered directly with their correct positions
1215
+ // Instancing conversion would require preserving actual mesh transforms, which is complex
1216
+ // For now, we render regular meshes directly (fast enough for most cases)
1217
+
1218
+ // Render throttling: During streaming, only render every RENDER_THROTTLE_MS
1219
+ // This prevents rendering 28K+ meshes from blocking WASM batch processing
1220
+ const now = Date.now();
1221
+ const timeSinceLastRender = now - lastRenderTimeRef.current;
1222
+ const shouldRender = !isStreaming || timeSinceLastRender >= RENDER_THROTTLE_MS;
1223
+
1224
+ if (shouldRender) {
1225
+ renderer.render();
1226
+ lastRenderTimeRef.current = now;
1227
+ }
1228
+ }, [geometry, coordinateInfo, isInitialized, isStreaming]);
1229
+
1230
+ // Force render when streaming completes (progress goes from <100% to 100% or null)
1231
+ const prevIsStreamingRef = useRef(isStreaming);
1232
+ useEffect(() => {
1233
+ const renderer = rendererRef.current;
1234
+ if (!renderer || !isInitialized) return;
1235
+
1236
+ // If streaming just completed (was streaming, now not), force immediate render
1237
+ if (prevIsStreamingRef.current && !isStreaming) {
1238
+ renderer.render();
1239
+ lastRenderTimeRef.current = Date.now();
1240
+ }
1241
+ prevIsStreamingRef.current = isStreaming;
1242
+ }, [isStreaming, isInitialized]);
1111
1243
 
1112
1244
  // Get selectedEntityIds from store for multi-selection
1113
1245
  const selectedEntityIds = useViewerStore((state) => state.selectedEntityIds);
@@ -13,7 +13,7 @@ export function ViewportContainer() {
13
13
  const { geometryResult, ifcDataStore } = useIfc();
14
14
  const selectedStorey = useViewerStore((s) => s.selectedStorey);
15
15
 
16
- // Filter geometry based on selected storey
16
+ // Filter geometry based on selected storey (for non-instanced fallback)
17
17
  const filteredGeometry = useMemo(() => {
18
18
  if (!geometryResult?.meshes || !ifcDataStore?.spatialHierarchy) {
19
19
  return geometryResult?.meshes || null;