@things-factory/figure-ui 10.1.86 → 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 +8 -2
- package/client/modeller/figure-section-editor.ts +244 -85
- package/client/modeller/figure-section-panel.ts +10 -10
- package/client/modeller/section-drawing.ts +55 -2
- package/dist-client/modeller/editor.js +8 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +36 -11
- package/dist-client/modeller/figure-section-editor.js +215 -75
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/figure-section-panel.js +10 -10
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +20 -1
- package/dist-client/modeller/section-drawing.js +42 -1
- package/dist-client/modeller/section-drawing.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/section-editor.test.ts +44 -4
- package/translations/en.json +11 -1
- package/translations/ja.json +11 -1
- package/translations/ko.json +11 -1
- package/translations/ms.json +11 -1
- package/translations/zh.json +11 -1
|
@@ -6,7 +6,7 @@ import { notify } from '@operato/layout'
|
|
|
6
6
|
import { OxPrompt } from '@operato/popup'
|
|
7
7
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
8
8
|
import { live } from 'lit/directives/live.js'
|
|
9
|
-
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
|
|
9
|
+
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
|
|
10
10
|
import type { RecipeTemplate } from './recipes.js'
|
|
11
11
|
import './figure-recipe-catalog.js'
|
|
12
12
|
import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement } from '@hatiolab/figure-model'
|
|
@@ -1784,9 +1784,15 @@ export class FigureEditor extends LitElement {
|
|
|
1784
1784
|
/** The section editor over the canvas, the figure behind it drawing what the working points make. */
|
|
1785
1785
|
private renderSectionEditor(geometry: readonly { id: string }[]) {
|
|
1786
1786
|
const part = geometry.find(g => ownerOf(g.id) === this.selected) as { id: string; shape: FigureEvaluatedShape } | undefined
|
|
1787
|
-
|
|
1787
|
+
/* A door frame's section is its own width, height, jamb and head: its fields edit it, not an outline. */
|
|
1788
|
+
if (!part?.shape?.extrusion || part.shape.dimensions.jamb !== undefined) return nothing
|
|
1789
|
+
const asset = this.asset() as FigureAsset
|
|
1790
|
+
const measures = figureSectionMeasuresOf(asset, this.selected)
|
|
1791
|
+
const kept = figureScaleAnchorOf(asset, this.selected)?.[part.shape.extrusion.axis]
|
|
1788
1792
|
return html`<figure-section-editor
|
|
1789
1793
|
.body=${{ ...part, id: this.selected }}
|
|
1794
|
+
.measures=${measures}
|
|
1795
|
+
.kept=${kept}
|
|
1790
1796
|
.revision=${this.session?.read().revision ?? 0}
|
|
1791
1797
|
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
1792
1798
|
@command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
|
|
@@ -3,10 +3,11 @@ import { customElement, property, state } from 'lit/decorators.js'
|
|
|
3
3
|
import { live } from 'lit/directives/live.js'
|
|
4
4
|
|
|
5
5
|
import { i18next, localize } from '@operato/i18n'
|
|
6
|
-
import { figureSectionSidesOf } from '@hatiolab/figure-model'
|
|
7
|
-
import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSectionPoint } from '@hatiolab/figure-model'
|
|
6
|
+
import { figureCavitySectionOf, figureSectionSidesOf } from '@hatiolab/figure-model'
|
|
7
|
+
import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
|
|
8
8
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
9
|
-
import {
|
|
9
|
+
import { insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
|
|
10
|
+
import { END_FACE } from './section-controls.js'
|
|
10
11
|
|
|
11
12
|
type Axis = 'x' | 'y' | 'z'
|
|
12
13
|
|
|
@@ -17,17 +18,20 @@ const SCREEN: Record<Axis, [Axis, Axis]> = { y: ['x', 'z'], x: ['z', 'y'], z: ['
|
|
|
17
18
|
export const NARROW_BELOW = 900
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* 단면 편집 — a
|
|
21
|
+
* 단면 편집 — a pushed part's section and length in one editor (ADR-0100 §3.2, stage 2).
|
|
21
22
|
*
|
|
22
23
|
* The outline is drawn flat, looking along the push, in millimetres from the part's origin; the figure behind stays in
|
|
23
|
-
* view and shows the solid the working
|
|
24
|
-
* and the 3D stand side by side. A point is dragged or typed
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
27
30
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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.
|
|
31
35
|
*/
|
|
32
36
|
@customElement('figure-section-editor')
|
|
33
37
|
export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
@@ -132,6 +136,20 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
132
136
|
padding: 8px 14px;
|
|
133
137
|
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
134
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
|
+
}
|
|
135
153
|
ol[points] {
|
|
136
154
|
list-style: none;
|
|
137
155
|
margin: 0;
|
|
@@ -204,6 +222,10 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
204
222
|
|
|
205
223
|
/** The part and its evaluated shape as they stand when the editor opens. */
|
|
206
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
|
|
207
229
|
/** The figure's revision now; the editor remembers the one it opened at. */
|
|
208
230
|
@property({ type: Number }) revision = 0
|
|
209
231
|
/** Narrow: the outline or the solid behind. */
|
|
@@ -215,28 +237,32 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
215
237
|
if (!this.narrow) this.show = 'outline'
|
|
216
238
|
})
|
|
217
239
|
|
|
218
|
-
@state() private working:
|
|
240
|
+
@state() private working: SectionWorking = { points: [], across: {}, length: 0 }
|
|
219
241
|
@state() private selected: string | null = null
|
|
220
242
|
@state() private note = ''
|
|
221
|
-
private
|
|
222
|
-
private
|
|
243
|
+
private opened!: SectionWorking
|
|
244
|
+
private history: SectionWorking[] = []
|
|
245
|
+
private lastValid!: SectionWorking
|
|
223
246
|
private openedAt = 0
|
|
224
247
|
private dragging: string | null = null
|
|
225
|
-
/** The
|
|
226
|
-
private dragFrom:
|
|
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
|
|
227
250
|
private extent = 200
|
|
228
251
|
|
|
229
252
|
private get axis(): Axis {
|
|
230
253
|
return (this.body?.shape.extrusion?.axis ?? 'y') as Axis
|
|
231
254
|
}
|
|
255
|
+
private get kind() {
|
|
256
|
+
return this.body?.shape.extrusion?.section.kind
|
|
257
|
+
}
|
|
232
258
|
|
|
233
259
|
connectedCallback() {
|
|
234
260
|
super.connectedCallback()
|
|
235
|
-
|
|
236
|
-
this.
|
|
261
|
+
this.working = workingOf(this.body!.shape)
|
|
262
|
+
this.opened = this.working
|
|
237
263
|
this.lastValid = this.working
|
|
238
264
|
this.openedAt = this.revision
|
|
239
|
-
this.extent = extentOf(this.working
|
|
265
|
+
this.extent = this.extentOf(this.working)
|
|
240
266
|
window.addEventListener('keydown', this.keys, true)
|
|
241
267
|
if (this.parentElement) this.room.observe(this.parentElement)
|
|
242
268
|
}
|
|
@@ -247,6 +273,13 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
247
273
|
super.disconnectedCallback()
|
|
248
274
|
}
|
|
249
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
|
+
|
|
250
283
|
/*
|
|
251
284
|
* Heard first (capture on the window), and a key the editor takes goes no further: the scene behind listens too, and
|
|
252
285
|
* would take Esc as "select nothing" and Delete as "delete the part".
|
|
@@ -271,11 +304,15 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
271
304
|
return this.revision !== this.openedAt
|
|
272
305
|
}
|
|
273
306
|
|
|
274
|
-
private
|
|
307
|
+
private valid(w: SectionWorking) {
|
|
308
|
+
return workingIssuesOf(this.body!.shape, w).words.length === 0
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private change(next: SectionWorking) {
|
|
275
312
|
this.history.push(this.working)
|
|
276
313
|
this.working = next
|
|
277
314
|
this.note = ''
|
|
278
|
-
if (
|
|
315
|
+
if (this.valid(next)) this.lastValid = next
|
|
279
316
|
this.preview()
|
|
280
317
|
}
|
|
281
318
|
|
|
@@ -283,33 +320,50 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
283
320
|
const before = this.history.pop()
|
|
284
321
|
if (!before) return
|
|
285
322
|
this.working = before
|
|
286
|
-
if (
|
|
323
|
+
if (this.valid(before)) this.lastValid = before
|
|
287
324
|
this.preview()
|
|
288
325
|
}
|
|
289
326
|
|
|
290
|
-
/** The solid behind shows the working
|
|
327
|
+
/** The solid behind shows the working state, or the last valid one while the working one is refused. */
|
|
291
328
|
private preview() {
|
|
292
329
|
this.dispatchEvent(
|
|
293
330
|
new CustomEvent('command-live', { detail: { action: this.action(this.lastValid) }, bubbles: true, composed: true })
|
|
294
331
|
)
|
|
295
332
|
}
|
|
296
333
|
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
}
|
|
299
346
|
}
|
|
300
347
|
|
|
301
348
|
private removePoint(id: string) {
|
|
302
|
-
const next = removePoint(this.working, id)
|
|
349
|
+
const next = removePoint(this.working.points, id)
|
|
303
350
|
if ('refused' in next) {
|
|
304
351
|
this.note = i18next.t('figure.text.section-issue-too-few')
|
|
305
352
|
return
|
|
306
353
|
}
|
|
307
354
|
this.selected = null
|
|
308
|
-
this.change(next)
|
|
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 } }
|
|
309
363
|
}
|
|
310
364
|
|
|
311
365
|
private apply() {
|
|
312
|
-
if (this.stale ||
|
|
366
|
+
if (this.stale || !this.valid(this.working)) return
|
|
313
367
|
this.dispatchEvent(new CustomEvent('command', { detail: { action: this.action(this.working) }, bubbles: true, composed: true }))
|
|
314
368
|
this.close()
|
|
315
369
|
}
|
|
@@ -332,14 +386,22 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
332
386
|
return { h: Math.round(p.x), v: Math.round(p.y) }
|
|
333
387
|
}
|
|
334
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
|
+
|
|
335
398
|
render() {
|
|
336
399
|
if (!this.body) return nothing
|
|
337
|
-
const issues =
|
|
338
|
-
const length = this.body.shape.extrusion ? this.body.shape.extrusion.end - this.body.shape.extrusion.start : 0
|
|
400
|
+
const { issues, words } = workingIssuesOf(this.body.shape, this.working)
|
|
339
401
|
return html`
|
|
340
402
|
<header>
|
|
341
403
|
<h2>${i18next.t('figure.label.section-editor')}</h2>
|
|
342
|
-
<span>${this.body.id}
|
|
404
|
+
<span>${this.body.id}</span>
|
|
343
405
|
</header>
|
|
344
406
|
<nav tabs>
|
|
345
407
|
${(['outline', 'solid'] as const).map(
|
|
@@ -347,31 +409,52 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
347
409
|
)}
|
|
348
410
|
</nav>
|
|
349
411
|
<div body>
|
|
350
|
-
<div outline>${this.drawing(issues)}</div>
|
|
351
|
-
<aside>${this.list(issues)}</aside>
|
|
412
|
+
<div outline>${this.drawing(issues, words.length > 0)}</div>
|
|
413
|
+
<aside>${this.list(issues, words)}</aside>
|
|
352
414
|
</div>
|
|
353
415
|
<footer>
|
|
354
416
|
<button ?disabled=${!this.history.length} @click=${() => this.back()}>${i18next.t('figure.button.section-editor-back')}</button>
|
|
355
417
|
<span grow ?stale=${this.stale}>
|
|
356
|
-
${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')}
|
|
357
421
|
</span>
|
|
358
422
|
<button @click=${() => this.cancel()}>${i18next.t('figure.button.section-editor-cancel')}</button>
|
|
359
|
-
<button primary ?disabled=${this.stale ||
|
|
423
|
+
<button primary ?disabled=${this.stale || words.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
|
|
360
424
|
</footer>
|
|
361
425
|
`
|
|
362
426
|
}
|
|
363
427
|
|
|
364
|
-
|
|
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) {
|
|
365
444
|
const [h, v] = SCREEN[this.axis]
|
|
366
445
|
const e = this.extent
|
|
367
446
|
const marked = markedBy(issues)
|
|
368
|
-
const
|
|
369
|
-
const
|
|
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) : []
|
|
370
451
|
const at = (id: string) => pts.find(p => p.id === id)!
|
|
371
452
|
const grid = Math.max(10, Math.pow(10, Math.floor(Math.log10(e))) / 2)
|
|
372
453
|
const lines: number[] = []
|
|
373
454
|
for (let g = -Math.floor(e / grid) * grid; g <= e; g += grid) lines.push(g)
|
|
374
|
-
const
|
|
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'
|
|
375
458
|
return html`<div caption ?last-valid=${invalid}>
|
|
376
459
|
${invalid ? i18next.t('figure.text.section-editor-last-valid') : i18next.t('figure.text.section-editor-units', { h: h.toUpperCase(), v: v.toUpperCase() })}
|
|
377
460
|
</div>
|
|
@@ -383,8 +466,11 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
383
466
|
if (!this.dragging) return
|
|
384
467
|
if (this.dragFrom) this.history.push(this.dragFrom), (this.dragFrom = null)
|
|
385
468
|
const p = this.svgPoint(ev)
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
388
474
|
this.preview()
|
|
389
475
|
}}
|
|
390
476
|
@pointerup=${() => ((this.dragging = null), (this.dragFrom = null))}
|
|
@@ -396,10 +482,15 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
396
482
|
<line x1="0" y1=${-e} x2="0" y2=${e} stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
|
|
397
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>
|
|
398
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>
|
|
399
|
-
${invalid
|
|
400
|
-
|
|
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)
|
|
401
493
|
: nothing}
|
|
402
|
-
<polygon points=${pts.map(p => `${p[h]},${p[v]}`).join(' ')} fill=${invalid ? 'none' : 'color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent)'} stroke="none" />
|
|
403
494
|
${sides.map(s => {
|
|
404
495
|
const a = at(s.from!),
|
|
405
496
|
b = at(s.to!)
|
|
@@ -410,8 +501,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
410
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"
|
|
411
502
|
@pointerdown=${(ev: PointerEvent) => {
|
|
412
503
|
ev.stopPropagation()
|
|
413
|
-
const made = insertAfter(this.working, s.from!, this.axis)
|
|
414
|
-
this.change(made.points)
|
|
504
|
+
const made = insertAfter(this.working.points, s.from!, this.axis)
|
|
505
|
+
this.change({ ...this.working, points: made.points })
|
|
415
506
|
this.selected = made.id
|
|
416
507
|
}}><title>${i18next.t('figure.text.section-editor-add-here', { a: pointLabel(s.from!), b: pointLabel(s.to!) })}</title></line>`
|
|
417
508
|
})}
|
|
@@ -431,53 +522,121 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
|
431
522
|
<text x=${p[h]! + e / 22} y=${p[v]! - e / 22} font-size=${e / 16} font-weight="600"
|
|
432
523
|
fill=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface)'}>${pointLabel(p.id)}</text>
|
|
433
524
|
</g>`
|
|
434
|
-
})}
|
|
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
|
+
})}`}
|
|
435
545
|
</svg>`
|
|
436
546
|
}
|
|
437
547
|
|
|
438
|
-
|
|
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']) {
|
|
439
575
|
const [h, v] = SCREEN[this.axis]
|
|
440
576
|
const marked = markedBy(issues)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
aria-label=${i18next.t('figure.label.section-editor-coordinate', { point: pointLabel(p.id), axis: axis.toUpperCase() })}
|
|
451
|
-
.value=${live(String(p[axis] ?? ''))}
|
|
452
|
-
@change=${(ev: Event) => {
|
|
453
|
-
const input = ev.target as HTMLInputElement
|
|
454
|
-
const value = Number(input.value)
|
|
455
|
-
if (input.value === '' || !Number.isFinite(value)) {
|
|
456
|
-
input.value = String(p[axis] ?? '')
|
|
457
|
-
return
|
|
458
|
-
}
|
|
459
|
-
this.change(movePoint(this.working, p.id, { [axis]: value }))
|
|
460
|
-
}}
|
|
461
|
-
/>`
|
|
462
|
-
)}
|
|
463
|
-
<button
|
|
464
|
-
?disabled=${this.working.length <= 3}
|
|
465
|
-
title=${this.working.length <= 3 ? i18next.t('figure.text.section-issue-too-few') : ''}
|
|
466
|
-
aria-label=${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
|
|
467
|
-
@click=${() => this.removePoint(p.id)}
|
|
468
|
-
>
|
|
469
|
-
${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
|
|
470
|
-
</button>
|
|
471
|
-
</li>`
|
|
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()
|
|
472
586
|
)}
|
|
473
|
-
|
|
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}
|
|
474
636
|
${this.note ? html`<small role="status">${this.note}</small>` : nothing}
|
|
475
|
-
${
|
|
637
|
+
${words.length
|
|
476
638
|
? html`<ul issues role="alert">
|
|
477
|
-
${
|
|
478
|
-
const w = issueWords(i, this.working, this.axis)
|
|
479
|
-
return html`<li>${i18next.t(w.key, w.params)}</li>`
|
|
480
|
-
})}
|
|
639
|
+
${words.map(w => html`<li>${i18next.t(w.key, w.params)}</li>`)}
|
|
481
640
|
</ul>`
|
|
482
641
|
: nothing}`
|
|
483
642
|
}
|
|
@@ -99,19 +99,19 @@ 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)
|
|
105
|
-
: html
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
${i18next.t('figure.button.section-editor-open')}
|
|
109
|
-
</button>
|
|
110
|
-
</div>`
|
|
111
|
-
: nothing}
|
|
112
|
-
<div field>
|
|
113
|
-
<small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
|
|
114
|
-
</div>`}
|
|
112
|
+
: html`<div field>
|
|
113
|
+
<small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
|
|
114
|
+
</div>`}
|
|
115
115
|
</section>`
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureSectionSidesOf } from '@hatiolab/figure-model'
|
|
2
|
-
import type { FigureSectionIssue, FigureSectionPoint } from '@hatiolab/figure-model'
|
|
1
|
+
import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureExtrusionFeaturesOf, figureSectionSidesOf } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureEvaluatedShape, FigureSection, FigureSectionIssue, FigureSectionPoint } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
* What the section editor does to a drawn section's points, and how it says what is wrong with them (ADR-0100 §3.2).
|
|
@@ -68,6 +68,8 @@ export function issueWords(issue: FigureSectionIssue, points: Points, axis: Axis
|
|
|
68
68
|
return { key: 'figure.text.section-issue-too-few', params: {} }
|
|
69
69
|
case 'TOO_MANY_POINTS':
|
|
70
70
|
return { key: 'figure.text.section-issue-too-many', params: { limit: String(issue.limit) } }
|
|
71
|
+
case 'CORNER_TOO_LARGE':
|
|
72
|
+
return { key: 'figure.text.section-issue-corner', params: { max: String(Math.round(issue.max * 1000) / 1000) } }
|
|
71
73
|
default:
|
|
72
74
|
return { key: 'figure.text.section-issue-number', params: {} }
|
|
73
75
|
}
|
|
@@ -95,3 +97,54 @@ export function extentOf(points: Points, axis: Axis): number {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
export const POINT_LIMIT = FIGURE_SECTION_POINT_LIMIT
|
|
100
|
+
|
|
101
|
+
/** What the section editor holds while it is open: the points of a drawn section, the widths across, the length. */
|
|
102
|
+
export interface SectionWorking {
|
|
103
|
+
points: FigureSectionPoint[]
|
|
104
|
+
/** Along each plane axis: a rectangle's size, an ellipse's diameter. Empty for a drawn section. */
|
|
105
|
+
across: Partial<Record<Axis, number>>
|
|
106
|
+
length: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The working state of a pushed part as it stands. */
|
|
110
|
+
export function workingOf(shape: FigureEvaluatedShape): SectionWorking {
|
|
111
|
+
const e = shape.extrusion!
|
|
112
|
+
const s = e.section
|
|
113
|
+
const [u, v] = FIGURE_SECTION_PLANE[e.axis]
|
|
114
|
+
const across =
|
|
115
|
+
s.kind === 'rectangle'
|
|
116
|
+
? { [u]: s.size[u]!, [v]: s.size[v]! }
|
|
117
|
+
: s.kind === 'ellipse'
|
|
118
|
+
? { [u]: 2 * s.radius[u]!, [v]: 2 * s.radius[v]! }
|
|
119
|
+
: {}
|
|
120
|
+
return { points: s.kind === 'polygon' ? s.points.map(p => ({ ...p })) : [], across, length: e.end - e.start }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The section the working state makes, of the part's own kind: its corners kept, its points or widths the working ones. */
|
|
124
|
+
export function sectionOf(shape: FigureEvaluatedShape, w: SectionWorking): FigureSection {
|
|
125
|
+
const s = shape.extrusion!.section
|
|
126
|
+
const [u, v] = FIGURE_SECTION_PLANE[shape.extrusion!.axis]
|
|
127
|
+
if (s.kind === 'polygon') return { kind: 'polygon', points: w.points }
|
|
128
|
+
if (s.kind === 'ellipse') return { kind: 'ellipse', radius: { [u]: w.across[u]! / 2, [v]: w.across[v]! / 2 } }
|
|
129
|
+
return { kind: 'rectangle', size: { [u]: w.across[u]!, [v]: w.across[v]! }, ...(s.corner ? { corner: s.corner } : {}) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What would be refused, as words, with the section's own issues for marking points and sides. By the kernel's checks. */
|
|
133
|
+
export function workingIssuesOf(
|
|
134
|
+
shape: FigureEvaluatedShape,
|
|
135
|
+
w: SectionWorking
|
|
136
|
+
): { issues: FigureSectionIssue[]; words: Array<{ key: string; params: Record<string, string> }> } {
|
|
137
|
+
const e = shape.extrusion!
|
|
138
|
+
const section = sectionOf(shape, w)
|
|
139
|
+
const issues = checkFigureSection(section, e.axis)
|
|
140
|
+
const words = issues.map(i => issueWords(i, w.points, e.axis))
|
|
141
|
+
if (!(Number.isFinite(w.length) && w.length > 0)) words.push({ key: 'figure.text.section-issue-length', params: {} })
|
|
142
|
+
if (!issues.length && words.length === 0 && shape.cavity) {
|
|
143
|
+
const made = figureExtrusionFeaturesOf({ axis: e.axis, section, start: e.start, end: e.start + w.length }, [shape.cavity])
|
|
144
|
+
if ('refused' in made) {
|
|
145
|
+
const r = made.refused
|
|
146
|
+
words.push({ key: r.code === 'CAVITY_NO_LENGTH' ? 'figure.text.section-issue-cavity-length' : 'figure.text.section-issue-cavity-walls', params: {} })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { issues, words }
|
|
150
|
+
}
|