@mlightcad/cad-simple-viewer 1.7.7-preview.0 → 1.7.7

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.
@@ -1,190 +1,190 @@
1
- import {
2
- acdbHostApplicationServices,
3
- AcGeBox2d,
4
- AcGePoint2d
5
- } from '@mlightcad/data-model'
6
-
7
- import type { AcApDocManager } from '../app/AcApDocManager'
8
- import { acapFindHitBox, type AcApFindLayoutHit } from './findDrawing'
9
-
10
- /** Options for {@link acapNavigateToFindHit}. */
11
- export interface AcApFindNavigateOptions {
12
- /**
13
- * Activate the hit's layout when it is not the active one. Default `true`.
14
- * When `false` a cross-layout hit is not navigated and `layout-mismatch`
15
- * is returned.
16
- */
17
- activateLayout?: boolean
18
- /** Margin passed to `zoomTo`. Default `1.1`. */
19
- margin?: number
20
- /**
21
- * Maximum time (ms) to wait for the freshly activated layout to finish
22
- * converting before zooming and selecting. Default 15000.
23
- */
24
- idleTimeoutMs?: number
25
- /**
26
- * Canvas area (CSS pixels from each edge) covered by UI such as a docked
27
- * palette. The hit is framed and centred in the remaining visible part of
28
- * the canvas so it never ends up hidden behind the palette.
29
- */
30
- insets?: AcApFindViewInsets
31
- }
32
-
33
- /** Pixels of the canvas covered on each side; see {@link AcApFindNavigateOptions.insets}. */
34
- export interface AcApFindViewInsets {
35
- left?: number
36
- right?: number
37
- top?: number
38
- bottom?: number
39
- }
40
-
41
- /** View methods used to keep the hit inside the uncovered canvas area. */
42
- interface InsetView {
43
- width?: number
44
- height?: number
45
- center?: { x: number; y: number }
46
- worldToScreen?(p: { x: number; y: number }): { x: number; y: number }
47
- screenToWorld?(p: { x: number; y: number }): { x: number; y: number }
48
- }
49
-
50
- /**
51
- * Zooms to `box` so that it fits, centred, in the part of the canvas that
52
- * `insets` leaves visible. Falls back to a plain `zoomTo` when the view
53
- * cannot convert coordinates or the visible part is too small to matter.
54
- */
55
- function zoomToVisible(
56
- view: InsetView & { zoomTo(box: AcGeBox2d, margin?: number): void },
57
- box: { min: { x: number; y: number }; max: { x: number; y: number } },
58
- margin: number,
59
- insets: AcApFindViewInsets | undefined
60
- ) {
61
- const width = Number(view.width) || 0
62
- const height = Number(view.height) || 0
63
- const left = Math.max(0, insets?.left ?? 0)
64
- const right = Math.max(0, insets?.right ?? 0)
65
- const top = Math.max(0, insets?.top ?? 0)
66
- const bottom = Math.max(0, insets?.bottom ?? 0)
67
- const visibleW = width - left - right
68
- const visibleH = height - top - bottom
69
- const usable =
70
- width > 0 &&
71
- height > 0 &&
72
- left + right + top + bottom > 0 &&
73
- visibleW >= width * 0.2 &&
74
- visibleH >= height * 0.2 &&
75
- !!view.worldToScreen &&
76
- !!view.screenToWorld
77
- if (!usable) {
78
- view.zoomTo(
79
- new AcGeBox2d(
80
- new AcGePoint2d(box.min.x, box.min.y),
81
- new AcGePoint2d(box.max.x, box.max.y)
82
- ),
83
- margin
84
- )
85
- return
86
- }
87
- // Grow the box so that, fitted to the full canvas, its original part
88
- // fits the visible part.
89
- const cx = (box.min.x + box.max.x) / 2
90
- const cy = (box.min.y + box.max.y) / 2
91
- const halfW = ((box.max.x - box.min.x) / 2) * (width / visibleW)
92
- const halfH = ((box.max.y - box.min.y) / 2) * (height / visibleH)
93
- view.zoomTo(
94
- new AcGeBox2d(
95
- new AcGePoint2d(cx - halfW, cy - halfH),
96
- new AcGePoint2d(cx + halfW, cy + halfH)
97
- ),
98
- margin
99
- )
100
- // Then pan so the hit sits in the middle of the visible part.
101
- const hitOnScreen = view.worldToScreen!({ x: cx, y: cy })
102
- const targetX = left + visibleW / 2
103
- const targetY = top + visibleH / 2
104
- const newCenter = view.screenToWorld!({
105
- x: width / 2 + (hitOnScreen.x - targetX),
106
- y: height / 2 + (hitOnScreen.y - targetY)
107
- })
108
- if (isFinite(newCenter.x) && isFinite(newCenter.y)) {
109
- view.center = new AcGePoint2d(newCenter.x, newCenter.y)
110
- }
111
- }
112
-
113
- /**
114
- * Outcome of {@link acapNavigateToFindHit}.
115
- *
116
- * - `navigated`: layout (if needed) activated, view zoomed, entity selected
117
- * - `layout-mismatch`: hit is in another layout and `activateLayout` is false
118
- * - `layout-missing`: the hit's layout no longer exists in the drawing
119
- * - `layout-switch-failed`: the layout manager refused / did not switch
120
- * - `no-document`: no open drawing or view
121
- * - `stale`: a newer navigation started while waiting; nothing was done
122
- */
123
- export type AcApFindNavigateResult =
124
- | 'navigated'
125
- | 'layout-mismatch'
126
- | 'layout-missing'
127
- | 'layout-switch-failed'
128
- | 'no-document'
129
- | 'stale'
130
-
131
- const navigationTokens = new WeakMap<object, number>()
132
-
133
- /**
134
- * Navigates to a FIND hit: activates its layout when needed, zooms to the text
135
- * and selects the owning entity.
136
- *
137
- * Same-layout hits are handled synchronously (before the first `await`), so
138
- * callers may ignore the returned promise in that case. For a hit in another
139
- * layout the layout is activated through the document's layout manager; the
140
- * first-visit auto zoom of that layout is suppressed (it would otherwise
141
- * override this zoom), then the function waits for the layout's entities to
142
- * be converted before selecting, because selection highlighting needs the
143
- * entity to be present in the scene.
144
- *
145
- * @param editor - Document manager that owns the active view and drawing.
146
- * @param hit - Hit returned by `acapFindText`.
147
- */
148
- export async function acapNavigateToFindHit(
149
- editor: AcApDocManager,
150
- hit: AcApFindLayoutHit,
151
- options: AcApFindNavigateOptions = {}
152
- ): Promise<AcApFindNavigateResult> {
153
- const view = editor.curView
154
- const db = editor.curDocument?.database
155
- if (!view || !db) return 'no-document'
156
-
157
- const token = (navigationTokens.get(view) ?? 0) + 1
158
- navigationTokens.set(view, token)
159
-
160
- if (view.activeLayoutBtrId !== hit.layoutId) {
161
- if (options.activateLayout === false) return 'layout-mismatch'
162
- if (!db.tables.blockTable.getIdAt(hit.layoutId)) return 'layout-missing'
163
-
164
- if (!view.isLayoutInitialized(hit.layoutId)) {
165
- // We frame the layout ourselves; keep its first-visit auto zoom from
166
- // running afterwards and overriding the zoom to the hit.
167
- view.markLayoutAsInitialized(hit.layoutId)
168
- }
169
- acdbHostApplicationServices().layoutManager.setCurrentLayoutBtrId(
170
- hit.layoutId,
171
- db
172
- )
173
- if (view.activeLayoutBtrId !== hit.layoutId) return 'layout-switch-failed'
174
-
175
- await view.waitUntilIdle(options.idleTimeoutMs ?? 15_000)
176
- if (navigationTokens.get(view) !== token) return 'stale'
177
- if (view.activeLayoutBtrId !== hit.layoutId) return 'stale'
178
- }
179
-
180
- const box = acapFindHitBox(db, hit.layoutId, hit)
181
- zoomToVisible(
182
- view as unknown as Parameters<typeof zoomToVisible>[0],
183
- box,
184
- options.margin ?? 1.1,
185
- options.insets
186
- )
187
- view.selectionSet.clear()
188
- view.selectionSet.add(hit.entityId)
189
- return 'navigated'
190
- }
1
+ import {
2
+ acdbHostApplicationServices,
3
+ AcGeBox2d,
4
+ AcGePoint2d
5
+ } from '@mlightcad/data-model'
6
+
7
+ import type { AcApDocManager } from '../app/AcApDocManager'
8
+ import { acapFindHitBox, type AcApFindLayoutHit } from './findDrawing'
9
+
10
+ /** Options for {@link acapNavigateToFindHit}. */
11
+ export interface AcApFindNavigateOptions {
12
+ /**
13
+ * Activate the hit's layout when it is not the active one. Default `true`.
14
+ * When `false` a cross-layout hit is not navigated and `layout-mismatch`
15
+ * is returned.
16
+ */
17
+ activateLayout?: boolean
18
+ /** Margin passed to `zoomTo`. Default `1.1`. */
19
+ margin?: number
20
+ /**
21
+ * Maximum time (ms) to wait for the freshly activated layout to finish
22
+ * converting before zooming and selecting. Default 15000.
23
+ */
24
+ idleTimeoutMs?: number
25
+ /**
26
+ * Canvas area (CSS pixels from each edge) covered by UI such as a docked
27
+ * palette. The hit is framed and centred in the remaining visible part of
28
+ * the canvas so it never ends up hidden behind the palette.
29
+ */
30
+ insets?: AcApFindViewInsets
31
+ }
32
+
33
+ /** Pixels of the canvas covered on each side; see {@link AcApFindNavigateOptions.insets}. */
34
+ export interface AcApFindViewInsets {
35
+ left?: number
36
+ right?: number
37
+ top?: number
38
+ bottom?: number
39
+ }
40
+
41
+ /** View methods used to keep the hit inside the uncovered canvas area. */
42
+ interface InsetView {
43
+ width?: number
44
+ height?: number
45
+ center?: { x: number; y: number }
46
+ worldToScreen?(p: { x: number; y: number }): { x: number; y: number }
47
+ screenToWorld?(p: { x: number; y: number }): { x: number; y: number }
48
+ }
49
+
50
+ /**
51
+ * Zooms to `box` so that it fits, centred, in the part of the canvas that
52
+ * `insets` leaves visible. Falls back to a plain `zoomTo` when the view
53
+ * cannot convert coordinates or the visible part is too small to matter.
54
+ */
55
+ function zoomToVisible(
56
+ view: InsetView & { zoomTo(box: AcGeBox2d, margin?: number): void },
57
+ box: { min: { x: number; y: number }; max: { x: number; y: number } },
58
+ margin: number,
59
+ insets: AcApFindViewInsets | undefined
60
+ ) {
61
+ const width = Number(view.width) || 0
62
+ const height = Number(view.height) || 0
63
+ const left = Math.max(0, insets?.left ?? 0)
64
+ const right = Math.max(0, insets?.right ?? 0)
65
+ const top = Math.max(0, insets?.top ?? 0)
66
+ const bottom = Math.max(0, insets?.bottom ?? 0)
67
+ const visibleW = width - left - right
68
+ const visibleH = height - top - bottom
69
+ const usable =
70
+ width > 0 &&
71
+ height > 0 &&
72
+ left + right + top + bottom > 0 &&
73
+ visibleW >= width * 0.2 &&
74
+ visibleH >= height * 0.2 &&
75
+ !!view.worldToScreen &&
76
+ !!view.screenToWorld
77
+ if (!usable) {
78
+ view.zoomTo(
79
+ new AcGeBox2d(
80
+ new AcGePoint2d(box.min.x, box.min.y),
81
+ new AcGePoint2d(box.max.x, box.max.y)
82
+ ),
83
+ margin
84
+ )
85
+ return
86
+ }
87
+ // Grow the box so that, fitted to the full canvas, its original part
88
+ // fits the visible part.
89
+ const cx = (box.min.x + box.max.x) / 2
90
+ const cy = (box.min.y + box.max.y) / 2
91
+ const halfW = ((box.max.x - box.min.x) / 2) * (width / visibleW)
92
+ const halfH = ((box.max.y - box.min.y) / 2) * (height / visibleH)
93
+ view.zoomTo(
94
+ new AcGeBox2d(
95
+ new AcGePoint2d(cx - halfW, cy - halfH),
96
+ new AcGePoint2d(cx + halfW, cy + halfH)
97
+ ),
98
+ margin
99
+ )
100
+ // Then pan so the hit sits in the middle of the visible part.
101
+ const hitOnScreen = view.worldToScreen!({ x: cx, y: cy })
102
+ const targetX = left + visibleW / 2
103
+ const targetY = top + visibleH / 2
104
+ const newCenter = view.screenToWorld!({
105
+ x: width / 2 + (hitOnScreen.x - targetX),
106
+ y: height / 2 + (hitOnScreen.y - targetY)
107
+ })
108
+ if (isFinite(newCenter.x) && isFinite(newCenter.y)) {
109
+ view.center = new AcGePoint2d(newCenter.x, newCenter.y)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Outcome of {@link acapNavigateToFindHit}.
115
+ *
116
+ * - `navigated`: layout (if needed) activated, view zoomed, entity selected
117
+ * - `layout-mismatch`: hit is in another layout and `activateLayout` is false
118
+ * - `layout-missing`: the hit's layout no longer exists in the drawing
119
+ * - `layout-switch-failed`: the layout manager refused / did not switch
120
+ * - `no-document`: no open drawing or view
121
+ * - `stale`: a newer navigation started while waiting; nothing was done
122
+ */
123
+ export type AcApFindNavigateResult =
124
+ | 'navigated'
125
+ | 'layout-mismatch'
126
+ | 'layout-missing'
127
+ | 'layout-switch-failed'
128
+ | 'no-document'
129
+ | 'stale'
130
+
131
+ const navigationTokens = new WeakMap<object, number>()
132
+
133
+ /**
134
+ * Navigates to a FIND hit: activates its layout when needed, zooms to the text
135
+ * and selects the owning entity.
136
+ *
137
+ * Same-layout hits are handled synchronously (before the first `await`), so
138
+ * callers may ignore the returned promise in that case. For a hit in another
139
+ * layout the layout is activated through the document's layout manager; the
140
+ * first-visit auto zoom of that layout is suppressed (it would otherwise
141
+ * override this zoom), then the function waits for the layout's entities to
142
+ * be converted before selecting, because selection highlighting needs the
143
+ * entity to be present in the scene.
144
+ *
145
+ * @param editor - Document manager that owns the active view and drawing.
146
+ * @param hit - Hit returned by `acapFindText`.
147
+ */
148
+ export async function acapNavigateToFindHit(
149
+ editor: AcApDocManager,
150
+ hit: AcApFindLayoutHit,
151
+ options: AcApFindNavigateOptions = {}
152
+ ): Promise<AcApFindNavigateResult> {
153
+ const view = editor.curView
154
+ const db = editor.curDocument?.database
155
+ if (!view || !db) return 'no-document'
156
+
157
+ const token = (navigationTokens.get(view) ?? 0) + 1
158
+ navigationTokens.set(view, token)
159
+
160
+ if (view.activeLayoutBtrId !== hit.layoutId) {
161
+ if (options.activateLayout === false) return 'layout-mismatch'
162
+ if (!db.tables.blockTable.getIdAt(hit.layoutId)) return 'layout-missing'
163
+
164
+ if (!view.isLayoutInitialized(hit.layoutId)) {
165
+ // We frame the layout ourselves; keep its first-visit auto zoom from
166
+ // running afterwards and overriding the zoom to the hit.
167
+ view.markLayoutAsInitialized(hit.layoutId)
168
+ }
169
+ acdbHostApplicationServices().layoutManager.setCurrentLayoutBtrId(
170
+ hit.layoutId,
171
+ db
172
+ )
173
+ if (view.activeLayoutBtrId !== hit.layoutId) return 'layout-switch-failed'
174
+
175
+ await view.waitUntilIdle(options.idleTimeoutMs ?? 15_000)
176
+ if (navigationTokens.get(view) !== token) return 'stale'
177
+ if (view.activeLayoutBtrId !== hit.layoutId) return 'stale'
178
+ }
179
+
180
+ const box = acapFindHitBox(db, hit.layoutId, hit)
181
+ zoomToVisible(
182
+ view as unknown as Parameters<typeof zoomToVisible>[0],
183
+ box,
184
+ options.margin ?? 1.1,
185
+ options.insets
186
+ )
187
+ view.selectionSet.clear()
188
+ view.selectionSet.add(hit.entityId)
189
+ return 'navigated'
190
+ }