@ifc-lite/viewer 1.21.0 → 1.22.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 (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
@@ -49,11 +49,14 @@ import type { ScriptDiagnostic } from '@/lib/llm/script-diagnostics';
49
49
  import { buildRepairSessionKey, getEscalatedRepairScope, pruneMessagesForRepair } from '@/lib/llm/repair-loop';
50
50
  import type { ChatMessage, ChatRepairRequest, FileAttachment } from '@/lib/llm/types';
51
51
  import { canUsePlainCodeBlockFallback, type ScriptMutationIntent } from '@/lib/llm/script-preservation';
52
- import { Check, Image as ImageIcon, Key, Eye, EyeOff, ExternalLink } from 'lucide-react';
52
+ import { Check, Image as ImageIcon, KeyRound } from 'lucide-react';
53
53
  import { hasDesktopFeatureAccess } from '@/lib/desktop-product';
54
54
  import { getModelById } from '@/lib/llm/models';
55
55
  import { resolveStreamRoute } from '@/lib/llm/byok-guard';
56
- import { getApiKeys, updateApiKeys, hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
56
+ import { getApiKeys, hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
57
+ import { ByokKeyModal } from './chat/ByokKeyModal';
58
+ import { ByokStreamingPill } from './chat/ByokStreamingPill';
59
+ import type { BYOKProvider } from '@/lib/llm/clipboard-detect';
57
60
  import { useSandbox } from '@/hooks/useSandbox';
58
61
 
59
62
  // Environment variable for the proxy URL
@@ -246,7 +249,26 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
246
249
  return subscribeApiKeys(refresh);
247
250
  }, [setChatHasByokKey]);
248
251
 
249
- const displayUsage: UsageInfo | null = usage;
252
+ // BYOK key modal controlled state for both auto-open (on locked-model pick)
253
+ // and manual open via the header 🔑 button. `provider` selects the initial tab.
254
+ const [byokModal, setByokModal] = useState<{ open: boolean; provider: BYOKProvider }>({
255
+ open: false,
256
+ provider: 'anthropic',
257
+ });
258
+ const openByokModal = useCallback((provider: BYOKProvider) => {
259
+ setByokModal({ open: true, provider });
260
+ }, []);
261
+ const closeByokModal = useCallback(() => {
262
+ setByokModal((s) => ({ ...s, open: false }));
263
+ }, []);
264
+
265
+ // The usage indicator tracks the free-tier proxy quota we enforce server-side.
266
+ // BYOK routes go directly from the browser to the provider, so the user's
267
+ // own provider account is what gates them — our quota doesn't apply, and
268
+ // showing it here is misleading. Hide it whenever the active model is
269
+ // direct-to-provider.
270
+ const activeModelSource = getModelById(activeModel)?.source ?? 'proxy';
271
+ const displayUsage: UsageInfo | null = activeModelSource === 'proxy' ? usage : null;
250
272
  const usageResetLabel = displayUsage?.resetAt && displayUsage.resetAt > 0
251
273
  ? new Date(displayUsage.resetAt * 1000).toLocaleDateString()
252
274
  : '—';
@@ -424,10 +446,9 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
424
446
  // doesn't stack orphaned user messages on repeated sends.
425
447
  const route = resolveStreamRoute(activeModel, getApiKeys());
426
448
  if (route.kind === 'missing-key') {
449
+ openByokModal(route.provider);
427
450
  setChatError(
428
- route.provider === 'anthropic'
429
- ? 'Enter your Anthropic API key above to use this model.'
430
- : 'Enter your OpenAI API key above to use this model.',
451
+ `${route.provider === 'anthropic' ? 'Anthropic' : 'OpenAI'} key required for this model — set it up to continue.`,
431
452
  );
432
453
  return;
433
454
  }
@@ -1188,6 +1209,17 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1188
1209
  const needsAnthropicKey = modelSource === 'anthropic' && !keyStateAnthropic;
1189
1210
  const needsOpenaiKey = modelSource === 'openai' && !keyStateOpenai;
1190
1211
  const needsByokKey = needsAnthropicKey || needsOpenaiKey;
1212
+
1213
+ // Auto-open the BYOK modal when the user picks a locked model. We only fire on
1214
+ // the *transition* into needsByokKey so the modal doesn't keep popping back up
1215
+ // after the user dismisses it without entering a key.
1216
+ const prevNeedsByokRef = useRef(false);
1217
+ useEffect(() => {
1218
+ if (needsByokKey && !prevNeedsByokRef.current) {
1219
+ openByokModal(needsAnthropicKey ? 'anthropic' : 'openai');
1220
+ }
1221
+ prevNeedsByokRef.current = needsByokKey;
1222
+ }, [needsByokKey, needsAnthropicKey, openByokModal]);
1191
1223
  const showSupportEmail = Boolean(error && error.includes('louis@ltplus.com'));
1192
1224
  const canContinue = Boolean(
1193
1225
  !isActive && (streamingContent.trim().length > 0 || lastFinishReason === 'length'),
@@ -1227,8 +1259,26 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1227
1259
  </Tooltip>
1228
1260
 
1229
1261
  <ModelSelector />
1262
+ <ByokStreamingPill modelId={activeModel} className="ml-1" />
1230
1263
  <div className="flex-1" />
1231
1264
 
1265
+ <Tooltip>
1266
+ <TooltipTrigger asChild>
1267
+ <Button
1268
+ variant="ghost"
1269
+ size="icon-xs"
1270
+ onClick={() => openByokModal(modelSource === 'openai' ? 'openai' : 'anthropic')}
1271
+ className={keyStateAnthropic || keyStateOpenai ? 'text-emerald-500' : ''}
1272
+ aria-label={keyStateAnthropic || keyStateOpenai ? 'Manage API keys' : 'Add API key for frontier models'}
1273
+ >
1274
+ <KeyRound className="h-3.5 w-3.5" />
1275
+ </Button>
1276
+ </TooltipTrigger>
1277
+ <TooltipContent>
1278
+ {keyStateAnthropic || keyStateOpenai ? 'Manage API keys' : 'Add API key for frontier models'}
1279
+ </TooltipContent>
1280
+ </Tooltip>
1281
+
1232
1282
  <Tooltip>
1233
1283
  <TooltipTrigger asChild>
1234
1284
  <Button
@@ -1256,9 +1306,20 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1256
1306
  </div>
1257
1307
  )}
1258
1308
 
1259
- {/* Inline BYOK key prompt shown when user picks a model without the matching key */}
1260
- {needsByokKey && canUseAiAssistant && (
1261
- <InlineKeyPrompt provider={needsAnthropicKey ? 'anthropic' : 'openai'} />
1309
+ {/* Slim CTA bannerappears when the modal has been dismissed but the
1310
+ selected model still needs a key. Re-opens the modal on click. */}
1311
+ {needsByokKey && canUseAiAssistant && !byokModal.open && (
1312
+ <button
1313
+ type="button"
1314
+ onClick={() => openByokModal(needsAnthropicKey ? 'anthropic' : 'openai')}
1315
+ className="w-full border-b bg-amber-500/10 px-3 py-2 text-left text-xs hover:bg-amber-500/15 transition-colors flex items-center gap-2"
1316
+ >
1317
+ <KeyRound className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
1318
+ <span>
1319
+ <strong>{needsAnthropicKey ? 'Anthropic' : 'OpenAI'} key needed</strong>{' '}
1320
+ for this model — click to set it up
1321
+ </span>
1322
+ </button>
1262
1323
  )}
1263
1324
 
1264
1325
  {/* Clear confirmation */}
@@ -1449,7 +1510,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1449
1510
  }}
1450
1511
  onKeyDown={handleKeyDown}
1451
1512
  onPaste={handlePaste}
1452
- placeholder={!canUseAiAssistant ? 'AI assistant not available' : needsByokKey ? `Enter your ${needsAnthropicKey ? 'Anthropic' : 'OpenAI'} API key above` : 'Ask anything...'}
1513
+ placeholder={!canUseAiAssistant ? 'AI assistant not available' : needsByokKey ? `Add your ${needsAnthropicKey ? 'Anthropic' : 'OpenAI'} key to chat with this model` : 'Ask anything...'}
1453
1514
  rows={1}
1454
1515
  className="flex-1 resize-none rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-1.5 text-sm min-h-[32px] max-h-[120px] focus:outline-none focus:ring-1 focus:ring-ring"
1455
1516
  style={{ height: 'auto', overflow: 'hidden' }}
@@ -1517,90 +1578,12 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1517
1578
  <span className="text-[10px] text-muted-foreground/30">⌘L</span>
1518
1579
  </div>
1519
1580
  </div>
1520
- </div>
1521
- );
1522
- }
1523
-
1524
- // ── Inline BYOK key prompt (shown inside chat panel when key is missing) ──
1525
-
1526
- const PROVIDER_INFO = {
1527
- anthropic: {
1528
- label: 'Anthropic',
1529
- placeholder: 'sk-ant-api03-...',
1530
- url: 'https://console.anthropic.com/settings/keys',
1531
- urlLabel: 'console.anthropic.com',
1532
- },
1533
- openai: {
1534
- label: 'OpenAI',
1535
- placeholder: 'sk-...',
1536
- url: 'https://platform.openai.com/api-keys',
1537
- urlLabel: 'platform.openai.com',
1538
- },
1539
- } as const;
1540
-
1541
- function InlineKeyPrompt({ provider }: { provider: 'anthropic' | 'openai' }) {
1542
- const [value, setValue] = useState('');
1543
- const [show, setShow] = useState(false);
1544
- const [saved, setSaved] = useState(false);
1545
- const info = PROVIDER_INFO[provider];
1546
-
1547
- const handleSave = useCallback(() => {
1548
- const trimmed = value.trim();
1549
- if (!trimmed) return;
1550
- if (provider === 'anthropic') {
1551
- updateApiKeys({ anthropicKey: trimmed });
1552
- } else {
1553
- updateApiKeys({ openaiKey: trimmed });
1554
- }
1555
- setSaved(true);
1556
- }, [value, provider]);
1557
-
1558
- // Brief success state before the parent unmounts this component
1559
- if (saved) {
1560
- return (
1561
- <div className="border-b bg-emerald-500/10 px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 dark:text-emerald-400">
1562
- <Check className="h-3.5 w-3.5" />
1563
- <span>{info.label} key saved — ready to chat</span>
1564
- </div>
1565
- );
1566
- }
1567
1581
 
1568
- return (
1569
- <div className="border-b bg-muted/30 px-3 py-2.5 space-y-1.5">
1570
- <div className="flex items-center gap-1.5 text-xs font-medium">
1571
- <Key className="h-3.5 w-3.5" />
1572
- {info.label} API key required
1573
- </div>
1574
- <p className="text-[11px] text-muted-foreground">
1575
- Paste your key below — stored in your browser only, sent directly to {info.label}.{' '}
1576
- <a href={info.url} target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
1577
- Get a key <ExternalLink className="h-2.5 w-2.5" />
1578
- </a>
1579
- </p>
1580
- <div className="flex gap-1.5">
1581
- <div className="relative flex-1">
1582
- <input
1583
- type={show ? 'text' : 'password'}
1584
- value={value}
1585
- onChange={(e) => setValue(e.target.value)}
1586
- onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
1587
- placeholder={info.placeholder}
1588
- autoComplete="off"
1589
- spellCheck={false}
1590
- className="w-full rounded border border-input bg-background px-2 py-1 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-7"
1591
- />
1592
- <button
1593
- type="button"
1594
- onClick={() => setShow(!show)}
1595
- className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1596
- >
1597
- {show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
1598
- </button>
1599
- </div>
1600
- <Button size="sm" className="h-7 px-3 text-xs" onClick={handleSave} disabled={!value.trim()}>
1601
- Save
1602
- </Button>
1603
- </div>
1582
+ <ByokKeyModal
1583
+ open={byokModal.open}
1584
+ onOpenChange={(open) => (open ? openByokModal(byokModal.provider) : closeByokModal())}
1585
+ initialProvider={byokModal.provider}
1586
+ />
1604
1587
  </div>
1605
1588
  );
1606
1589
  }
@@ -6,7 +6,7 @@
6
6
  * Context menu for entity interactions
7
7
  */
8
8
 
9
- import { useCallback, useEffect, useRef, useMemo } from 'react';
9
+ import { useCallback, useEffect, useLayoutEffect, useRef, useMemo, useState } from 'react';
10
10
  import {
11
11
  Equal,
12
12
  Plus,
@@ -73,18 +73,30 @@ export function EntityContextMenu() {
73
73
  };
74
74
  }, [contextMenu.entityId, models, ifcDataStore]);
75
75
 
76
- // Close menu when clicking outside
76
+ // Close menu when clicking/tapping outside.
77
+ //
78
+ // Listen on `pointerdown` (with capture) rather than `mousedown`:
79
+ // the canvas calls `e.preventDefault()` on its own pointerdown
80
+ // handler, which in some browsers suppresses the compatibility
81
+ // `mousedown` event — so a plain `mousedown` listener never fires
82
+ // when the user clicks the 3D viewport to dismiss the menu.
77
83
  useEffect(() => {
78
- const handleClickOutside = (e: MouseEvent) => {
84
+ if (!contextMenu.isOpen) return;
85
+ const handlePointerOutside = (e: PointerEvent) => {
79
86
  if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
80
87
  closeContextMenu();
81
88
  }
82
89
  };
83
-
84
- if (contextMenu.isOpen) {
85
- document.addEventListener('mousedown', handleClickOutside);
86
- return () => document.removeEventListener('mousedown', handleClickOutside);
87
- }
90
+ // Also close on scroll/resize — the anchor coords go stale.
91
+ const handleDismiss = () => closeContextMenu();
92
+ document.addEventListener('pointerdown', handlePointerOutside, true);
93
+ window.addEventListener('resize', handleDismiss);
94
+ window.addEventListener('wheel', handleDismiss, { passive: true });
95
+ return () => {
96
+ document.removeEventListener('pointerdown', handlePointerOutside, true);
97
+ window.removeEventListener('resize', handleDismiss);
98
+ window.removeEventListener('wheel', handleDismiss);
99
+ };
88
100
  }, [contextMenu.isOpen, closeContextMenu]);
89
101
 
90
102
  // Close on escape
@@ -271,6 +283,48 @@ export function EntityContextMenu() {
271
283
  closeContextMenu();
272
284
  }, [contextEntityRef, canEdit, contextEntityType, contextMenu.entityId, removeEntity, hideEntity, setSelectedEntityId, closeContextMenu]);
273
285
 
286
+ // Viewport-constrained placement (mirrors OS context-menu behaviour):
287
+ // flip up/left when the menu would overflow the bottom/right edges,
288
+ // then clamp against the opposite edge so it never crosses any side
289
+ // of the viewport. Two-pass render: invisible first, then measure and
290
+ // reposition before the browser paints (useLayoutEffect is sync).
291
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
292
+
293
+ useLayoutEffect(() => {
294
+ if (!contextMenu.isOpen) {
295
+ setPosition(null);
296
+ return;
297
+ }
298
+ const node = menuRef.current;
299
+ if (!node) return;
300
+ const rect = node.getBoundingClientRect();
301
+ const margin = 4;
302
+ const vw = window.innerWidth;
303
+ const vh = window.innerHeight;
304
+ const anchorX = contextMenu.screenX;
305
+ const anchorY = contextMenu.screenY;
306
+
307
+ // Horizontal: prefer right of cursor, flip to left if it would
308
+ // overflow the right edge, then clamp.
309
+ let left = anchorX;
310
+ if (left + rect.width + margin > vw) {
311
+ const flipped = anchorX - rect.width;
312
+ left = flipped >= margin ? flipped : Math.max(margin, vw - rect.width - margin);
313
+ }
314
+ if (left < margin) left = margin;
315
+
316
+ // Vertical: prefer below cursor, flip above if it would overflow
317
+ // the bottom edge, then clamp.
318
+ let top = anchorY;
319
+ if (top + rect.height + margin > vh) {
320
+ const flipped = anchorY - rect.height;
321
+ top = flipped >= margin ? flipped : Math.max(margin, vh - rect.height - margin);
322
+ }
323
+ if (top < margin) top = margin;
324
+
325
+ setPosition({ left, top });
326
+ }, [contextMenu.isOpen, contextMenu.screenX, contextMenu.screenY, contextMenu.entityId]);
327
+
274
328
  if (!contextMenu.isOpen) {
275
329
  return null;
276
330
  }
@@ -289,8 +343,12 @@ export function EntityContextMenu() {
289
343
  ref={menuRef}
290
344
  className="fixed z-50 bg-popover border rounded-lg shadow-lg py-1 min-w-48"
291
345
  style={{
292
- left: contextMenu.screenX,
293
- top: contextMenu.screenY,
346
+ left: position?.left ?? contextMenu.screenX,
347
+ top: position?.top ?? contextMenu.screenY,
348
+ // Hide the first render: we need a measured rect to compute the
349
+ // constrained position. `useLayoutEffect` resolves this before
350
+ // paint, so the user never sees the unclamped flash.
351
+ visibility: position ? 'visible' : 'hidden',
294
352
  }}
295
353
  >
296
354
  {contextMenu.entityId && (
@@ -42,6 +42,7 @@ import {
42
42
  FileCode2,
43
43
  CalendarClock,
44
44
  Globe2,
45
+ Move,
45
46
  Settings,
46
47
  } from 'lucide-react';
47
48
  import { Button } from '@/components/ui/button';
@@ -72,7 +73,6 @@ import { BulkPropertyEditor } from './BulkPropertyEditor';
72
73
  import { DataConnector } from './DataConnector';
73
74
  import { ExportChangesButton } from './ExportChangesButton';
74
75
  import { SearchInline } from './SearchInline';
75
- // CesiumSettingsDialog removed — settings now shown as overlay on Cesium viewer
76
76
  import { useFloorplanView } from '@/hooks/useFloorplanView';
77
77
  import { buildDesktopUpgradeUrl, hasDesktopFeatureAccess, type DesktopFeature } from '@/lib/desktop-product';
78
78
  import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
@@ -341,6 +341,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
341
341
  const cesiumAvailable = useViewerStore((state) => state.cesiumAvailable);
342
342
  const cesiumEnabled = useViewerStore((state) => state.cesiumEnabled);
343
343
  const toggleCesium = useViewerStore((state) => state.toggleCesium);
344
+ const cesiumPlacementEditMode = useViewerStore((state) => state.cesiumPlacementEditMode);
345
+ const setCesiumPlacementEditMode = useViewerStore((state) => state.setCesiumPlacementEditMode);
344
346
  const storeModels = useViewerStore((state) => state.models);
345
347
  const desktopEntitlement = useViewerStore((state) => state.desktopEntitlement);
346
348
  const analysisExtensionState = useSyncExternalStore(
@@ -1273,9 +1275,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1273
1275
  </TooltipContent>
1274
1276
  </Tooltip>
1275
1277
 
1276
- {/* Cesium 3D Context toggle + settings — web only, only when model has georeferencing */}
1278
+ {/* Cesium 3D Context toggle — web only, only when model has georeferencing */}
1277
1279
  {cesiumAvailable && !desktopShell && (
1278
- <div className="flex items-center">
1280
+ <div className="flex items-center gap-1">
1279
1281
  <Tooltip>
1280
1282
  <TooltipTrigger asChild>
1281
1283
  <Button
@@ -1286,6 +1288,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1286
1288
  onClick={(e) => {
1287
1289
  (e.currentTarget as HTMLButtonElement).blur();
1288
1290
  toggleCesium();
1291
+ if (cesiumEnabled) {
1292
+ setCesiumPlacementEditMode(false);
1293
+ if (activeTool === 'cesium-placement') setActiveTool('select');
1294
+ }
1289
1295
  }}
1290
1296
  className={cn(cesiumEnabled && 'bg-teal-600 text-white hover:bg-teal-700')}
1291
1297
  >
@@ -1296,6 +1302,30 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1296
1302
  {cesiumEnabled ? 'Hide' : 'Show'} 3D World Context (Cesium)
1297
1303
  </TooltipContent>
1298
1304
  </Tooltip>
1305
+ {cesiumEnabled && (
1306
+ <Tooltip>
1307
+ <TooltipTrigger asChild>
1308
+ <Button
1309
+ variant={cesiumPlacementEditMode ? 'default' : 'ghost'}
1310
+ size="icon-sm"
1311
+ aria-label={cesiumPlacementEditMode ? 'Stop moving georeference' : 'Move georeference in Cesium'}
1312
+ aria-pressed={cesiumPlacementEditMode}
1313
+ onClick={(e) => {
1314
+ (e.currentTarget as HTMLButtonElement).blur();
1315
+ const next = !cesiumPlacementEditMode;
1316
+ setCesiumPlacementEditMode(next);
1317
+ setActiveTool(next ? 'cesium-placement' : 'select');
1318
+ }}
1319
+ className={cn(cesiumPlacementEditMode && 'bg-amber-500 text-zinc-950 hover:bg-amber-400')}
1320
+ >
1321
+ <Move className="h-4 w-4" />
1322
+ </Button>
1323
+ </TooltipTrigger>
1324
+ <TooltipContent>
1325
+ {cesiumPlacementEditMode ? 'Stop moving georeference' : 'Move georeference'}
1326
+ </TooltipContent>
1327
+ </Tooltip>
1328
+ )}
1299
1329
  </div>
1300
1330
  )}
1301
1331
 
@@ -12,6 +12,7 @@ import { Section2DPanel } from './Section2DPanel';
12
12
  import { BasketPresentationDock } from './BasketPresentationDock';
13
13
  import { BCFOverlay } from './bcf/BCFOverlay';
14
14
  import { CesiumOverlay } from './CesiumOverlay';
15
+ import { CesiumPlacementEditor } from './CesiumPlacementEditor';
15
16
  import { getViewerStoreApi, useViewerStore } from '@/store';
16
17
  import { toGlobalIdFromModels } from '@/store/globalId';
17
18
  import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet';
@@ -25,7 +26,7 @@ import { toast } from '@/components/ui/toast';
25
26
  import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
26
27
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight } from 'lucide-react';
27
28
  import type { MeshData, CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
28
- import { type IfcDataStore } from '@ifc-lite/parser';
29
+ import { type IfcDataStore, type MapConversion } from '@ifc-lite/parser';
29
30
  import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
30
31
 
31
32
  const ZERO_VEC3 = { x: 0, y: 0, z: 0 };
@@ -46,6 +47,8 @@ export function ViewportContainer() {
46
47
  const resetViewerState = useViewerStore((s) => s.resetViewerState);
47
48
  const bcfOverlayVisible = useViewerStore((s) => s.bcfOverlayVisible);
48
49
  const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
50
+ const cesiumPlacementDraft = useViewerStore((s) => s.cesiumPlacementDraft);
51
+ const cesiumPlacementDraftModelId = useViewerStore((s) => s.cesiumPlacementDraftModelId);
49
52
  const georefMutations = useViewerStore((s) => s.georefMutations);
50
53
  const setCesiumSourceModelId = useViewerStore((s) => s.setCesiumSourceModelId);
51
54
  const setCesiumAvailable = useViewerStore((s) => s.setCesiumAvailable);
@@ -206,6 +209,27 @@ export function ViewportContainer() {
206
209
  const georef = useMemo(() => {
207
210
  if (!cesiumEnabled) return null;
208
211
 
212
+ const applyPlacementDraft = <T extends { mapConversion?: MapConversion }>(
213
+ modelId: string,
214
+ effective: T,
215
+ ): T & { baseMapConversion?: T['mapConversion'] } => {
216
+ const preview = cesiumPlacementDraftModelId === modelId ? cesiumPlacementDraft : null;
217
+ if (!preview || !effective.mapConversion) {
218
+ return {
219
+ ...effective,
220
+ baseMapConversion: effective.mapConversion,
221
+ };
222
+ }
223
+ return {
224
+ ...effective,
225
+ baseMapConversion: effective.mapConversion,
226
+ mapConversion: {
227
+ ...effective.mapConversion,
228
+ ...preview,
229
+ },
230
+ };
231
+ };
232
+
209
233
  // Check federated models first
210
234
  for (const [modelId, model] of storeModels) {
211
235
  const ds = model.ifcDataStore;
@@ -215,9 +239,14 @@ export function ViewportContainer() {
215
239
  model.geometryResult?.coordinateInfo,
216
240
  georefMutations.get(modelId),
217
241
  );
218
- if (effective?.projectedCRS?.name && effective.mapConversion) {
242
+ if (
243
+ effective?.projectedCRS?.name
244
+ && effective.mapConversion
245
+ && effective.source !== 'siteLocation'
246
+ ) {
247
+ const previewed = applyPlacementDraft(modelId, effective);
219
248
  return {
220
- ...effective,
249
+ ...previewed,
221
250
  sourceModelId: modelId,
222
251
  storeyElevations: ds.spatialHierarchy?.storeyElevations,
223
252
  };
@@ -231,9 +260,14 @@ export function ViewportContainer() {
231
260
  mergedGeometryResult?.coordinateInfo,
232
261
  georefMutations.get('__legacy__'),
233
262
  );
234
- if (effective?.projectedCRS?.name && effective.mapConversion) {
263
+ if (
264
+ effective?.projectedCRS?.name
265
+ && effective.mapConversion
266
+ && effective.source !== 'siteLocation'
267
+ ) {
268
+ const previewed = applyPlacementDraft('__legacy__', effective);
235
269
  return {
236
- ...effective,
270
+ ...previewed,
237
271
  sourceModelId: '__legacy__',
238
272
  storeyElevations: ifcDataStore.spatialHierarchy?.storeyElevations,
239
273
  };
@@ -241,7 +275,16 @@ export function ViewportContainer() {
241
275
  }
242
276
 
243
277
  return null;
244
- }, [cesiumEnabled, storeModels, ifcDataStore, georefMutations, mutationVersion, mergedGeometryResult]);
278
+ }, [
279
+ cesiumEnabled,
280
+ storeModels,
281
+ ifcDataStore,
282
+ georefMutations,
283
+ mutationVersion,
284
+ mergedGeometryResult,
285
+ cesiumPlacementDraft,
286
+ cesiumPlacementDraftModelId,
287
+ ]);
245
288
 
246
289
  // Determine whether Cesium button should be visible (model has georef or user added it via mutations).
247
290
  // Runs independently of cesiumEnabled so the button appears/disappears reactively.
@@ -256,7 +299,7 @@ export function ViewportContainer() {
256
299
  model.geometryResult?.coordinateInfo,
257
300
  georefMutations.get(modelId),
258
301
  );
259
- if (effective?.projectedCRS?.name) return true;
302
+ if (effective?.projectedCRS?.name && effective.source !== 'siteLocation') return true;
260
303
  }
261
304
  // Fallback to legacy single-model
262
305
  if (ifcDataStore) {
@@ -265,7 +308,7 @@ export function ViewportContainer() {
265
308
  mergedGeometryResult?.coordinateInfo,
266
309
  georefMutations.get('__legacy__'),
267
310
  );
268
- if (effective?.projectedCRS?.name) return true;
311
+ if (effective?.projectedCRS?.name && effective.source !== 'siteLocation') return true;
269
312
  }
270
313
  return false;
271
314
  }
@@ -442,14 +485,13 @@ export function ViewportContainer() {
442
485
  if (ifcType === 'IfcSite' && !typeVisibility.site) continue;
443
486
  }
444
487
 
445
- if (ifcType === 'IfcSpace' || ifcType === 'IfcOpeningElement') {
446
- cache.push({
447
- ...mesh,
448
- color: [mesh.color[0], mesh.color[1], mesh.color[2], Math.min(mesh.color[3] * 0.3, 0.3)],
449
- });
450
- } else {
451
- cache.push(mesh);
452
- }
488
+ // Mesh alpha flows through unchanged. The previous code re-multiplied
489
+ // IfcSpace / IfcOpeningElement alpha down to <= 0.3 here, which stomped
490
+ // lens / Pset colour rules even when the user explicitly chose alpha 1.0.
491
+ // Defaults still come from styling.rs / default-materials.ts; the
492
+ // renderer promotes overridden entities to the opaque pipeline so the
493
+ // overlay paint pass finds matching depth. See issue #677.
494
+ cache.push(mesh);
453
495
  }
454
496
 
455
497
  filteredSourceLenRef.current = allMeshes.length;
@@ -913,6 +955,7 @@ export function ViewportContainer() {
913
955
  {cesiumEnabled && georef && !isTauri() && (
914
956
  <CesiumOverlay
915
957
  mapConversion={georef.mapConversion}
958
+ cameraMapConversion={georef.baseMapConversion}
916
959
  projectedCRS={georef.projectedCRS}
917
960
  coordinateInfo={georef.coordinateInfo}
918
961
  geometryResult={mergedGeometryResult}
@@ -920,6 +963,17 @@ export function ViewportContainer() {
920
963
  storeyElevations={georef.storeyElevations}
921
964
  />
922
965
  )}
966
+ {cesiumEnabled && georef?.mapConversion && !isTauri() && georef.baseMapConversion && (
967
+ <CesiumPlacementEditor
968
+ modelId={georef.sourceModelId}
969
+ mapConversion={georef.mapConversion}
970
+ baseMapConversion={georef.baseMapConversion}
971
+ projectedCRS={georef.projectedCRS}
972
+ coordinateInfo={georef.coordinateInfo}
973
+ lengthUnitScale={georef.lengthUnitScale}
974
+ storeyElevations={georef.storeyElevations}
975
+ />
976
+ )}
923
977
  <Viewport
924
978
  geometry={filteredGeometry}
925
979
  geometryVersion={geometryVersion}