@things-factory/figure-ui 10.1.85 → 10.1.87
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/client/modeller/editor.ts +44 -3
- package/client/modeller/figure-section-editor.ts +643 -0
- package/client/modeller/figure-section-panel.ts +7 -0
- package/client/modeller/live.ts +5 -0
- package/client/modeller/section-drawing.ts +150 -0
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +6 -0
- package/dist-client/modeller/editor.js +50 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +87 -0
- package/dist-client/modeller/figure-section-editor.js +635 -0
- package/dist-client/modeller/figure-section-editor.js.map +1 -0
- package/dist-client/modeller/figure-section-panel.js +7 -0
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/live.js +7 -0
- package/dist-client/modeller/live.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +52 -0
- package/dist-client/modeller/section-drawing.js +123 -0
- package/dist-client/modeller/section-drawing.js.map +1 -0
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/browser/acceptance-page.js +7 -0
- package/test/section-editor.test.ts +232 -0
- package/test/section-panel.test.ts +12 -9
- package/translations/en.json +34 -0
- package/translations/ja.json +34 -0
- package/translations/ko.json +34 -0
- package/translations/ms.json +34 -0
- package/translations/zh.json +34 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { css, html, LitElement, nothing, svg } from 'lit'
|
|
2
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
import { live } from 'lit/directives/live.js'
|
|
4
|
+
|
|
5
|
+
import { i18next, localize } from '@operato/i18n'
|
|
6
|
+
import { figureCavitySectionOf, figureSectionSidesOf } from '@hatiolab/figure-model'
|
|
7
|
+
import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
|
|
8
|
+
import { SidePanelStyles } from './side-panel-styles.js'
|
|
9
|
+
import { insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
|
|
10
|
+
import { END_FACE } from './section-controls.js'
|
|
11
|
+
|
|
12
|
+
type Axis = 'x' | 'y' | 'z'
|
|
13
|
+
|
|
14
|
+
/** How the section is laid on the screen, by the axis it is pushed along: across to the right, then down. */
|
|
15
|
+
const SCREEN: Record<Axis, [Axis, Axis]> = { y: ['x', 'z'], x: ['z', 'y'], z: ['x', 'y'] }
|
|
16
|
+
|
|
17
|
+
/** The editor's width and room for the solid beside it: below this the two take turns. */
|
|
18
|
+
export const NARROW_BELOW = 900
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 단면 편집 — a pushed part's section and length in one editor (ADR-0100 §3.2, stage 2).
|
|
22
|
+
*
|
|
23
|
+
* The outline is drawn flat, looking along the push, in millimetres from the part's origin; the figure behind stays in
|
|
24
|
+
* view and shows the solid the working state makes (`command-live`, the same preview the section panel uses), so the 2D
|
|
25
|
+
* and the 3D stand side by side. A drawn section's point is dragged or typed, a side pressed gains a point in its
|
|
26
|
+
* middle, a point is deleted, never below three. A rectangle's width and depth and an ellipse's diameters are dragged by
|
|
27
|
+
* their handles or typed; a circle's two are one. The push's length is typed, and the face it keeps is the one the
|
|
28
|
+
* part's placement or fastening keeps (§4.1: read here, changed elsewhere). A measure that follows another value is
|
|
29
|
+
* shown and not offered.
|
|
30
|
+
*
|
|
31
|
+
* What the kernel would refuse is marked on the outline and said in the list; apply is held back, and the solid shows
|
|
32
|
+
* the last valid state, which the caption names. Apply sends one `set-section` command (one step of the history); cancel
|
|
33
|
+
* or Esc sends nothing and lets the preview go. The undo here is the editor's own. If the figure changes while the editor
|
|
34
|
+
* is open, apply is refused until it is opened again.
|
|
35
|
+
*/
|
|
36
|
+
@customElement('figure-section-editor')
|
|
37
|
+
export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
38
|
+
static styles = [SidePanelStyles, css`
|
|
39
|
+
:host {
|
|
40
|
+
position: absolute;
|
|
41
|
+
left: 12px;
|
|
42
|
+
top: 12px;
|
|
43
|
+
bottom: 12px;
|
|
44
|
+
z-index: 5;
|
|
45
|
+
display: grid;
|
|
46
|
+
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
47
|
+
width: min(560px, calc(100% - 24px));
|
|
48
|
+
background: var(--md-sys-color-surface);
|
|
49
|
+
color: var(--md-sys-color-on-surface);
|
|
50
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
51
|
+
border-radius: 12px;
|
|
52
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
}
|
|
55
|
+
/* Each in its own row: the tabs are hidden on a wide screen, and without the rows named the body fell into theirs. */
|
|
56
|
+
header {
|
|
57
|
+
grid-row: 1;
|
|
58
|
+
}
|
|
59
|
+
nav[tabs] {
|
|
60
|
+
grid-row: 2;
|
|
61
|
+
}
|
|
62
|
+
div[body] {
|
|
63
|
+
grid-row: 3;
|
|
64
|
+
}
|
|
65
|
+
footer {
|
|
66
|
+
grid-row: 4;
|
|
67
|
+
}
|
|
68
|
+
header {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: baseline;
|
|
71
|
+
gap: 10px;
|
|
72
|
+
padding: 10px 14px 4px;
|
|
73
|
+
}
|
|
74
|
+
header h2 {
|
|
75
|
+
margin: 0;
|
|
76
|
+
font-size: var(--ui-t-body);
|
|
77
|
+
}
|
|
78
|
+
header span {
|
|
79
|
+
font-size: 0.72rem;
|
|
80
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
81
|
+
}
|
|
82
|
+
nav[tabs] {
|
|
83
|
+
display: none;
|
|
84
|
+
gap: 4px;
|
|
85
|
+
padding: 0 14px 6px;
|
|
86
|
+
}
|
|
87
|
+
nav[tabs] button,
|
|
88
|
+
footer button,
|
|
89
|
+
li button {
|
|
90
|
+
font: inherit;
|
|
91
|
+
font-size: 0.78rem;
|
|
92
|
+
min-height: 30px;
|
|
93
|
+
padding: 0 12px;
|
|
94
|
+
border-radius: 15px;
|
|
95
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
96
|
+
background: var(--md-sys-color-surface-container-lowest);
|
|
97
|
+
color: var(--md-sys-color-on-surface);
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
nav[tabs] button[on] {
|
|
101
|
+
background: var(--md-sys-color-secondary-container);
|
|
102
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
103
|
+
}
|
|
104
|
+
div[body] {
|
|
105
|
+
display: grid;
|
|
106
|
+
grid-template-rows: minmax(160px, 1fr) auto;
|
|
107
|
+
min-height: 0;
|
|
108
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
109
|
+
}
|
|
110
|
+
div[outline] {
|
|
111
|
+
position: relative;
|
|
112
|
+
min-height: 0;
|
|
113
|
+
}
|
|
114
|
+
svg {
|
|
115
|
+
width: 100%;
|
|
116
|
+
height: 100%;
|
|
117
|
+
display: block;
|
|
118
|
+
touch-action: none;
|
|
119
|
+
user-select: none;
|
|
120
|
+
}
|
|
121
|
+
div[caption] {
|
|
122
|
+
position: absolute;
|
|
123
|
+
left: 8px;
|
|
124
|
+
top: 6px;
|
|
125
|
+
font-size: 0.68rem;
|
|
126
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
127
|
+
pointer-events: none;
|
|
128
|
+
}
|
|
129
|
+
div[caption][last-valid] {
|
|
130
|
+
color: var(--md-sys-color-error);
|
|
131
|
+
font-weight: 600;
|
|
132
|
+
}
|
|
133
|
+
aside {
|
|
134
|
+
max-height: 34vh;
|
|
135
|
+
overflow: auto;
|
|
136
|
+
padding: 8px 14px;
|
|
137
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
138
|
+
}
|
|
139
|
+
div[sizes] {
|
|
140
|
+
display: grid;
|
|
141
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
142
|
+
gap: 6px;
|
|
143
|
+
margin-bottom: 8px;
|
|
144
|
+
}
|
|
145
|
+
label[mm] {
|
|
146
|
+
display: grid;
|
|
147
|
+
gap: 2px;
|
|
148
|
+
}
|
|
149
|
+
label[mm] input {
|
|
150
|
+
min-width: 0;
|
|
151
|
+
font: inherit;
|
|
152
|
+
}
|
|
153
|
+
ol[points] {
|
|
154
|
+
list-style: none;
|
|
155
|
+
margin: 0;
|
|
156
|
+
padding: 0;
|
|
157
|
+
display: grid;
|
|
158
|
+
gap: 4px;
|
|
159
|
+
}
|
|
160
|
+
ol[points] li {
|
|
161
|
+
display: grid;
|
|
162
|
+
grid-template-columns: 2.2em minmax(0, 1fr) minmax(0, 1fr) auto;
|
|
163
|
+
gap: 6px;
|
|
164
|
+
align-items: center;
|
|
165
|
+
padding: 2px 4px;
|
|
166
|
+
border-radius: 6px;
|
|
167
|
+
}
|
|
168
|
+
ol[points] li[selected] {
|
|
169
|
+
background: var(--md-sys-color-secondary-container);
|
|
170
|
+
}
|
|
171
|
+
ol[points] li[marked] b {
|
|
172
|
+
color: var(--md-sys-color-error);
|
|
173
|
+
}
|
|
174
|
+
ol[points] input {
|
|
175
|
+
min-width: 0;
|
|
176
|
+
font: inherit;
|
|
177
|
+
}
|
|
178
|
+
ul[issues] {
|
|
179
|
+
margin: 8px 0 0;
|
|
180
|
+
padding: 0 0 0 16px;
|
|
181
|
+
color: var(--md-sys-color-error);
|
|
182
|
+
font-size: 0.76rem;
|
|
183
|
+
}
|
|
184
|
+
small {
|
|
185
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
186
|
+
}
|
|
187
|
+
footer {
|
|
188
|
+
display: flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
gap: 8px;
|
|
191
|
+
padding: 8px 14px;
|
|
192
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
193
|
+
}
|
|
194
|
+
footer span[grow] {
|
|
195
|
+
flex: 1;
|
|
196
|
+
font-size: 0.72rem;
|
|
197
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
198
|
+
}
|
|
199
|
+
footer span[grow][stale] {
|
|
200
|
+
color: var(--md-sys-color-error);
|
|
201
|
+
}
|
|
202
|
+
footer button[primary] {
|
|
203
|
+
background: var(--md-sys-color-primary);
|
|
204
|
+
color: var(--md-sys-color-on-primary);
|
|
205
|
+
border-color: transparent;
|
|
206
|
+
}
|
|
207
|
+
button:disabled {
|
|
208
|
+
opacity: 0.45;
|
|
209
|
+
cursor: default;
|
|
210
|
+
}
|
|
211
|
+
/* Too little room beside the editor for the solid: the outline and the solid take turns (§7). */
|
|
212
|
+
:host([narrow]) nav[tabs] {
|
|
213
|
+
display: flex;
|
|
214
|
+
}
|
|
215
|
+
:host([narrow][show='solid']) div[body] {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
:host([narrow][show='solid']) {
|
|
219
|
+
bottom: auto;
|
|
220
|
+
}
|
|
221
|
+
`]
|
|
222
|
+
|
|
223
|
+
/** The part and its evaluated shape as they stand when the editor opens. */
|
|
224
|
+
@property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
|
|
225
|
+
/** Which of its measures can be set (figure-model's `figureSectionMeasuresOf`). */
|
|
226
|
+
@property({ attribute: false }) measures?: { measures: Record<Axis, FigureSectionMeasure>; round: boolean }
|
|
227
|
+
/** Along the push, the face a length change keeps: 1 its start, -1 its end, 0 its centre; undefined when it cannot be said. */
|
|
228
|
+
@property({ attribute: false }) kept?: -1 | 0 | 1
|
|
229
|
+
/** The figure's revision now; the editor remembers the one it opened at. */
|
|
230
|
+
@property({ type: Number }) revision = 0
|
|
231
|
+
/** Narrow: the outline or the solid behind. */
|
|
232
|
+
@property({ reflect: true }) show: 'outline' | 'solid' = 'outline'
|
|
233
|
+
/** Set when the space the editor stands in leaves too little beside it to see the solid. */
|
|
234
|
+
@property({ type: Boolean, reflect: true }) narrow = false
|
|
235
|
+
private room = new ResizeObserver(([entry]) => {
|
|
236
|
+
this.narrow = (entry?.contentRect.width ?? 0) < NARROW_BELOW
|
|
237
|
+
if (!this.narrow) this.show = 'outline'
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
@state() private working: SectionWorking = { points: [], across: {}, length: 0 }
|
|
241
|
+
@state() private selected: string | null = null
|
|
242
|
+
@state() private note = ''
|
|
243
|
+
private opened!: SectionWorking
|
|
244
|
+
private history: SectionWorking[] = []
|
|
245
|
+
private lastValid!: SectionWorking
|
|
246
|
+
private openedAt = 0
|
|
247
|
+
private dragging: string | null = null
|
|
248
|
+
/** The state as it was when a drag began: kept once something moves, so a press alone is not a step back. */
|
|
249
|
+
private dragFrom: SectionWorking | null = null
|
|
250
|
+
private extent = 200
|
|
251
|
+
|
|
252
|
+
private get axis(): Axis {
|
|
253
|
+
return (this.body?.shape.extrusion?.axis ?? 'y') as Axis
|
|
254
|
+
}
|
|
255
|
+
private get kind() {
|
|
256
|
+
return this.body?.shape.extrusion?.section.kind
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
connectedCallback() {
|
|
260
|
+
super.connectedCallback()
|
|
261
|
+
this.working = workingOf(this.body!.shape)
|
|
262
|
+
this.opened = this.working
|
|
263
|
+
this.lastValid = this.working
|
|
264
|
+
this.openedAt = this.revision
|
|
265
|
+
this.extent = this.extentOf(this.working)
|
|
266
|
+
window.addEventListener('keydown', this.keys, true)
|
|
267
|
+
if (this.parentElement) this.room.observe(this.parentElement)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
disconnectedCallback() {
|
|
271
|
+
window.removeEventListener('keydown', this.keys, true)
|
|
272
|
+
this.room.disconnect()
|
|
273
|
+
super.disconnectedCallback()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** How far the drawing reaches from the origin: the outline's farthest point with room around, in 50 mm steps. */
|
|
277
|
+
private extentOf(w: SectionWorking): number {
|
|
278
|
+
const [h, v] = SCREEN[this.axis]
|
|
279
|
+
const far = Math.max(1, ...w.points.flatMap(p => [Math.abs(p[h] ?? 0), Math.abs(p[v] ?? 0)]), ...Object.values(w.across).map(a => (a ?? 0) / 2))
|
|
280
|
+
return Math.ceil((far * 1.4 + 40) / 50) * 50
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/*
|
|
284
|
+
* Heard first (capture on the window), and a key the editor takes goes no further: the scene behind listens too, and
|
|
285
|
+
* would take Esc as "select nothing" and Delete as "delete the part".
|
|
286
|
+
*/
|
|
287
|
+
private keys = (e: KeyboardEvent) => {
|
|
288
|
+
const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
|
|
289
|
+
const take = () => (e.preventDefault(), e.stopImmediatePropagation())
|
|
290
|
+
if (e.key === 'Escape') {
|
|
291
|
+
take()
|
|
292
|
+
this.cancel()
|
|
293
|
+
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !inField && this.selected) {
|
|
294
|
+
/* In a field the key deletes a character; outside, the chosen point. */
|
|
295
|
+
take()
|
|
296
|
+
this.removePoint(this.selected)
|
|
297
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !inField) {
|
|
298
|
+
take()
|
|
299
|
+
this.back()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private get stale() {
|
|
304
|
+
return this.revision !== this.openedAt
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private valid(w: SectionWorking) {
|
|
308
|
+
return workingIssuesOf(this.body!.shape, w).words.length === 0
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private change(next: SectionWorking) {
|
|
312
|
+
this.history.push(this.working)
|
|
313
|
+
this.working = next
|
|
314
|
+
this.note = ''
|
|
315
|
+
if (this.valid(next)) this.lastValid = next
|
|
316
|
+
this.preview()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private back() {
|
|
320
|
+
const before = this.history.pop()
|
|
321
|
+
if (!before) return
|
|
322
|
+
this.working = before
|
|
323
|
+
if (this.valid(before)) this.lastValid = before
|
|
324
|
+
this.preview()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The solid behind shows the working state, or the last valid one while the working one is refused. */
|
|
328
|
+
private preview() {
|
|
329
|
+
this.dispatchEvent(
|
|
330
|
+
new CustomEvent('command-live', { detail: { action: this.action(this.lastValid) }, bubbles: true, composed: true })
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** One command for everything changed since the editor opened; a measure not changed is not sent (it may follow another). */
|
|
335
|
+
private action(w: SectionWorking): FigureAuthoringAction {
|
|
336
|
+
const o = this.opened
|
|
337
|
+
const changed = (a: number | undefined, b: number | undefined) => Math.abs((a ?? 0) - (b ?? 0)) > 1e-9
|
|
338
|
+
const across = Object.fromEntries(Object.entries(w.across).filter(([k, v]) => changed(v, o.across[k as Axis])))
|
|
339
|
+
return {
|
|
340
|
+
kind: 'set-section',
|
|
341
|
+
part: this.body!.id,
|
|
342
|
+
...(this.kind === 'polygon' ? { points: w.points.map(p => ({ ...p })) } : {}),
|
|
343
|
+
...(Object.keys(across).length ? { across } : {}),
|
|
344
|
+
...(changed(w.length, o.length) ? { length: w.length } : {})
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private removePoint(id: string) {
|
|
349
|
+
const next = removePoint(this.working.points, id)
|
|
350
|
+
if ('refused' in next) {
|
|
351
|
+
this.note = i18next.t('figure.text.section-issue-too-few')
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
this.selected = null
|
|
355
|
+
this.change({ ...this.working, points: next })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A width across set: a circle's two are one. */
|
|
359
|
+
private across(axis: Axis, value: number): SectionWorking {
|
|
360
|
+
const [u, v] = SCREEN[this.axis]
|
|
361
|
+
const both = this.measures?.round ? { [u]: value, [v]: value } : { [axis]: value }
|
|
362
|
+
return { ...this.working, across: { ...this.working.across, ...both } }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private apply() {
|
|
366
|
+
if (this.stale || !this.valid(this.working)) return
|
|
367
|
+
this.dispatchEvent(new CustomEvent('command', { detail: { action: this.action(this.working) }, bubbles: true, composed: true }))
|
|
368
|
+
this.close()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private cancel() {
|
|
372
|
+
this.dispatchEvent(new CustomEvent('command-live', { detail: { action: null }, bubbles: true, composed: true }))
|
|
373
|
+
this.close()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private close() {
|
|
377
|
+
this.dispatchEvent(new CustomEvent('section-editor-close', { bubbles: true, composed: true }))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private svgPoint(e: PointerEvent): { h: number; v: number } {
|
|
381
|
+
const el = this.renderRoot.querySelector('svg')!
|
|
382
|
+
const pt = el.createSVGPoint()
|
|
383
|
+
pt.x = e.clientX
|
|
384
|
+
pt.y = e.clientY
|
|
385
|
+
const p = pt.matrixTransform(el.getScreenCTM()!.inverse())
|
|
386
|
+
return { h: Math.round(p.x), v: Math.round(p.y) }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Words for the face a length change keeps, by the part's relations; nothing when they cannot say. */
|
|
390
|
+
private keptWords(): string {
|
|
391
|
+
if (this.kept === undefined) return ''
|
|
392
|
+
if (this.kept === 0) return i18next.t('figure.text.section-editor-keeps-centre')
|
|
393
|
+
return i18next.t('figure.text.section-editor-keeps', {
|
|
394
|
+
face: i18next.t('figure.label.face-' + END_FACE(this.axis, this.kept === 1 ? 'start' : 'end'))
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
render() {
|
|
399
|
+
if (!this.body) return nothing
|
|
400
|
+
const { issues, words } = workingIssuesOf(this.body.shape, this.working)
|
|
401
|
+
return html`
|
|
402
|
+
<header>
|
|
403
|
+
<h2>${i18next.t('figure.label.section-editor')}</h2>
|
|
404
|
+
<span>${this.body.id}</span>
|
|
405
|
+
</header>
|
|
406
|
+
<nav tabs>
|
|
407
|
+
${(['outline', 'solid'] as const).map(
|
|
408
|
+
one => html`<button ?on=${this.show === one} @click=${() => (this.show = one)}>${i18next.t(`figure.label.section-editor-${one}`)}</button>`
|
|
409
|
+
)}
|
|
410
|
+
</nav>
|
|
411
|
+
<div body>
|
|
412
|
+
<div outline>${this.drawing(issues, words.length > 0)}</div>
|
|
413
|
+
<aside>${this.list(issues, words)}</aside>
|
|
414
|
+
</div>
|
|
415
|
+
<footer>
|
|
416
|
+
<button ?disabled=${!this.history.length} @click=${() => this.back()}>${i18next.t('figure.button.section-editor-back')}</button>
|
|
417
|
+
<span grow ?stale=${this.stale}>
|
|
418
|
+
${this.stale
|
|
419
|
+
? i18next.t('figure.text.section-editor-stale')
|
|
420
|
+
: i18next.t(this.kind === 'polygon' ? 'figure.text.section-editor-hint' : 'figure.text.section-editor-hint-sized')}
|
|
421
|
+
</span>
|
|
422
|
+
<button @click=${() => this.cancel()}>${i18next.t('figure.button.section-editor-cancel')}</button>
|
|
423
|
+
<button primary ?disabled=${this.stale || words.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
|
|
424
|
+
</footer>
|
|
425
|
+
`
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A section drawn as the screen lays it: across to the right, then down. */
|
|
429
|
+
private outline(section: FigureSection, attrs: { fill: string; stroke: string; width: number; dash?: string }, centre: Partial<Record<Axis, number>> = {}) {
|
|
430
|
+
const [h, v] = SCREEN[this.axis]
|
|
431
|
+
const ch = centre[h] ?? 0,
|
|
432
|
+
cv = centre[v] ?? 0
|
|
433
|
+
if (section.kind === 'polygon')
|
|
434
|
+
return svg`<polygon points=${section.points.map(p => `${p[h]},${p[v]}`).join(' ')} fill=${attrs.fill} stroke=${attrs.stroke} stroke-width=${attrs.width} stroke-dasharray=${attrs.dash ?? ''} />`
|
|
435
|
+
if (section.kind === 'ellipse')
|
|
436
|
+
return svg`<ellipse cx=${ch} cy=${cv} rx=${section.radius[h]} ry=${section.radius[v]} fill=${attrs.fill} stroke=${attrs.stroke} stroke-width=${attrs.width} stroke-dasharray=${attrs.dash ?? ''} />`
|
|
437
|
+
const w = section.size[h]!,
|
|
438
|
+
d = section.size[v]!
|
|
439
|
+
return svg`<rect x=${ch - w / 2} y=${cv - d / 2} width=${w} height=${d} rx=${section.corner?.[h] ?? 0} ry=${section.corner?.[v] ?? 0}
|
|
440
|
+
fill=${attrs.fill} stroke=${attrs.stroke} stroke-width=${attrs.width} stroke-dasharray=${attrs.dash ?? ''} />`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private drawing(issues: ReturnType<typeof workingIssuesOf>['issues'], invalid: boolean) {
|
|
444
|
+
const [h, v] = SCREEN[this.axis]
|
|
445
|
+
const e = this.extent
|
|
446
|
+
const marked = markedBy(issues)
|
|
447
|
+
const w = this.working
|
|
448
|
+
const section = sectionOf(this.body!.shape, w)
|
|
449
|
+
const pts = w.points
|
|
450
|
+
const sides = this.kind === 'polygon' && pts.length >= 2 ? figureSectionSidesOf({ kind: 'polygon', points: pts }, this.axis) : []
|
|
451
|
+
const at = (id: string) => pts.find(p => p.id === id)!
|
|
452
|
+
const grid = Math.max(10, Math.pow(10, Math.floor(Math.log10(e))) / 2)
|
|
453
|
+
const lines: number[] = []
|
|
454
|
+
for (let g = -Math.floor(e / grid) * grid; g <= e; g += grid) lines.push(g)
|
|
455
|
+
const cavity = this.body!.shape.cavity
|
|
456
|
+
const inside = cavity && !invalid ? figureCavitySectionOf(section, this.axis, cavity.wall) : undefined
|
|
457
|
+
const sized = (axis: Axis) => this.measures?.measures[axis] === 'editable'
|
|
458
|
+
return html`<div caption ?last-valid=${invalid}>
|
|
459
|
+
${invalid ? i18next.t('figure.text.section-editor-last-valid') : i18next.t('figure.text.section-editor-units', { h: h.toUpperCase(), v: v.toUpperCase() })}
|
|
460
|
+
</div>
|
|
461
|
+
<svg
|
|
462
|
+
viewBox=${`${-e} ${-e} ${2 * e} ${2 * e}`}
|
|
463
|
+
role="img"
|
|
464
|
+
aria-label=${i18next.t('figure.label.section-editor')}
|
|
465
|
+
@pointermove=${(ev: PointerEvent) => {
|
|
466
|
+
if (!this.dragging) return
|
|
467
|
+
if (this.dragFrom) this.history.push(this.dragFrom), (this.dragFrom = null)
|
|
468
|
+
const p = this.svgPoint(ev)
|
|
469
|
+
const handle = /^handle:(.)$/.exec(this.dragging)
|
|
470
|
+
this.working = handle
|
|
471
|
+
? this.across(handle[1] as Axis, Math.max(1, 2 * Math.abs(handle[1] === h ? p.h : p.v)))
|
|
472
|
+
: { ...this.working, points: movePoint(this.working.points, this.dragging, { [h]: p.h, [v]: p.v }) }
|
|
473
|
+
if (this.valid(this.working)) this.lastValid = this.working
|
|
474
|
+
this.preview()
|
|
475
|
+
}}
|
|
476
|
+
@pointerup=${() => ((this.dragging = null), (this.dragFrom = null))}
|
|
477
|
+
@pointerleave=${() => ((this.dragging = null), (this.dragFrom = null))}
|
|
478
|
+
>
|
|
479
|
+
${svg`${lines.map(g => svg`<line x1=${g} y1=${-e} x2=${g} y2=${e} stroke="var(--md-sys-color-outline-variant)" stroke-width=${e / 400} opacity="0.5" />
|
|
480
|
+
<line x1=${-e} y1=${g} x2=${e} y2=${g} stroke="var(--md-sys-color-outline-variant)" stroke-width=${e / 400} opacity="0.5" />`)}
|
|
481
|
+
<line x1=${-e} y1="0" x2=${e} y2="0" stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
|
|
482
|
+
<line x1="0" y1=${-e} x2="0" y2=${e} stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
|
|
483
|
+
<text x=${e * 0.92} y=${-e * 0.02} font-size=${e / 18} fill="var(--md-sys-color-on-surface-variant)">${h.toUpperCase()}</text>
|
|
484
|
+
<text x=${e * 0.02} y=${e * 0.95} font-size=${e / 18} fill="var(--md-sys-color-on-surface-variant)">${v.toUpperCase()}</text>
|
|
485
|
+
${invalid ? this.outline(sectionOf(this.body!.shape, this.lastValid), { fill: 'none', stroke: 'var(--md-sys-color-outline)', width: e / 250, dash: `${e / 50} ${e / 70}` }) : nothing}
|
|
486
|
+
${this.outline(section, {
|
|
487
|
+
fill: invalid ? 'none' : 'color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent)',
|
|
488
|
+
stroke: this.kind === 'polygon' ? 'none' : invalid ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)',
|
|
489
|
+
width: e / 130
|
|
490
|
+
})}
|
|
491
|
+
${inside && 'section' in inside
|
|
492
|
+
? this.outline(inside.section, { fill: 'var(--md-sys-color-surface)', stroke: 'var(--md-sys-color-primary)', width: e / 250, dash: `${e / 60} ${e / 90}` }, inside.centre)
|
|
493
|
+
: nothing}
|
|
494
|
+
${sides.map(s => {
|
|
495
|
+
const a = at(s.from!),
|
|
496
|
+
b = at(s.to!)
|
|
497
|
+
const bad = marked.sides.has(s.id)
|
|
498
|
+
return svg`<line x1=${a[h]} y1=${a[v]} x2=${b[h]} y2=${b[v]}
|
|
499
|
+
stroke=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'} stroke-width=${e / (bad ? 90 : 130)}
|
|
500
|
+
stroke-dasharray=${bad ? `${e / 40} ${e / 80}` : ''} />
|
|
501
|
+
<line side=${s.id} x1=${a[h]} y1=${a[v]} x2=${b[h]} y2=${b[v]} stroke="transparent" stroke-width=${e / 25} style="cursor: copy"
|
|
502
|
+
@pointerdown=${(ev: PointerEvent) => {
|
|
503
|
+
ev.stopPropagation()
|
|
504
|
+
const made = insertAfter(this.working.points, s.from!, this.axis)
|
|
505
|
+
this.change({ ...this.working, points: made.points })
|
|
506
|
+
this.selected = made.id
|
|
507
|
+
}}><title>${i18next.t('figure.text.section-editor-add-here', { a: pointLabel(s.from!), b: pointLabel(s.to!) })}</title></line>`
|
|
508
|
+
})}
|
|
509
|
+
${pts.map(p => {
|
|
510
|
+
const bad = marked.points.has(p.id),
|
|
511
|
+
on = this.selected === p.id
|
|
512
|
+
return svg`<g point=${p.id} style="cursor: grab"
|
|
513
|
+
@pointerdown=${(ev: PointerEvent) => {
|
|
514
|
+
ev.stopPropagation()
|
|
515
|
+
;(ev.target as Element).setPointerCapture?.(ev.pointerId)
|
|
516
|
+
this.dragFrom = this.working
|
|
517
|
+
this.selected = p.id
|
|
518
|
+
this.dragging = p.id
|
|
519
|
+
}}>
|
|
520
|
+
<circle cx=${p[h]} cy=${p[v]} r=${e / (on ? 28 : 36)} fill=${bad ? 'var(--md-sys-color-error)' : on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-surface)'}
|
|
521
|
+
stroke=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'} stroke-width=${e / 250} />
|
|
522
|
+
<text x=${p[h]! + e / 22} y=${p[v]! - e / 22} font-size=${e / 16} font-weight="600"
|
|
523
|
+
fill=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface)'}>${pointLabel(p.id)}</text>
|
|
524
|
+
</g>`
|
|
525
|
+
})}
|
|
526
|
+
${this.kind === 'polygon'
|
|
527
|
+
? nothing
|
|
528
|
+
: ([h, v] as const).filter(sized).map(axis => {
|
|
529
|
+
const half = (w.across[axis] ?? 0) / 2
|
|
530
|
+
const cx = axis === h ? half : 0,
|
|
531
|
+
cy = axis === h ? 0 : half
|
|
532
|
+
return svg`<g handle=${axis} style=${`cursor: ${axis === h ? 'ew-resize' : 'ns-resize'}`}
|
|
533
|
+
@pointerdown=${(ev: PointerEvent) => {
|
|
534
|
+
ev.stopPropagation()
|
|
535
|
+
;(ev.target as Element).setPointerCapture?.(ev.pointerId)
|
|
536
|
+
this.dragFrom = this.working
|
|
537
|
+
this.dragging = `handle:${axis}`
|
|
538
|
+
}}>
|
|
539
|
+
<rect x=${cx - e / 40} y=${cy - e / 40} width=${e / 20} height=${e / 20} rx=${e / 120}
|
|
540
|
+
fill="var(--md-sys-color-surface)" stroke="var(--md-sys-color-primary)" stroke-width=${e / 200} />
|
|
541
|
+
<text x=${cx + e / 28} y=${cy - e / 28} font-size=${e / 18} fill="var(--md-sys-color-on-surface)">${Math.round((w.across[axis] ?? 0) * 10) / 10}</text>
|
|
542
|
+
<title>${i18next.t('figure.label.section-editor-across', { axis: axis.toUpperCase() })}</title>
|
|
543
|
+
</g>`
|
|
544
|
+
})}`}
|
|
545
|
+
</svg>`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A millimetre field: committed to the working state on change, a bad number put back. */
|
|
549
|
+
private field(label: string, now: number, set: (value: number) => void, disabled = false, note = '') {
|
|
550
|
+
return html`<label mm>
|
|
551
|
+
<span>${label}</span>
|
|
552
|
+
<input
|
|
553
|
+
type="number"
|
|
554
|
+
step="any"
|
|
555
|
+
min="0"
|
|
556
|
+
inputmode="decimal"
|
|
557
|
+
aria-label=${label}
|
|
558
|
+
.value=${live(String(Math.round(now * 1000) / 1000))}
|
|
559
|
+
?disabled=${disabled}
|
|
560
|
+
@change=${(ev: Event) => {
|
|
561
|
+
const input = ev.target as HTMLInputElement
|
|
562
|
+
const value = Number(input.value)
|
|
563
|
+
if (input.value === '' || !Number.isFinite(value)) {
|
|
564
|
+
input.value = String(now)
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
set(value)
|
|
568
|
+
}}
|
|
569
|
+
/>
|
|
570
|
+
${note ? html`<small>${note}</small>` : nothing}
|
|
571
|
+
</label>`
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private list(issues: ReturnType<typeof workingIssuesOf>['issues'], words: ReturnType<typeof workingIssuesOf>['words']) {
|
|
575
|
+
const [h, v] = SCREEN[this.axis]
|
|
576
|
+
const marked = markedBy(issues)
|
|
577
|
+
const m = this.measures?.measures
|
|
578
|
+
const linked = i18next.t('figure.text.section-editor-linked')
|
|
579
|
+
return html`<div sizes>
|
|
580
|
+
${this.field(
|
|
581
|
+
i18next.t('figure.label.section-editor-length'),
|
|
582
|
+
this.working.length,
|
|
583
|
+
value => this.change({ ...this.working, length: value }),
|
|
584
|
+
m?.[this.axis] !== 'editable',
|
|
585
|
+
m?.[this.axis] === 'linked' ? linked : this.keptWords()
|
|
586
|
+
)}
|
|
587
|
+
${this.kind === 'polygon'
|
|
588
|
+
? nothing
|
|
589
|
+
: this.measures?.round
|
|
590
|
+
? this.field(i18next.t('figure.label.section-editor-diameter'), this.working.across[h] ?? 0, value => this.change(this.across(h, value)), m?.[h] !== 'editable', m?.[h] === 'linked' ? linked : '')
|
|
591
|
+
: ([h, v] as const).map(axis =>
|
|
592
|
+
this.field(
|
|
593
|
+
i18next.t('figure.label.section-editor-across', { axis: axis.toUpperCase() }),
|
|
594
|
+
this.working.across[axis] ?? 0,
|
|
595
|
+
value => this.change(this.across(axis, value)),
|
|
596
|
+
m?.[axis] !== 'editable',
|
|
597
|
+
m?.[axis] === 'linked' ? linked : ''
|
|
598
|
+
)
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
${this.kind === 'polygon'
|
|
602
|
+
? html`<ol points aria-label=${i18next.t('figure.label.section-editor-points')}>
|
|
603
|
+
${this.working.points.map(
|
|
604
|
+
p => html`<li ?selected=${this.selected === p.id} ?marked=${marked.points.has(p.id)} @focusin=${() => (this.selected = p.id)}>
|
|
605
|
+
<b>${pointLabel(p.id)}</b>
|
|
606
|
+
${([h, v] as const).map(
|
|
607
|
+
axis => html`<input
|
|
608
|
+
type="number"
|
|
609
|
+
step="any"
|
|
610
|
+
inputmode="decimal"
|
|
611
|
+
aria-label=${i18next.t('figure.label.section-editor-coordinate', { point: pointLabel(p.id), axis: axis.toUpperCase() })}
|
|
612
|
+
.value=${live(String(p[axis] ?? ''))}
|
|
613
|
+
@change=${(ev: Event) => {
|
|
614
|
+
const input = ev.target as HTMLInputElement
|
|
615
|
+
const value = Number(input.value)
|
|
616
|
+
if (input.value === '' || !Number.isFinite(value)) {
|
|
617
|
+
input.value = String(p[axis] ?? '')
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
this.change({ ...this.working, points: movePoint(this.working.points, p.id, { [axis]: value }) })
|
|
621
|
+
}}
|
|
622
|
+
/>`
|
|
623
|
+
)}
|
|
624
|
+
<button
|
|
625
|
+
?disabled=${this.working.points.length <= 3}
|
|
626
|
+
title=${this.working.points.length <= 3 ? i18next.t('figure.text.section-issue-too-few') : ''}
|
|
627
|
+
aria-label=${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
|
|
628
|
+
@click=${() => this.removePoint(p.id)}
|
|
629
|
+
>
|
|
630
|
+
${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
|
|
631
|
+
</button>
|
|
632
|
+
</li>`
|
|
633
|
+
)}
|
|
634
|
+
</ol>`
|
|
635
|
+
: nothing}
|
|
636
|
+
${this.note ? html`<small role="status">${this.note}</small>` : nothing}
|
|
637
|
+
${words.length
|
|
638
|
+
? html`<ul issues role="alert">
|
|
639
|
+
${words.map(w => html`<li>${i18next.t(w.key, w.params)}</li>`)}
|
|
640
|
+
</ul>`
|
|
641
|
+
: nothing}`
|
|
642
|
+
}
|
|
643
|
+
}
|
|
@@ -99,6 +99,13 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
|
|
|
99
99
|
const kind = e.section.kind
|
|
100
100
|
return html`<section>
|
|
101
101
|
<h3>${i18next.t('figure.label.group-section')}</h3>
|
|
102
|
+
${body.shape.dimensions.jamb === undefined
|
|
103
|
+
? html`<div field>
|
|
104
|
+
<button act ?disabled=${this.locked} @click=${() => this.dispatchEvent(new CustomEvent('section-editor-open', { bubbles: true, composed: true }))}>
|
|
105
|
+
${i18next.t('figure.button.section-editor-open')}
|
|
106
|
+
</button>
|
|
107
|
+
</div>`
|
|
108
|
+
: nothing}
|
|
102
109
|
${kind === 'rectangle' ? this.renderCorner(body) : nothing}
|
|
103
110
|
${kind === 'rectangle' || kind === 'ellipse'
|
|
104
111
|
? this.renderCavity(body, e.axis)
|
package/client/modeller/live.ts
CHANGED
|
@@ -57,6 +57,11 @@ export function livePartsOf(asset: FigureAsset, live: LiveValues): FigureRenderP
|
|
|
57
57
|
if (live.action) next = applyFigureAuthoring(next, live.action)
|
|
58
58
|
/* Read against the figure the edit makes: a command can take an input away (one wall becomes one per side). */
|
|
59
59
|
const { design, state } = liveOverridesOf(next, live.values)
|
|
60
|
+
/*
|
|
61
|
+
* An input the edit or the command in hand wrote takes its value: the values as they stand hold its old one (a
|
|
62
|
+
* point's coordinate before it was moved, a corner's radius before it was typed again) and would put it back.
|
|
63
|
+
*/
|
|
64
|
+
for (const id of Object.keys(design)) if (next.designInputs[id] !== asset.designInputs[id]) delete design[id]
|
|
60
65
|
if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
|
|
61
66
|
return drawableFigureParts(compileFigureAsset(next).evaluate(state).geometry)
|
|
62
67
|
} catch {
|