@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.
- package/.turbo/turbo-build.log +57 -50
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/assets/arrow-fie-E7fe.js +20 -0
- package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
- package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
- package/dist/assets/bcf-Bhx-K17f.js +281 -0
- package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
- package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
- package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
- package/dist/assets/e57-source-CQHxE8n3.js +1 -0
- package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
- package/dist/assets/exporters-KTio0Tdm.js +5723 -0
- package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
- package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
- package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
- package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
- package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
- package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
- package/dist/assets/index-BZC2YaOP.css +1 -0
- package/dist/assets/index-HqAIQkr6.js +22 -0
- package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
- package/dist/assets/las-BW6LIc_j.js +1 -0
- package/dist/assets/las-source-C_IGrgRq.js +1 -0
- package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
- package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
- package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
- package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
- package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
- package/dist/assets/ply-source-C8jjyzxE.js +4 -0
- package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
- package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
- package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
- package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
- package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
- package/dist/assets/zip-BJqVbRkU.js +2 -0
- package/dist/index.html +10 -12
- package/package.json +11 -11
- package/src/components/mcp/PlaygroundChat.tsx +90 -52
- package/src/components/viewer/CesiumOverlay.tsx +150 -91
- package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
- package/src/components/viewer/ChatPanel.tsx +76 -93
- package/src/components/viewer/EntityContextMenu.tsx +68 -10
- package/src/components/viewer/MainToolbar.tsx +33 -3
- package/src/components/viewer/ViewportContainer.tsx +70 -16
- package/src/components/viewer/ViewportOverlays.tsx +2 -98
- package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
- package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
- package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
- package/src/components/viewer/selectionHandlers.ts +7 -1
- package/src/lib/geo/cesium-bridge.ts +86 -50
- package/src/lib/geo/cesium-placement.test.ts +244 -0
- package/src/lib/geo/cesium-placement.ts +231 -0
- package/src/lib/geo/effective-georef.test.ts +74 -1
- package/src/lib/geo/effective-georef.ts +40 -93
- package/src/lib/geo/geo-scale.ts +104 -0
- package/src/lib/geo/reproject.test.ts +130 -0
- package/src/lib/geo/reproject.ts +37 -12
- package/src/lib/geo/terrain-elevation.ts +198 -89
- package/src/lib/lens/adapter.ts +52 -6
- package/src/lib/llm/clipboard-detect.test.ts +150 -0
- package/src/lib/llm/clipboard-detect.ts +90 -0
- package/src/lib/llm/models.ts +28 -0
- package/src/lib/llm/stream-direct.ts +16 -4
- package/src/lib/llm/types.ts +8 -0
- package/src/services/playground-model.ts +55 -0
- package/src/store/index.ts +4 -5
- package/src/store/slices/cesiumSlice.ts +100 -19
- package/src/store.ts +3 -0
- package/dist/assets/arrow-CZ5kQ26f.js +0 -20
- package/dist/assets/bcf-4K724hw0.js +0 -281
- package/dist/assets/cesium-DUOzBlqv.js +0 -17817
- package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
- package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
- package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
- package/dist/assets/index-CSWgTe1s.css +0 -1
- package/dist/assets/index-XwKzDuw6.js +0 -22
- package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
- package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
- package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
- package/dist/assets/zip-DBEtpeu6.js +0 -12
- 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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
{/*
|
|
1260
|
-
|
|
1261
|
-
|
|
1309
|
+
{/* Slim CTA banner — appears 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 ? `
|
|
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
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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 (
|
|
242
|
+
if (
|
|
243
|
+
effective?.projectedCRS?.name
|
|
244
|
+
&& effective.mapConversion
|
|
245
|
+
&& effective.source !== 'siteLocation'
|
|
246
|
+
) {
|
|
247
|
+
const previewed = applyPlacementDraft(modelId, effective);
|
|
219
248
|
return {
|
|
220
|
-
...
|
|
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 (
|
|
263
|
+
if (
|
|
264
|
+
effective?.projectedCRS?.name
|
|
265
|
+
&& effective.mapConversion
|
|
266
|
+
&& effective.source !== 'siteLocation'
|
|
267
|
+
) {
|
|
268
|
+
const previewed = applyPlacementDraft('__legacy__', effective);
|
|
235
269
|
return {
|
|
236
|
-
...
|
|
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
|
-
}, [
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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}
|