@things-factory/figure-ui 10.1.83 → 10.1.85
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/command-preview.ts +4 -2
- package/client/modeller/drawn-shapes.ts +29 -0
- package/client/modeller/editor.ts +17 -1
- package/client/modeller/figure-material-panel.ts +2 -3
- package/client/modeller/figure-parts.ts +13 -15
- package/client/modeller/figure-section-panel.ts +304 -0
- package/client/modeller/gizmo.ts +26 -16
- package/client/modeller/joint-fan.ts +4 -3
- package/client/modeller/links.ts +24 -24
- package/client/modeller/live.ts +8 -4
- package/client/modeller/maturity.ts +12 -6
- package/client/modeller/motion-ends.ts +2 -1
- package/client/modeller/refusals.ts +20 -0
- package/client/modeller/renderer.ts +3 -2
- package/client/modeller/section-controls.ts +115 -0
- package/client/modeller/starting-shape.ts +34 -0
- package/dist-client/modeller/command-preview.js +4 -2
- package/dist-client/modeller/command-preview.js.map +1 -1
- package/dist-client/modeller/drawn-shapes.d.ts +44 -0
- package/dist-client/modeller/drawn-shapes.js +12 -0
- package/dist-client/modeller/drawn-shapes.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +3 -0
- package/dist-client/modeller/editor.js +16 -1
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
- package/dist-client/modeller/figure-clips.d.ts +1 -1
- package/dist-client/modeller/figure-controls.d.ts +1 -1
- package/dist-client/modeller/figure-links.d.ts +1 -1
- package/dist-client/modeller/figure-material-panel.d.ts +1 -1
- package/dist-client/modeller/figure-material-panel.js +2 -3
- package/dist-client/modeller/figure-material-panel.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +3 -2
- package/dist-client/modeller/figure-parts.js +7 -12
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +1 -1
- package/dist-client/modeller/figure-section-panel.d.ts +42 -0
- package/dist-client/modeller/figure-section-panel.js +289 -0
- package/dist-client/modeller/figure-section-panel.js.map +1 -0
- package/dist-client/modeller/gizmo.d.ts +3 -3
- package/dist-client/modeller/gizmo.js +24 -11
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/joint-fan.js +4 -3
- package/dist-client/modeller/joint-fan.js.map +1 -1
- package/dist-client/modeller/links.d.ts +4 -1
- package/dist-client/modeller/links.js +23 -25
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/live.d.ts +3 -1
- package/dist-client/modeller/live.js +6 -3
- package/dist-client/modeller/live.js.map +1 -1
- package/dist-client/modeller/maturity.d.ts +3 -2
- package/dist-client/modeller/maturity.js +9 -6
- package/dist-client/modeller/maturity.js.map +1 -1
- package/dist-client/modeller/motion-ends.js +2 -1
- package/dist-client/modeller/motion-ends.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +0 -5
- package/dist-client/modeller/refusals.js +25 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/renderer.d.ts +3 -4
- package/dist-client/modeller/renderer.js +3 -2
- package/dist-client/modeller/renderer.js.map +1 -1
- package/dist-client/modeller/section-controls.d.ts +44 -0
- package/dist-client/modeller/section-controls.js +91 -0
- package/dist-client/modeller/section-controls.js.map +1 -0
- package/dist-client/modeller/starting-shape.d.ts +32 -0
- package/dist-client/modeller/starting-shape.js +37 -0
- package/dist-client/modeller/starting-shape.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/acceptance-page.js +8 -0
- package/test/command-preview.test.ts +3 -2
- package/test/fixtures/joint-column.json +110 -15
- package/test/fixtures/joint-roller.json +66 -9
- package/test/fixtures/robot-arm-6dof.json +203 -27
- package/test/joint-fan.test.ts +10 -7
- package/test/links.test.ts +4 -4
- package/test/material.test.ts +7 -2
- package/test/palette-icons.test.ts +1 -1
- package/test/parts-wiring.test.ts +6 -2
- package/test/renderer.test.ts +2 -1
- package/test/resize-with-placement.test.ts +3 -3
- package/test/scene-canvas.test.ts +2 -1
- package/test/section-panel.test.ts +192 -0
- package/translations/en.json +24 -0
- package/translations/ja.json +24 -0
- package/translations/ko.json +24 -0
- package/translations/ms.json +24 -0
- package/translations/zh.json +24 -0
- package/dist-client/tsconfig.localkernel.tsbuildinfo +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FigureBodyMove, FigureClipLoss, FigureFaceArea, FigureRowReading } from '@hatiolab/figure-model'
|
|
2
2
|
|
|
3
3
|
import type { FigureRenderPart } from './renderer.js'
|
|
4
|
+
import { boxShape, discShape } from './drawn-shapes.js'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
7
|
* A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would
|
|
@@ -53,9 +54,10 @@ export function faceHighlightOf(face: FigureFaceArea | undefined): FigureRenderP
|
|
|
53
54
|
/* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */
|
|
54
55
|
const r = [0, 1, 2].map(i => [face.u[i]!, face.normal[i]!, face.v[i]!])
|
|
55
56
|
const t = [0, 1, 2].map(i => face.centre[i]! + face.normal[i]! * (LIFT + THICKNESS / 2))
|
|
57
|
+
/* A plane touching a curved side is a line (Q3): drawn as a narrow strip, never a plate. */
|
|
56
58
|
const shape: FigureRenderPart['shape'] = face.round
|
|
57
|
-
?
|
|
58
|
-
:
|
|
59
|
+
? discShape(face.size.u / 2, THICKNESS)
|
|
60
|
+
: boxShape(Math.max(face.size.u, THICKNESS), THICKNESS, Math.max(face.size.v, THICKNESS))
|
|
59
61
|
/* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */
|
|
60
62
|
return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } }
|
|
61
63
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes the modeller draws of its own -- a lit face, a joint's fan, a slide's line -- written as the shapes figure-model
|
|
3
|
+
* evaluates (ADR-0100): a section pushed along Y, centred. things-scene's one builder draws them as it draws a part.
|
|
4
|
+
*/
|
|
5
|
+
export interface DrawnShape {
|
|
6
|
+
provider: 'extrude'
|
|
7
|
+
dimensions: Record<string, number>
|
|
8
|
+
extrusion: { axis: 'y'; section: DrawnSection; start: number; end: number }
|
|
9
|
+
}
|
|
10
|
+
type DrawnSection =
|
|
11
|
+
| { kind: 'rectangle'; size: { x: number; z: number } }
|
|
12
|
+
| { kind: 'ellipse'; radius: { x: number; z: number } }
|
|
13
|
+
| { kind: 'polygon'; points: Array<{ id: string; x: number; z: number }> }
|
|
14
|
+
|
|
15
|
+
const centred = (section: DrawnSection, height: number): DrawnShape => ({
|
|
16
|
+
provider: 'extrude',
|
|
17
|
+
dimensions: { length: height },
|
|
18
|
+
extrusion: { axis: 'y', section, start: -height / 2, end: height / 2 }
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/** A box: width along X, height along Y, depth along Z. */
|
|
22
|
+
export const boxShape = (width: number, height: number, depth: number): DrawnShape => centred({ kind: 'rectangle', size: { x: width, z: depth } }, height)
|
|
23
|
+
|
|
24
|
+
/** A disc of a radius, `height` thick along Y. */
|
|
25
|
+
export const discShape = (radius: number, height: number): DrawnShape => centred({ kind: 'ellipse', radius: { x: radius, z: radius } }, height)
|
|
26
|
+
|
|
27
|
+
/** A section in X-Z, `height` thick along Y. */
|
|
28
|
+
export const sectionShape = (points: ReadonlyArray<{ x: number; z: number }>, height: number): DrawnShape =>
|
|
29
|
+
centred({ kind: 'polygon', points: points.map((p, i) => ({ id: `p${i + 1}`, x: p.x, z: p.z })) }, height)
|
|
@@ -16,6 +16,7 @@ import { drawableFigureParts } from './renderer.js'
|
|
|
16
16
|
import './canvas.js'
|
|
17
17
|
import './figure-parts.js'
|
|
18
18
|
import './figure-material-panel.js'
|
|
19
|
+
import './figure-section-panel.js'
|
|
19
20
|
import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf, type HeldPreview } from './command-preview.js'
|
|
20
21
|
import { motionEndsOf, type MotionEnds } from './motion-ends.js'
|
|
21
22
|
import './figure-links.js'
|
|
@@ -47,6 +48,7 @@ import { jointFanOf } from './joint-fan.js'
|
|
|
47
48
|
import { jointDragOf } from './joint-drag.js'
|
|
48
49
|
import type { FirstSave } from './first-save.js'
|
|
49
50
|
import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
|
|
51
|
+
import { startingShapeKindOfBody } from './starting-shape.js'
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -701,6 +703,12 @@ export class FigureEditor extends LitElement {
|
|
|
701
703
|
private hold(values: Record<string, number>, edit?: FigurePartEdit) {
|
|
702
704
|
this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
|
|
703
705
|
}
|
|
706
|
+
/** A command in hand, drawn and not run; `null` lets go of it and the figure as it stands is drawn again. */
|
|
707
|
+
private holdAction(action: FigureAuthoringAction | null) {
|
|
708
|
+
const { action: _, ...rest } = this.live
|
|
709
|
+
this.live = action ? { ...rest, action } : rest
|
|
710
|
+
}
|
|
711
|
+
|
|
704
712
|
private changed() {
|
|
705
713
|
this.requestUpdate()
|
|
706
714
|
this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }))
|
|
@@ -1138,6 +1146,8 @@ export class FigureEditor extends LitElement {
|
|
|
1138
1146
|
}
|
|
1139
1147
|
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
1140
1148
|
private linkCommand(action: FigureAuthoringAction) {
|
|
1149
|
+
/* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
|
|
1150
|
+
this.holdAction(null)
|
|
1141
1151
|
this.retry = undefined
|
|
1142
1152
|
this.notice = ''
|
|
1143
1153
|
const hadSize = this.hasSize()
|
|
@@ -1342,7 +1352,7 @@ export class FigureEditor extends LitElement {
|
|
|
1342
1352
|
}
|
|
1343
1353
|
rows.set(id, {
|
|
1344
1354
|
id,
|
|
1345
|
-
|
|
1355
|
+
kind: startingShapeKindOfBody((g as { shape?: never }).shape) ?? '',
|
|
1346
1356
|
hidden: this.hiddenParts.has(id),
|
|
1347
1357
|
invisible: (g as { visible?: boolean }).visible === false,
|
|
1348
1358
|
...(id !== g.id ? { copies: 1 } : {}),
|
|
@@ -2012,6 +2022,12 @@ export class FigureEditor extends LitElement {
|
|
|
2012
2022
|
${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
|
|
2013
2023
|
${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
|
|
2014
2024
|
${this.renderLinks(unavailable, 'shape')}
|
|
2025
|
+
<figure-section-panel
|
|
2026
|
+
.body=${{ ...part, id: this.selected }}
|
|
2027
|
+
?locked=${unavailable}
|
|
2028
|
+
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
2029
|
+
@command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
|
|
2030
|
+
></figure-section-panel>
|
|
2015
2031
|
<figure-material-panel
|
|
2016
2032
|
.body=${{ ...part, id: this.selected }}
|
|
2017
2033
|
.palette=${view.palette ?? {}}
|
|
@@ -6,6 +6,7 @@ import { MATERIAL_PRESETS, SEGMENT_PRESETS, figureTrianglesOf } from '@hatiolab/
|
|
|
6
6
|
import { activePalette } from '@hatiolab/things-scene'
|
|
7
7
|
import type { FigureAssetGeometry, FigureMaterial, FigurePartEdit } from '@hatiolab/figure-model'
|
|
8
8
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
9
|
+
import { isRoundBody } from './starting-shape.js'
|
|
9
10
|
|
|
10
11
|
/** The purpose each palette role is for, by its token. */
|
|
11
12
|
const PURPOSE: Record<string, string> = {
|
|
@@ -23,7 +24,6 @@ const PURPOSE: Record<string, string> = {
|
|
|
23
24
|
export const purposeOf = (token?: string) => i18next.t(`figure.label.palette-purpose-${PURPOSE[token ?? ''] ?? 'registered'}`)
|
|
24
25
|
|
|
25
26
|
/** Bodies with a rim — the ones `set-segments` accepts. */
|
|
26
|
-
const ROUND = new Set(['cylinder', 'ellipticCylinder', 'sphere', 'frustum'])
|
|
27
27
|
const DEFAULT_SEGMENTS = 12
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -230,8 +230,7 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
private renderSegments(part: FigureAssetGeometry & { id: string }) {
|
|
233
|
-
|
|
234
|
-
if (!ROUND.has(provider)) return nothing
|
|
233
|
+
if (!isRoundBody((part as { shape?: never }).shape)) return nothing
|
|
235
234
|
const current = part.segments ?? DEFAULT_SEGMENTS
|
|
236
235
|
return html`
|
|
237
236
|
<div field>
|
|
@@ -13,7 +13,7 @@ import { partLabelOf } from './fields.js'
|
|
|
13
13
|
*
|
|
14
14
|
* 팔레트는 목록 위에, 눈 단추는 늘 자리를 차지하고, 조작은 고른 줄에서만 보인다.
|
|
15
15
|
*
|
|
16
|
-
* 부품의 도형은 평가가 준 `
|
|
16
|
+
* 부품의 도형은 평가가 준 형상에서 읽은 시작 모양 이름(`kind`)으로 보인다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
|
|
17
17
|
* 보이고 안 보이는 것)와는 다른 것이다. 그것은 속성 패널의 몫이다.
|
|
18
18
|
*
|
|
19
19
|
* 이 컴포넌트는 정본을 만지지 않는다. 무엇을 하겠다는 것만 알린다.
|
|
@@ -22,7 +22,8 @@ import { partLabelOf } from './fields.js'
|
|
|
22
22
|
/** 한 줄 — 평가에서 이 패널이 읽는 것만. */
|
|
23
23
|
export interface FigurePartRow {
|
|
24
24
|
id: string
|
|
25
|
-
|
|
25
|
+
/** 시작 모양 이름(`startingShapeKindOfBody`): 상자 · 원기둥 … 아이콘을 고른다. */
|
|
26
|
+
kind: string
|
|
26
27
|
/** 작업대에서 잠깐 가린 것. 저장되지 않는다. */
|
|
27
28
|
hidden?: boolean
|
|
28
29
|
/** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
|
|
@@ -36,37 +37,34 @@ export interface FigurePartRow {
|
|
|
36
37
|
fastenedTo?: string
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
const
|
|
41
|
-
|
|
40
|
+
/** 시작 모양마다의 아이콘. Material Symbols 이름이다. */
|
|
41
|
+
const KIND_ICON: Record<string, string> = {
|
|
42
|
+
cube: 'deployed_code',
|
|
42
43
|
cylinder: 'database',
|
|
43
|
-
ellipticCylinder: 'database',
|
|
44
44
|
frustum: 'change_history',
|
|
45
45
|
sphere: 'circle',
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
hollowBox: 'check_box_outline_blank',
|
|
49
|
-
hollowBoxXZ: 'check_box_outline_blank',
|
|
46
|
+
'rounded-box': 'crop_square',
|
|
47
|
+
'hollow-box': 'check_box_outline_blank',
|
|
50
48
|
polygon: 'pentagon',
|
|
51
49
|
portal: 'door_front'
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
/** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
|
|
55
53
|
/*
|
|
56
|
-
The palette. A plate and a wall are boxes of a shape a person reaches for, so they are
|
|
54
|
+
The palette: starting shapes (ADR-0100 §2.1). A plate and a wall are boxes of a shape a person reaches for, so they are
|
|
55
|
+
starting sizes, not kinds of their own. A rounded box and a box open at the top are not here: they are a box with its
|
|
56
|
+
corners rounded or an inside made, from the part's own 단면 section (§3.3), so the palette does not offer a second way.
|
|
57
57
|
*/
|
|
58
58
|
export type FigurePaletteKind = FigurePrimitiveCreation['kind'] | 'plate' | 'wall'
|
|
59
59
|
const RECIPES: Array<{ kind: FigurePaletteKind; icon: string; label: string }> = [
|
|
60
60
|
{ kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
|
|
61
61
|
{ kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
|
|
62
|
-
{ kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rounded-box' },
|
|
63
62
|
{ kind: 'sphere', icon: 'circle', label: 'figure.label.primitive-sphere' },
|
|
64
63
|
{ kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
|
|
65
64
|
{ kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
|
|
66
65
|
{ kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
|
|
67
66
|
{ kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
|
|
68
|
-
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
69
|
-
{ kind: 'hollow-box', icon: 'inventory_2', label: 'figure.label.primitive-hollow-box' }
|
|
67
|
+
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
70
68
|
]
|
|
71
69
|
|
|
72
70
|
@customElement('figure-parts')
|
|
@@ -575,7 +573,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
575
573
|
>
|
|
576
574
|
<md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
|
|
577
575
|
</button>
|
|
578
|
-
<md-icon>${
|
|
576
|
+
<md-icon>${KIND_ICON[part.kind] ?? 'deployed_code'}</md-icon>
|
|
579
577
|
<span label title=${part.id}
|
|
580
578
|
>${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}${part.fastenedTo
|
|
581
579
|
? html`<small fastened-to title=${i18next.t('figure.label.fastened-to')}>${part.fastenedTo}</small>`
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { css, html, LitElement, nothing, type PropertyValues } 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 type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
|
|
7
|
+
import { SidePanelStyles } from './side-panel-styles.js'
|
|
8
|
+
import {
|
|
9
|
+
END_FACE,
|
|
10
|
+
SIDE_FACE,
|
|
11
|
+
cavityDepthOf,
|
|
12
|
+
cavityRefusalOf,
|
|
13
|
+
cavityRequestOf,
|
|
14
|
+
cavityResultOf,
|
|
15
|
+
cornerLimitOf,
|
|
16
|
+
cornerRefusalOf,
|
|
17
|
+
defaultWallOf,
|
|
18
|
+
mm,
|
|
19
|
+
sameOf,
|
|
20
|
+
sidesOf,
|
|
21
|
+
sideWallLimitOf,
|
|
22
|
+
wallLimitOf,
|
|
23
|
+
type FigureCavityAsk
|
|
24
|
+
} from './section-controls.js'
|
|
25
|
+
|
|
26
|
+
type Axis = 'x' | 'y' | 'z'
|
|
27
|
+
type End = 'start' | 'end'
|
|
28
|
+
type Body = { id: string; shape: FigureEvaluatedShape }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 단면 — a pushed part's rounded corners and its inside (ADR-0100 §3.3).
|
|
32
|
+
*
|
|
33
|
+
* Corners for a rectangle; an inside for a rectangle or an ellipse, with its walls (one thickness, or one per side when
|
|
34
|
+
* asked for), its two ends open or closed, and what that makes of the part and how deep. A drawn section says beside
|
|
35
|
+
* the inside why it has none. A turned body shows nothing here.
|
|
36
|
+
*
|
|
37
|
+
* A value being typed is drawn at once and recorded nowhere (`command-live`); a value the kernel would refuse is said
|
|
38
|
+
* under its field with the most it can be, and is not sent. Enter or leaving the field sends the one `command`, one step
|
|
39
|
+
* of the undo history; Esc puts the value back.
|
|
40
|
+
*/
|
|
41
|
+
@customElement('figure-section-panel')
|
|
42
|
+
export class FigureSectionPanel extends localize(i18next)(LitElement) {
|
|
43
|
+
static styles = [SidePanelStyles, css`
|
|
44
|
+
:host {
|
|
45
|
+
display: block;
|
|
46
|
+
}
|
|
47
|
+
div[row] {
|
|
48
|
+
display: flex;
|
|
49
|
+
gap: 6px;
|
|
50
|
+
align-items: center;
|
|
51
|
+
}
|
|
52
|
+
div[row] > * {
|
|
53
|
+
flex: 1;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
}
|
|
56
|
+
div[sides] {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
59
|
+
gap: 6px;
|
|
60
|
+
/* A side's refusal under its field lengthens its cell only: the field beside it stays level. */
|
|
61
|
+
align-items: start;
|
|
62
|
+
}
|
|
63
|
+
div[sides] > label {
|
|
64
|
+
display: grid;
|
|
65
|
+
gap: 2px;
|
|
66
|
+
}
|
|
67
|
+
small[refusal] {
|
|
68
|
+
color: var(--md-sys-color-error);
|
|
69
|
+
}
|
|
70
|
+
input[aria-invalid='true'] {
|
|
71
|
+
outline: 2px solid var(--md-sys-color-error);
|
|
72
|
+
}
|
|
73
|
+
`]
|
|
74
|
+
|
|
75
|
+
@property({ attribute: false }) body?: Body
|
|
76
|
+
@property({ type: Boolean }) locked = false
|
|
77
|
+
/** What each field would not take, by field, said under it. */
|
|
78
|
+
@state() private refusals: Record<string, string> = {}
|
|
79
|
+
/** What is typed in a field and not yet sent, by field: the figure drawn with it must not type over it. */
|
|
80
|
+
@state() private typed: Record<string, string> = {}
|
|
81
|
+
/** Walls one per side, asked for or because they already differ. */
|
|
82
|
+
@state() private perSide = false
|
|
83
|
+
|
|
84
|
+
protected willUpdate(changed: PropertyValues<this>) {
|
|
85
|
+
const before = changed.get('body') as Body | undefined
|
|
86
|
+
if (changed.has('body') && before?.id !== this.body?.id) {
|
|
87
|
+
this.refusals = {}
|
|
88
|
+
this.typed = {}
|
|
89
|
+
/* A part whose walls already differ opens with them one per side. */
|
|
90
|
+
const walls = this.body?.shape.cavity?.wall
|
|
91
|
+
this.perSide = !!walls && sameOf(Object.values(walls)) === undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
render() {
|
|
96
|
+
const body = this.body
|
|
97
|
+
const e = body?.shape?.provider === 'extrude' ? body.shape.extrusion : undefined
|
|
98
|
+
if (!body || !e) return nothing
|
|
99
|
+
const kind = e.section.kind
|
|
100
|
+
return html`<section>
|
|
101
|
+
<h3>${i18next.t('figure.label.group-section')}</h3>
|
|
102
|
+
${kind === 'rectangle' ? this.renderCorner(body) : nothing}
|
|
103
|
+
${kind === 'rectangle' || kind === 'ellipse'
|
|
104
|
+
? this.renderCavity(body, e.axis)
|
|
105
|
+
: html`<div field>
|
|
106
|
+
<small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
|
|
107
|
+
</div>`}
|
|
108
|
+
</section>`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private renderCorner(body: Body) {
|
|
112
|
+
const d = body.shape.dimensions
|
|
113
|
+
const reaches = Object.keys(d)
|
|
114
|
+
.filter(k => k.startsWith('corner.'))
|
|
115
|
+
.map(k => d[k])
|
|
116
|
+
const now = reaches.length ? sameOf(reaches) : 0
|
|
117
|
+
const max = cornerLimitOf(body.shape)
|
|
118
|
+
return html`<div field>
|
|
119
|
+
<label for="corner">${i18next.t('figure.label.section-corner')}</label>
|
|
120
|
+
${this.number('corner', now, {
|
|
121
|
+
min: 0,
|
|
122
|
+
ask: value => {
|
|
123
|
+
const r = cornerRefusalOf(body.shape, value)
|
|
124
|
+
return r ? i18next.t('figure.text.section-corner-over', { max: String(mm(r.max)) }) : ''
|
|
125
|
+
},
|
|
126
|
+
action: value => ({ kind: 'set-section-corner', part: body.id, corner: value > 0 ? value : null }),
|
|
127
|
+
placeholder: now === undefined ? i18next.t('figure.text.section-corner-differs') : ''
|
|
128
|
+
})}
|
|
129
|
+
${this.refusals.corner ? nothing : max !== undefined ? html`<small>${i18next.t('figure.text.section-at-most', { max: String(mm(max)) })}</small>` : nothing}
|
|
130
|
+
</div>`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private renderCavity(body: Body, axis: Axis) {
|
|
134
|
+
const cavity = body.shape.cavity
|
|
135
|
+
const request = (patch: Partial<FigureCavityRequest>) => cavityRequestOf(body.shape, patch)
|
|
136
|
+
const said = (ask: FigureCavityAsk | null) =>
|
|
137
|
+
!ask
|
|
138
|
+
? ''
|
|
139
|
+
: ask.code === 'not-positive'
|
|
140
|
+
? i18next.t('figure.text.section-not-positive')
|
|
141
|
+
: i18next.t(`figure.text.section-${ask.code === 'wall-too-thick' ? 'wall' : 'ends'}-over`, { max: String(ask.max) })
|
|
142
|
+
const cavityField = (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) =>
|
|
143
|
+
this.number(key, now, {
|
|
144
|
+
min: 0,
|
|
145
|
+
ask: value => {
|
|
146
|
+
if (!(value > 0)) return i18next.t('figure.text.section-not-positive')
|
|
147
|
+
const ask = cavityRefusalOf(body.shape, request(patch(value)))
|
|
148
|
+
/* One side too thick: the most it can be is the width across less the wall opposite, not the even limit. */
|
|
149
|
+
const side = key.startsWith('wall.') ? key.slice(5) : undefined
|
|
150
|
+
if (ask?.code === 'wall-too-thick' && side && cavity)
|
|
151
|
+
return said({ code: 'wall-too-thick', max: mm(sideWallLimitOf(body.shape, cavity.wall, side)) })
|
|
152
|
+
return said(ask)
|
|
153
|
+
},
|
|
154
|
+
action: value => ({ kind: 'set-cavity', part: body.id, cavity: request(patch(value)) })
|
|
155
|
+
})
|
|
156
|
+
return html`<div field>
|
|
157
|
+
<label>
|
|
158
|
+
<input
|
|
159
|
+
type="checkbox"
|
|
160
|
+
.checked=${live(!!cavity)}
|
|
161
|
+
?disabled=${this.locked}
|
|
162
|
+
@change=${(ev: Event) => {
|
|
163
|
+
const on = (ev.target as HTMLInputElement).checked
|
|
164
|
+
;(ev.target as HTMLInputElement).checked = !!cavity
|
|
165
|
+
this.command({ kind: 'set-cavity', part: body.id, cavity: on ? request({}) : null })
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
${i18next.t('figure.label.section-cavity')}
|
|
169
|
+
</label>
|
|
170
|
+
${cavity
|
|
171
|
+
? html`<small result>
|
|
172
|
+
${i18next.t('figure.text.section-depth', {
|
|
173
|
+
result: i18next.t(`figure.label.section-result-${cavityResultOf(cavity)}`),
|
|
174
|
+
depth: String(cavityDepthOf(body.shape))
|
|
175
|
+
})}
|
|
176
|
+
</small>`
|
|
177
|
+
: nothing}
|
|
178
|
+
</div>
|
|
179
|
+
${cavity
|
|
180
|
+
? html`<div field>
|
|
181
|
+
<label>${i18next.t('figure.label.section-wall')}</label>
|
|
182
|
+
${this.perSide
|
|
183
|
+
? html`<div sides>
|
|
184
|
+
${[...sidesOf(axis)].sort((a, b) => a[0]!.localeCompare(b[0]!) || (a[1] === '-' ? -1 : 1)).map(
|
|
185
|
+
side => html`<label>
|
|
186
|
+
<span>${i18next.t('figure.label.face-' + SIDE_FACE[side])}</span>
|
|
187
|
+
${cavityField(`wall.${side}`, cavity.wall[side], value => ({ wall: { ...cavity.wall, [side]: value } }))}
|
|
188
|
+
</label>`
|
|
189
|
+
)}
|
|
190
|
+
</div>`
|
|
191
|
+
: cavityField('wall', sameOf(Object.values(cavity.wall)), value => ({ wall: value }))}
|
|
192
|
+
${this.perSide || this.refusals.wall
|
|
193
|
+
? nothing
|
|
194
|
+
: html`<small>${i18next.t('figure.text.section-wall-at-most', { max: String(mm(wallLimitOf(body.shape))) })}</small>`}
|
|
195
|
+
<label>
|
|
196
|
+
<input
|
|
197
|
+
type="checkbox"
|
|
198
|
+
.checked=${live(this.perSide)}
|
|
199
|
+
?disabled=${this.locked}
|
|
200
|
+
@change=${(ev: Event) => (this.perSide = (ev.target as HTMLInputElement).checked)}
|
|
201
|
+
/>
|
|
202
|
+
${i18next.t('figure.label.section-wall-per-side')}
|
|
203
|
+
</label>
|
|
204
|
+
</div>
|
|
205
|
+
${(['start', 'end'] as const).map(end => this.renderEnd(body, axis, end, cavity[end], request, cavityField))}`
|
|
206
|
+
: nothing}`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private renderEnd(
|
|
210
|
+
body: Body,
|
|
211
|
+
axis: Axis,
|
|
212
|
+
end: End,
|
|
213
|
+
now: 'open' | { closed: number },
|
|
214
|
+
request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest,
|
|
215
|
+
cavityField: (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) => unknown
|
|
216
|
+
) {
|
|
217
|
+
const closed = now !== 'open'
|
|
218
|
+
return html`<div field>
|
|
219
|
+
<label>${i18next.t('figure.label.face-' + END_FACE(axis, end))}</label>
|
|
220
|
+
<div row>
|
|
221
|
+
<select
|
|
222
|
+
.value=${live(closed ? 'closed' : 'open')}
|
|
223
|
+
?disabled=${this.locked}
|
|
224
|
+
@change=${(ev: Event) => {
|
|
225
|
+
const select = ev.target as HTMLSelectElement
|
|
226
|
+
const want = select.value
|
|
227
|
+
select.value = closed ? 'closed' : 'open'
|
|
228
|
+
this.command({ kind: 'set-cavity', part: body.id, cavity: request({ [end]: want === 'open' ? 'open' : { closed: defaultWallOf(body.shape) } }) })
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<option value="closed">${i18next.t('figure.label.section-end-closed')}</option>
|
|
232
|
+
<option value="open">${i18next.t('figure.label.section-end-open')}</option>
|
|
233
|
+
</select>
|
|
234
|
+
${closed ? cavityField(`closed.${end}`, now.closed, value => ({ [end]: { closed: value } })) : html`<span></span>`}
|
|
235
|
+
</div>
|
|
236
|
+
</div>`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A millimetre field. Typing draws the value (or, if the kernel would refuse it, says why under the field and draws the
|
|
241
|
+
* figure as it stands); Enter or leaving sends it once; Esc puts the value back.
|
|
242
|
+
*/
|
|
243
|
+
private number(
|
|
244
|
+
key: string,
|
|
245
|
+
now: number | undefined,
|
|
246
|
+
o: { min: number; ask: (value: number) => string; action: (value: number) => FigureAuthoringAction; placeholder?: string }
|
|
247
|
+
) {
|
|
248
|
+
const shown = now === undefined ? '' : String(mm(now))
|
|
249
|
+
const done = () => {
|
|
250
|
+
const { [key]: _, ...rest } = this.typed
|
|
251
|
+
this.typed = rest
|
|
252
|
+
}
|
|
253
|
+
const read = (input: HTMLInputElement) => (input.value === '' ? NaN : Number(input.value))
|
|
254
|
+
const judge = (input: HTMLInputElement) => {
|
|
255
|
+
const value = read(input)
|
|
256
|
+
const refusal = !Number.isFinite(value) || value < o.min ? '' : o.ask(value)
|
|
257
|
+
this.refusals = { ...this.refusals, [key]: refusal }
|
|
258
|
+
return Number.isFinite(value) && value >= o.min && !refusal ? value : undefined
|
|
259
|
+
}
|
|
260
|
+
return html`<input
|
|
261
|
+
id=${key}
|
|
262
|
+
type="number"
|
|
263
|
+
min=${o.min}
|
|
264
|
+
step="any"
|
|
265
|
+
inputmode="decimal"
|
|
266
|
+
.value=${live(this.typed[key] ?? shown)}
|
|
267
|
+
placeholder=${o.placeholder ?? ''}
|
|
268
|
+
aria-invalid=${this.refusals[key] ? 'true' : 'false'}
|
|
269
|
+
aria-describedby=${`${key}-refusal`}
|
|
270
|
+
?disabled=${this.locked}
|
|
271
|
+
@input=${(ev: Event) => {
|
|
272
|
+
this.typed = { ...this.typed, [key]: (ev.target as HTMLInputElement).value }
|
|
273
|
+
const value = judge(ev.target as HTMLInputElement)
|
|
274
|
+
this.preview(value === undefined ? null : o.action(value))
|
|
275
|
+
}}
|
|
276
|
+
@change=${(ev: Event) => {
|
|
277
|
+
const input = ev.target as HTMLInputElement
|
|
278
|
+
if (input.value === shown) return done(), this.preview(null)
|
|
279
|
+
const value = judge(input)
|
|
280
|
+
/* A refused value stays typed with its reason under it, to be corrected; nothing is sent. */
|
|
281
|
+
if (value === undefined) return this.preview(null)
|
|
282
|
+
done()
|
|
283
|
+
this.command(o.action(value))
|
|
284
|
+
}}
|
|
285
|
+
@keydown=${(ev: KeyboardEvent) => {
|
|
286
|
+
if (ev.key !== 'Escape') return
|
|
287
|
+
const input = ev.target as HTMLInputElement
|
|
288
|
+
input.value = shown
|
|
289
|
+
done()
|
|
290
|
+
this.refusals = { ...this.refusals, [key]: '' }
|
|
291
|
+
this.preview(null)
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
${this.refusals[key] ? html`<small refusal id=${`${key}-refusal`} role="alert">${this.refusals[key]}</small>` : nothing}`
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private preview(action: FigureAuthoringAction | null) {
|
|
298
|
+
this.dispatchEvent(new CustomEvent('command-live', { detail: { action }, bubbles: true, composed: true }))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private command(action: FigureAuthoringAction) {
|
|
302
|
+
this.dispatchEvent(new CustomEvent('command', { detail: { action }, bubbles: true, composed: true }))
|
|
303
|
+
}
|
|
304
|
+
}
|
package/client/modeller/gizmo.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { figureAttachmentsOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { figureAttachmentsOf, figureShapeProviderOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureAsset, FigureInputChange } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What the gizmo may write for a part: move · turn · size, on a asset.
|
|
6
6
|
*
|
|
7
7
|
* A drag can only be written back where the part is placed by its own numbers. A part the create commands made is:
|
|
8
|
-
* its pose is `rigid@1` into the asset frame from `<part>.position.*` and `<part>.rotation.*`, and
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* its pose is `rigid@1` into the asset frame from `<part>.position.*` and `<part>.rotation.*`, and its shape takes its
|
|
9
|
+
* measures from inputs of its own — `<part>.size.*` from `addFigureBox`, `<part>.dimension.*` from the editor's shape
|
|
10
|
+
* palette — at the arguments the shape itself names (figure-model's provider). A part fastened to another, following an instance size or placed by
|
|
11
11
|
* expressions has no such numbers; the gizmo does not offer what it could not write back, rather
|
|
12
12
|
* than move something the next evaluation would put back.
|
|
13
13
|
*/
|
|
@@ -29,9 +29,6 @@ export interface GizmoReach {
|
|
|
29
29
|
round?: { across: string[]; along: string[] }
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Shapes whose first three arguments are the extent along X, Y and Z: a door frame's too (its width, height, depth). */
|
|
33
|
-
const BOXES = new Set(['box-shape@1', 'rounded-box@1', 'portal-profile@2', 'hollow-box@2'])
|
|
34
|
-
|
|
35
32
|
interface Node {
|
|
36
33
|
id: string
|
|
37
34
|
op: string
|
|
@@ -65,15 +62,28 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
|
|
|
65
62
|
const posed = direct || reseated
|
|
66
63
|
const shape = nodes.find(node => node.id === `${part}.shape`)
|
|
67
64
|
const mine = (ids: string[]) => ids.every(id => id.startsWith(`${part}.`) && design.has(id))
|
|
68
|
-
|
|
69
|
-
shape
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
/*
|
|
66
|
+
Which argument sizes the part along X, Y and Z: the shape says (figure-model's provider, ADR-0100) -- never its first
|
|
67
|
+
three arguments. A round section written from one radius, a sphere and a taper keep their shape: their radii take one
|
|
68
|
+
factor together.
|
|
69
|
+
*/
|
|
70
|
+
const provider = shape ? figureShapeProviderOf(shape as never) : undefined
|
|
71
|
+
const edit = provider?.edit
|
|
72
|
+
const refOf = (axis: 'x' | 'y' | 'z') => {
|
|
73
|
+
const e = edit?.[axis]
|
|
74
|
+
return e && !('refused' in e) ? shape!.args[e.arg]! : undefined
|
|
75
|
+
}
|
|
76
|
+
const measures = AXES.map(refOf)
|
|
77
|
+
const shared = !!edit && AXES.some(axis => { const e = edit[axis]; return !('refused' in e) && !!(e.sameAs || e.with?.length) })
|
|
78
|
+
let round: GizmoReach['round']
|
|
79
|
+
if (shared && edit) {
|
|
80
|
+
const withs = AXES.flatMap(axis => { const e = edit[axis]; return !('refused' in e) ? (e.with ?? []).map(i => shape!.args[i]!) : [] })
|
|
81
|
+
const sphere = !!provider && Object.keys(provider.faces).length === 0
|
|
82
|
+
const across = [...new Set([refOf('x')!, ...withs])]
|
|
83
|
+
const along = sphere ? [] : [refOf('y')!]
|
|
84
|
+
if ([...across, ...along].every(id => id && mine([id]))) round = { across: sphere ? [...new Set([...across, refOf('y')!])] : across, along }
|
|
85
|
+
}
|
|
86
|
+
const extent = !round && measures.every(Boolean) ? (measures as string[]) : []
|
|
77
87
|
const sizes =
|
|
78
88
|
extent.length === 3 && extent.every(id => id.startsWith(`${part}.`) && design.has(id)) ? (extent as [string, string, string]) : undefined
|
|
79
89
|
/*
|
|
@@ -2,6 +2,7 @@ import { compileFigureAsset, compileFigureGraph, figureWorldBoxesOf, figureWrite
|
|
|
2
2
|
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
import type { FigureRenderPart } from './renderer.js'
|
|
5
|
+
import { sectionShape } from './drawn-shapes.js'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
* Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
|
|
@@ -249,14 +250,14 @@ export function jointFanOf(asset: FigureAsset, part: string, values: Record<stri
|
|
|
249
250
|
const r = [0, 1, 2].map(i => [first[i]!, axisNow[i]!, zAxis[i]!])
|
|
250
251
|
const local = (p: number[]) => {
|
|
251
252
|
const v = sub(p, pivot)
|
|
252
|
-
return { x: dot(v, first),
|
|
253
|
+
return { x: dot(v, first), z: dot(v, zAxis) }
|
|
253
254
|
}
|
|
254
255
|
const full = max - min >= 359.999
|
|
255
|
-
const profile = [...(full ? [] : [{ x: 0,
|
|
256
|
+
const profile = [...(full ? [] : [{ x: 0, z: 0 }]), ...traced.map(local)].map(p => ({ x: Math.round(p.x * 1000) / 1000, z: Math.round(p.z * 1000) / 1000 }))
|
|
256
257
|
|
|
257
258
|
return {
|
|
258
259
|
id: JOINT_FAN,
|
|
259
|
-
shape:
|
|
260
|
+
shape: sectionShape(profile, THICKNESS),
|
|
260
261
|
pose: { t: pivot, r },
|
|
261
262
|
material: { token: '__joint-fan', transparent: true },
|
|
262
263
|
/** Which control turns it: the joint the fan shows. */
|