@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
package/client/modeller/links.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
|
|
4
|
+
import { startingShapeKindOf } from './starting-shape.js'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
7
|
* What a part is tied to — the readers behind the parts' size response, fastening and motion sections. Kept apart from the element so they can be tested without a browser.
|
|
@@ -62,7 +63,7 @@ export function canFollow(asset: FigureAsset, part: string): boolean {
|
|
|
62
63
|
*/
|
|
63
64
|
export function followableAxes(asset: FigureAsset, part: string): Axis[] {
|
|
64
65
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
65
|
-
return shape ? figureFollowableAxesOf(shape
|
|
66
|
+
return shape ? figureFollowableAxesOf(shape as never) : []
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/**
|
|
@@ -87,27 +88,14 @@ export function resizeAxesOf(asset: FigureAsset, part: string, pending?: readonl
|
|
|
87
88
|
return { axes: editable, basis: editable.length ? 'suggested' : 'none' }
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
const KIND_OF_SHAPE: Record<string, string> = {
|
|
91
|
-
'box-shape@1': 'cube',
|
|
92
|
-
'rounded-box@1': 'rounded-box',
|
|
93
|
-
'rounded-box@2': 'rounded-box',
|
|
94
|
-
'hollow-box@1': 'hollow-box',
|
|
95
|
-
'hollow-box@2': 'hollow-box',
|
|
96
|
-
'portal-profile@2': 'portal',
|
|
97
|
-
'cylinder-shape@1': 'cylinder',
|
|
98
|
-
'cylinder-shape@2': 'cylinder',
|
|
99
|
-
'sphere-shape@1': 'sphere',
|
|
100
|
-
'frustum-shape@1': 'frustum',
|
|
101
|
-
'polygon-shape@1': 'polygon'
|
|
102
|
-
}
|
|
103
|
-
|
|
104
91
|
/**
|
|
105
92
|
* The selected part in three short lines, for the head of its panel (designer 2026-09-27, B): what it is, how its
|
|
106
93
|
* size answers the figure's, and what holds it -- 「box · 상자 / 너비는 도형을 따라감 / 아랫면을 바닥에 유지 중」.
|
|
107
94
|
*/
|
|
108
95
|
export function partSummaryOf(asset: FigureAsset, part: string, say: (key: string, params?: Record<string, string>) => string): { kind: string; size: string; relation: string } {
|
|
109
96
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
110
|
-
const
|
|
97
|
+
const named = startingShapeKindOf(shape as never)
|
|
98
|
+
const kind = named ? say(`figure.label.primitive-${named}`) : ''
|
|
111
99
|
const editable = followableAxes(asset, part)
|
|
112
100
|
const round = editable.length === 2
|
|
113
101
|
const word = (axis: Axis) => say(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
|
|
@@ -146,7 +134,10 @@ export function fieldWordsOf(id: string, say: (key: string) => string): string {
|
|
|
146
134
|
/** What a part's own measure on an axis is now (a box's side, a cylinder's radius or length), at the design as it stands. */
|
|
147
135
|
export function dimensionNow(asset: FigureAsset, part: string, axis: Axis): number | undefined {
|
|
148
136
|
const m = modelOf(asset)
|
|
149
|
-
|
|
137
|
+
/* The argument the shape names for this axis (figure-model's provider), not its place among the arguments. */
|
|
138
|
+
const shape = m.nodes.find(n => n.id === `${part}.shape`)
|
|
139
|
+
const edit = shape && figureShapeProviderOf(shape as never)?.edit[axis]
|
|
140
|
+
const ref = edit && !('refused' in edit) ? shape!.args[edit.arg] : undefined
|
|
150
141
|
if (!ref) return undefined
|
|
151
142
|
try {
|
|
152
143
|
const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
|
|
@@ -163,7 +154,7 @@ export function dimensionNow(asset: FigureAsset, part: string, axis: Axis): numb
|
|
|
163
154
|
*/
|
|
164
155
|
export function measureNow(asset: FigureAsset, part: string, axis: Axis): number | undefined {
|
|
165
156
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
166
|
-
const edit = shape && figureShapeProviderOf(shape
|
|
157
|
+
const edit = shape && figureShapeProviderOf(shape as never)?.edit[axis]
|
|
167
158
|
if (!shape || !edit || 'refused' in edit) return undefined
|
|
168
159
|
try {
|
|
169
160
|
const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
|
|
@@ -180,13 +171,20 @@ export function measureNow(asset: FigureAsset, part: string, axis: Axis): number
|
|
|
180
171
|
*/
|
|
181
172
|
export function facesOf(asset: FigureAsset, part: string): string[] {
|
|
182
173
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
183
|
-
return Object.keys((shape && figureShapeFaces(shape
|
|
174
|
+
return inScreenOrder(Object.keys((shape && figureShapeFaces(shape as never)) ?? {}))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Faces in the order the screen lists them: the six outer ones left to front, then the rest as the shape gives them. */
|
|
178
|
+
const SCREEN_ORDER = ['left', 'right', 'bottom', 'top', 'back', 'front']
|
|
179
|
+
function inScreenOrder(names: string[]): string[] {
|
|
180
|
+
const rank = (n: string) => (SCREEN_ORDER.includes(n) ? SCREEN_ORDER.indexOf(n) : SCREEN_ORDER.length)
|
|
181
|
+
return names.map((n, i) => [n, i] as const).sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]).map(([n]) => n)
|
|
184
182
|
}
|
|
185
183
|
|
|
186
184
|
/** The axis a face of a part faces along, from the same definitions. */
|
|
187
185
|
export function faceAxisOf(asset: FigureAsset, part: string, face: string): Axis | undefined {
|
|
188
186
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
189
|
-
return (shape && figureShapeFaces(shape
|
|
187
|
+
return (shape && figureShapeFaces(shape as never)?.[face]?.axis) as Axis | undefined
|
|
190
188
|
}
|
|
191
189
|
|
|
192
190
|
/** Whether `attach` can take this part: any part whose shape has a flat face. */
|
|
@@ -556,12 +554,14 @@ export function choiceOf(follow: Follow): string {
|
|
|
556
554
|
}
|
|
557
555
|
}
|
|
558
556
|
|
|
559
|
-
/**
|
|
557
|
+
/**
|
|
558
|
+
* The faces a place can sit on for this part: its flat faces, but not a plane that only touches a curved side along a
|
|
559
|
+
* line (a cylinder's side, Q3) -- nothing is held on a line. A sphere has none.
|
|
560
|
+
*/
|
|
560
561
|
export function placeFacesOf(asset: FigureAsset, part: string): FigureFace[] {
|
|
561
562
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return []
|
|
563
|
+
const faces = (shape && figureShapeFaces(shape as never)) ?? {}
|
|
564
|
+
return inScreenOrder(Object.entries(faces).filter(([, f]) => !(f as { contact?: string }).contact).map(([name]) => name)) as FigureFace[]
|
|
565
565
|
}
|
|
566
566
|
|
|
567
567
|
/** The places things are held that sit on this part. */
|
package/client/modeller/live.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { compileFigureAsset, editFigurePart } from '@hatiolab/figure-model'
|
|
2
|
-
import type { FigureAsset, FigurePartEdit } from '@hatiolab/figure-model'
|
|
1
|
+
import { applyFigureAuthoring, compileFigureAsset, editFigurePart } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureAsset, FigureAuthoringAction, FigurePartEdit } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
import { drawableFigureParts, type FigureRenderPart } from './renderer.js'
|
|
5
5
|
|
|
@@ -19,6 +19,8 @@ export interface LiveValues {
|
|
|
19
19
|
values: Record<string, number>
|
|
20
20
|
/** A part edit in hand, not yet committed. */
|
|
21
21
|
edit?: FigurePartEdit
|
|
22
|
+
/** An authoring command in hand (a corner radius, an inside, being typed), not yet committed (ADR-0100 §3.3). */
|
|
23
|
+
action?: FigureAuthoringAction
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export const NOTHING_LIVE: LiveValues = { values: {} }
|
|
@@ -38,7 +40,7 @@ export function liveOverridesOf(asset: FigureAsset, values: Record<string, numbe
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function isLive(live: LiveValues): boolean {
|
|
41
|
-
return !!live.edit || Object.keys(live.values).length > 0
|
|
43
|
+
return !!live.edit || !!live.action || Object.keys(live.values).length > 0
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -50,9 +52,11 @@ export function isLive(live: LiveValues): boolean {
|
|
|
50
52
|
*/
|
|
51
53
|
export function livePartsOf(asset: FigureAsset, live: LiveValues): FigureRenderPart[] | null {
|
|
52
54
|
if (!isLive(live)) return null
|
|
53
|
-
const { design, state } = liveOverridesOf(asset, live.values)
|
|
54
55
|
try {
|
|
55
56
|
let next = live.edit ? editFigurePart(asset, live.edit) : asset
|
|
57
|
+
if (live.action) next = applyFigureAuthoring(next, live.action)
|
|
58
|
+
/* Read against the figure the edit makes: a command can take an input away (one wall becomes one per side). */
|
|
59
|
+
const { design, state } = liveOverridesOf(next, live.values)
|
|
56
60
|
if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
|
|
57
61
|
return drawableFigureParts(compileFigureAsset(next).evaluate(state).geometry)
|
|
58
62
|
} catch {
|
|
@@ -154,8 +154,9 @@ export function figureReportOf(asset: FigureAsset): FigureReport | undefined {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
* The key things-scene shares a body's geometry by (`figure-geometry.ts` `keyOf`):
|
|
158
|
-
*
|
|
157
|
+
* The key things-scene shares a body's geometry by (`figure-geometry.ts` `keyOf`): the operation, what is not in its
|
|
158
|
+
* numbers (the axis, the kind of section, which ends of an inside are open), its numbers to a micrometre, and segments
|
|
159
|
+
* (12 when unset).
|
|
159
160
|
*/
|
|
160
161
|
export function shapeKeyOf(body: FigureAssetGeometry | FigureGeometry): string {
|
|
161
162
|
if (!('shape' in body)) {
|
|
@@ -169,9 +170,12 @@ export function shapeKeyOf(body: FigureAssetGeometry | FigureGeometry): string {
|
|
|
169
170
|
.sort()
|
|
170
171
|
.map(k => `${k}=${round(shape.dimensions[k]!)}`)
|
|
171
172
|
.join(',')
|
|
172
|
-
const
|
|
173
|
+
const e = shape.extrusion,
|
|
174
|
+
c = shape.cavity,
|
|
175
|
+
r = shape.revolution
|
|
176
|
+
const meaning = e ? `${e.axis}:${e.section.kind}:${c ? `${c.start === 'open' ? 'o' : 'c'}${c.end === 'open' ? 'o' : 'c'}` : '-'}` : r ? `${r.axis}:${r.profile.kind}` : ''
|
|
173
177
|
const segments = (body as { segments?: number }).segments ?? 12
|
|
174
|
-
return `${shape.provider}|${dims}|s${segments}
|
|
178
|
+
return `${shape.provider}|${meaning}|${dims}|s${segments}`
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
/**
|
|
@@ -202,7 +206,9 @@ function consistency(asset: FigureAsset, distinctMaterials: number): FigureMatur
|
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
/** Seen from above, these do not fill their footprint box, by provider name. */
|
|
205
|
-
|
|
209
|
+
/** Round seen from above: a circle or an ellipse pushed along Y, anything turned about Y. */
|
|
210
|
+
const roundFootprint = (shape: { extrusion?: { axis: string; section: { kind: string } }; revolution?: { axis: string } }) =>
|
|
211
|
+
(shape.extrusion?.axis === 'y' && shape.extrusion.section.kind === 'ellipse') || shape.revolution?.axis === 'y'
|
|
206
212
|
|
|
207
213
|
/**
|
|
208
214
|
* Can it be told apart from above — baseline 1.
|
|
@@ -276,7 +282,7 @@ function topViewRegions(
|
|
|
276
282
|
const x1 = Math.min(GRID, Math.ceil((box.centre.x + box.extent.x / 2 - frame.x0) * sx))
|
|
277
283
|
const y0 = Math.max(0, Math.floor((box.centre.z - box.extent.z / 2 - frame.z0) * sz))
|
|
278
284
|
const y1 = Math.min(GRID, Math.ceil((box.centre.z + box.extent.z / 2 - frame.z0) * sz))
|
|
279
|
-
const round = 'shape' in body &&
|
|
285
|
+
const round = 'shape' in body && roundFootprint(body.shape as never)
|
|
280
286
|
|
|
281
287
|
for (let y = y0; y < y1; y++) {
|
|
282
288
|
for (let x = x0; x < x1; x++) {
|
|
@@ -3,6 +3,7 @@ import type { FigureAsset } from '@hatiolab/figure-model'
|
|
|
3
3
|
|
|
4
4
|
import { PREVIEW, TARGET_FACE } from './command-preview.js'
|
|
5
5
|
import type { FigureRenderPart } from './renderer.js'
|
|
6
|
+
import { boxShape } from './drawn-shapes.js'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
* Where a part's motion takes it (ADR-0097 §5.5, designer 2026-09-27): the bodies its control moves, drawn translucent at
|
|
@@ -73,7 +74,7 @@ export function motionEndsOf(asset: FigureAsset, part: string, values: Record<st
|
|
|
73
74
|
const y = [z[1]! * x[2]! - z[2]! * x[1]!, z[2]! * x[0]! - z[0]! * x[2]!, z[0]! * x[1]! - z[1]! * x[0]!]
|
|
74
75
|
ghost.push({
|
|
75
76
|
id: `${TARGET_FACE}:slide`,
|
|
76
|
-
shape:
|
|
77
|
+
shape: boxShape(length, THICKNESS, THICKNESS),
|
|
77
78
|
pose: { r: [0, 1, 2].map(i => [x[i]!, y[i]!, z[i]!]), t: [0, 1, 2].map(i => (from[i]! + to[i]!) / 2) },
|
|
78
79
|
material: { token: TARGET_FACE }
|
|
79
80
|
})
|
|
@@ -117,10 +117,30 @@ export function recipeRefusalOf(error: unknown, say: Say): string | null {
|
|
|
117
117
|
* duplicate of a row's part showed 「EDIT_TARGET: … not a repeated template」, 2026-09-27). The figure's node limit
|
|
118
118
|
* is said with its number.
|
|
119
119
|
*/
|
|
120
|
+
/**
|
|
121
|
+
* A corner or an inside the part cannot take (ADR-0100 stage 1): what was too large or too thick, or what holds on
|
|
122
|
+
* to what the edit would take away. The kernel names the problem in its message; the screen names it in words.
|
|
123
|
+
*/
|
|
124
|
+
function sectionRefusalOf(code: string | undefined, message: string, say: Say): string | null {
|
|
125
|
+
if (code === 'FEATURE_MAPPING_REQUIRED') {
|
|
126
|
+
/* 「takes away inside-bottom (joint j1); top (feature f2)」: the faces, each in the author's words. */
|
|
127
|
+
const faces = [...message.matchAll(/([\w-]+) \([^)]*\)/g)].map(([, face]) => say('figure.label.face-' + face, {}))
|
|
128
|
+
return say('figure.text.section-face-in-use', { faces: faces.join(', ') })
|
|
129
|
+
}
|
|
130
|
+
if (code === 'DIMENSION_LINKED') return say('figure.text.section-dimension-linked', {})
|
|
131
|
+
if (code !== 'GEOMETRY_DOMAIN') return null
|
|
132
|
+
if (/CORNER_TOO_LARGE/.test(message)) return say('figure.text.section-corner-too-large', {})
|
|
133
|
+
if (/NO_ROOM_INSIDE/.test(message)) return say('figure.text.section-wall-too-thick', {})
|
|
134
|
+
if (/CAVITY_NO_LENGTH/.test(message)) return say('figure.text.section-ends-too-thick', {})
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
export function editRefusalOf(error: unknown, say: Say): string {
|
|
121
139
|
const code = (error as { code?: string })?.code
|
|
122
140
|
const message = error instanceof Error ? error.message : String(error)
|
|
123
141
|
if (code === 'DOCUMENT_LIMIT') return say('figure.text.edit-refused-node-limit', { max: /maximum (\d+) nodes/.exec(message)?.[1] ?? '' })
|
|
142
|
+
const section = sectionRefusalOf(code, message, say)
|
|
143
|
+
if (section) return section
|
|
124
144
|
return say('figure.text.edit-refused', { code: code ?? '' })
|
|
125
145
|
}
|
|
126
146
|
|
|
@@ -4,7 +4,7 @@ import { buildFigureGeometry } from '@hatiolab/things-scene'
|
|
|
4
4
|
/** Evaluated boundary: graph values are resolved before reaching the renderer. */
|
|
5
5
|
export interface FigureRenderPart {
|
|
6
6
|
id: string
|
|
7
|
-
shape: { provider: string; dimensions: Record<string, number>;
|
|
7
|
+
shape: { provider: string; dimensions: Record<string, number>; extrusion?: unknown; cavity?: unknown; revolution?: unknown }
|
|
8
8
|
pose: {r:number[][];t:number[]}
|
|
9
9
|
material: {token:string;preset?:string;transparent?:boolean;emissive?:{token:string;intensity:number;on:boolean}}
|
|
10
10
|
segments?:number
|
|
@@ -34,7 +34,8 @@ export function drawableFigureParts(geometry: readonly unknown[]): FigureRenderP
|
|
|
34
34
|
*/
|
|
35
35
|
function geometry(part: FigureRenderPart): THREE.BufferGeometry {
|
|
36
36
|
const built = buildFigureGeometry(part.shape as never, part.segments ?? 12) as THREE.BufferGeometry | undefined
|
|
37
|
-
|
|
37
|
+
/* A shape the builder does not know comes back empty: said, not drawn as nothing. */
|
|
38
|
+
if (!built?.getAttribute('position')) throw Error('Unsupported shape: ' + part.shape.provider)
|
|
38
39
|
return built
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { FIGURE_SECTION_PLANE, checkFigureSection, figureExtrusionFeaturesOf } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* What the 단면 section of the properties tab reads and asks (ADR-0100 §3.3): a pushed part's corners and its inside.
|
|
6
|
+
* Whether a value can be taken is asked of the kernel's own section checks -- the screen keeps no rule of its own -- and
|
|
7
|
+
* the most it can take is said beside the field, so a refusal names the value to type rather than only that it failed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Axis = 'x' | 'y' | 'z'
|
|
11
|
+
type End = 'start' | 'end'
|
|
12
|
+
type Extrusion = NonNullable<FigureEvaluatedShape['extrusion']>
|
|
13
|
+
|
|
14
|
+
/** The face each side of a section is, and each end of a push: a push's start is its low side. */
|
|
15
|
+
export const SIDE_FACE: Record<string, string> = { 'x-': 'left', 'x+': 'right', 'y-': 'bottom', 'y+': 'top', 'z-': 'back', 'z+': 'front' }
|
|
16
|
+
export const END_FACE = (axis: Axis, end: End) => SIDE_FACE[`${axis}${end === 'start' ? '-' : '+'}`]!
|
|
17
|
+
|
|
18
|
+
/** The one value every entry holds, or undefined when they differ (or there are none). */
|
|
19
|
+
export const sameOf = (values: readonly (number | undefined)[]) =>
|
|
20
|
+
values.length && values.every(v => v !== undefined && Math.abs(v - values[0]!) < 1e-6) ? values[0] : undefined
|
|
21
|
+
|
|
22
|
+
export const mm = (value: number) => Math.round(value * 1000) / 1000
|
|
23
|
+
|
|
24
|
+
/** The four sides of a push's section, in the kernel's order: the first plane axis low and high, then the second. */
|
|
25
|
+
export const sidesOf = (axis: Axis) => FIGURE_SECTION_PLANE[axis].flatMap(a => [`${a}-`, `${a}+`])
|
|
26
|
+
|
|
27
|
+
/** The section's widths across, one per plane axis: a rectangle's sides, an ellipse's diameters. */
|
|
28
|
+
function acrossOf(e: Extrusion): number[] {
|
|
29
|
+
const s = e.section
|
|
30
|
+
if (s.kind === 'rectangle') return FIGURE_SECTION_PLANE[e.axis].map(a => s.size[a]!)
|
|
31
|
+
if (s.kind === 'ellipse') return FIGURE_SECTION_PLANE[e.axis].map(a => 2 * s.radius[a]!)
|
|
32
|
+
return []
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The wall a new inside starts with: a tenth of the section's narrower side, at least 1 mm. */
|
|
36
|
+
export function defaultWallOf(shape: FigureEvaluatedShape): number {
|
|
37
|
+
return Math.max(1, Math.round(Math.min(...acrossOf(shape.extrusion!)) / 10))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The inside as the panel asks for it: the part's own, with one thing changed. A part with none yet gets a default
|
|
42
|
+
* wall all round, a closed start as thick as the wall and an open end -- a tray standing on its floor.
|
|
43
|
+
*/
|
|
44
|
+
export function cavityRequestOf(shape: FigureEvaluatedShape, patch: Partial<FigureCavityRequest> = {}): FigureCavityRequest {
|
|
45
|
+
const cavity = shape.cavity
|
|
46
|
+
return {
|
|
47
|
+
wall: cavity ? (sameOf(Object.values(cavity.wall)) ?? { ...cavity.wall }) : defaultWallOf(shape),
|
|
48
|
+
start: cavity?.start ?? { closed: defaultWallOf(shape) },
|
|
49
|
+
end: cavity?.end ?? 'open',
|
|
50
|
+
...patch
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The most a rectangle's corners can reach: half its narrower side (the kernel's CORNER_TOO_LARGE says where). */
|
|
55
|
+
export function cornerLimitOf(shape: FigureEvaluatedShape): number | undefined {
|
|
56
|
+
const e = shape.extrusion
|
|
57
|
+
if (e?.section.kind !== 'rectangle') return undefined
|
|
58
|
+
const [u, v] = FIGURE_SECTION_PLANE[e.axis]
|
|
59
|
+
const far = 2 * Math.max(...acrossOf(e))
|
|
60
|
+
const issues = checkFigureSection({ kind: 'rectangle', size: e.section.size, corner: { [u]: far, [v]: far } }, e.axis)
|
|
61
|
+
const maxes = issues.flatMap(i => (i.code === 'CORNER_TOO_LARGE' ? [i.max] : []))
|
|
62
|
+
return maxes.length ? Math.min(...maxes) : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a corner radius can be taken; if not, the most it can be. */
|
|
66
|
+
export function cornerRefusalOf(shape: FigureEvaluatedShape, radius: number): { max: number } | null {
|
|
67
|
+
const e = shape.extrusion
|
|
68
|
+
if (e?.section.kind !== 'rectangle' || !(radius > 0)) return null
|
|
69
|
+
const [u, v] = FIGURE_SECTION_PLANE[e.axis]
|
|
70
|
+
const issues = checkFigureSection({ kind: 'rectangle', size: e.section.size, corner: { [u]: radius, [v]: radius } }, e.axis)
|
|
71
|
+
return issues.some(i => i.code === 'CORNER_TOO_LARGE') ? { max: cornerLimitOf(shape)! } : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One side's wall, the others kept: it must leave room inside, so under the width across it less the opposite wall. */
|
|
75
|
+
export function sideWallLimitOf(shape: FigureEvaluatedShape, wall: Record<string, number>, side: string): number {
|
|
76
|
+
const e = shape.extrusion!
|
|
77
|
+
const axis = side[0] as Axis
|
|
78
|
+
const across = acrossOf(e)[FIGURE_SECTION_PLANE[e.axis].indexOf(axis)]!
|
|
79
|
+
return across - (wall[`${axis}${side[1] === '-' ? '+' : '-'}`] ?? 0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A wall must leave room inside: under half the narrower width the section has. */
|
|
83
|
+
export const wallLimitOf = (shape: FigureEvaluatedShape) => Math.min(...acrossOf(shape.extrusion!)) / 2
|
|
84
|
+
|
|
85
|
+
export type FigureCavityAsk =
|
|
86
|
+
| { code: 'wall-too-thick'; max: number }
|
|
87
|
+
| { code: 'ends-too-thick'; max: number }
|
|
88
|
+
| { code: 'not-positive' }
|
|
89
|
+
|
|
90
|
+
/** Whether an inside can be taken, asked of the kernel; if not, what to change and the most it can be. */
|
|
91
|
+
export function cavityRefusalOf(shape: FigureEvaluatedShape, request: FigureCavityRequest): FigureCavityAsk | null {
|
|
92
|
+
const e = shape.extrusion!
|
|
93
|
+
const sides = sidesOf(e.axis)
|
|
94
|
+
const wall = typeof request.wall === 'number' ? Object.fromEntries(sides.map(s => [s, request.wall as number])) : request.wall
|
|
95
|
+
const made = figureExtrusionFeaturesOf(e, [{ id: 'inside', wall: wall as Record<string, number>, start: request.start, end: request.end }])
|
|
96
|
+
if (!('refused' in made)) return null
|
|
97
|
+
const r = made.refused
|
|
98
|
+
if (r.code === 'CAVITY' && r.refused === 'NO_ROOM_INSIDE') return { code: 'wall-too-thick', max: mm(wallLimitOf(shape)) }
|
|
99
|
+
if (r.code === 'CAVITY_NO_LENGTH') return { code: 'ends-too-thick', max: mm(e.end - e.start) }
|
|
100
|
+
return { code: 'not-positive' }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the inside makes of the part (§3.3): open at both ends a pipe, at one a container, at neither a closed space. */
|
|
104
|
+
export function cavityResultOf(cavity: NonNullable<FigureEvaluatedShape['cavity']>): 'pipe' | 'container' | 'sealed' {
|
|
105
|
+
const open = [cavity.start, cavity.end].filter(e => e === 'open').length
|
|
106
|
+
return open === 2 ? 'pipe' : open === 1 ? 'container' : 'sealed'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** How deep the inside is along the push: the length less the closed ends. */
|
|
110
|
+
export function cavityDepthOf(shape: FigureEvaluatedShape): number {
|
|
111
|
+
const e = shape.extrusion!,
|
|
112
|
+
c = shape.cavity!
|
|
113
|
+
const closed = (x: typeof c.start) => (x === 'open' ? 0 : x.closed)
|
|
114
|
+
return mm(e.end - e.start - closed(c.start) - closed(c.end))
|
|
115
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person calls a part's shape (ADR-0100 §2.1): the starting shape it reads as -- 상자, 둥근 상자, 위가 열린 상자,
|
|
3
|
+
* 원기둥, 문틀, 구, 원뿔, 그린 단면. A word for the screen, read from the shape node's own params; it is not a kind of
|
|
4
|
+
* stored shape, and nothing is decided by it.
|
|
5
|
+
*/
|
|
6
|
+
export type StartingShapeKind = 'cube' | 'rounded-box' | 'hollow-box' | 'cylinder' | 'portal' | 'sphere' | 'frustum' | 'polygon'
|
|
7
|
+
|
|
8
|
+
export function startingShapeKindOf(shape: { op: string; params?: Record<string, unknown> } | undefined): StartingShapeKind | undefined {
|
|
9
|
+
const p = shape?.params ?? {}
|
|
10
|
+
if (shape?.op === 'revolve@1') return p.profile === 'taper' ? 'frustum' : 'sphere'
|
|
11
|
+
if (shape?.op !== 'extrude@1') return undefined
|
|
12
|
+
if (p.section === 'ellipse') return 'cylinder'
|
|
13
|
+
if (p.section === 'polygon') return 'polygon'
|
|
14
|
+
if (p.section === 'portal') return 'portal'
|
|
15
|
+
if (p.cavity) return 'hollow-box'
|
|
16
|
+
return p.corner === 'round' ? 'rounded-box' : 'cube'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The same word for an evaluated body, when only the shape the kernel evaluated is in hand (a row of the parts list):
|
|
21
|
+
* a door frame is the section with jambs, a drawn section the rest of the drawn ones.
|
|
22
|
+
*/
|
|
23
|
+
export function startingShapeKindOfBody(shape: { provider?: string; dimensions?: Record<string, number>; extrusion?: { section: { kind: string; corner?: unknown } }; cavity?: unknown; revolution?: { profile: { kind: string } } } | undefined): StartingShapeKind | undefined {
|
|
24
|
+
if (shape?.revolution) return shape.revolution.profile.kind === 'taper' ? 'frustum' : 'sphere'
|
|
25
|
+
const s = shape?.extrusion?.section
|
|
26
|
+
if (!s) return undefined
|
|
27
|
+
if (s.kind === 'ellipse') return 'cylinder'
|
|
28
|
+
if (s.kind === 'polygon') return shape!.dimensions?.jamb !== undefined ? 'portal' : 'polygon'
|
|
29
|
+
if (shape!.cavity) return 'hollow-box'
|
|
30
|
+
return s.corner ? 'rounded-box' : 'cube'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a body is round across -- a circle or an ellipse pushed, anything turned: it takes a segment count. */
|
|
34
|
+
export const isRoundBody = (shape: Parameters<typeof startingShapeKindOfBody>[0]) => ['cylinder', 'sphere', 'frustum'].includes(startingShapeKindOfBody(shape) ?? '')
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boxShape, discShape } from './drawn-shapes.js';
|
|
1
2
|
/*
|
|
2
3
|
* A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would
|
|
3
4
|
* move, translucent where they would stand; the face they would meet, lit; and in words, how far and how each turns --
|
|
@@ -25,9 +26,10 @@ export function faceHighlightOf(face) {
|
|
|
25
26
|
/* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */
|
|
26
27
|
const r = [0, 1, 2].map(i => [face.u[i], face.normal[i], face.v[i]]);
|
|
27
28
|
const t = [0, 1, 2].map(i => face.centre[i] + face.normal[i] * (LIFT + THICKNESS / 2));
|
|
29
|
+
/* A plane touching a curved side is a line (Q3): drawn as a narrow strip, never a plate. */
|
|
28
30
|
const shape = face.round
|
|
29
|
-
?
|
|
30
|
-
:
|
|
31
|
+
? discShape(face.size.u / 2, THICKNESS)
|
|
32
|
+
: boxShape(Math.max(face.size.u, THICKNESS), THICKNESS, Math.max(face.size.v, THICKNESS));
|
|
31
33
|
/* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */
|
|
32
34
|
return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } };
|
|
33
35
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command-preview.js","sourceRoot":"","sources":["../../client/modeller/command-preview.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FigureBodyMove, FigureClipLoss, FigureFaceArea, FigureRowReading } from '@hatiolab/figure-model'\n\nimport type { FigureRenderPart } from './renderer.js'\n\n/*\n * A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would\n * move, translucent where they would stand; the face they would meet, lit; and in words, how far and how each turns --\n * the turn as figure-model gives it, one axis and one angle in the figure's frame.\n */\n\n/* The material tokens of what a command would do: the bodies where they would stand, and the face they would meet. */\nexport const PREVIEW = '__preview'\nexport const TARGET_FACE = '__target-face'\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\n/** A preview as the editor holds it: bound to the figure, version and command it was worked out for. */\nexport interface HeldPreview {\n context: string\n revision: number\n /** The command, as text: a preview for another command is not this one's. */\n key: string\n geometry?: unknown[]\n moves?: FigureBodyMove[]\n /** Bodies it would draw that are not drawn now: a row's copies. */\n added?: string[]\n /** The row it would make, read on the preview's figure. */\n row?: FigureRowReading\n clipsWouldGo?: FigureClipLoss[]\n face?: FigureFaceArea\n /** Whether a face was asked for and could not be shown. */\n faceUnknown?: boolean\n /** Why the command would be refused; nothing else is shown then. */\n refused?: string\n}\n\n/** The moving bodies where they would stand, drawn translucent. The ones that stay are not drawn twice. */\nexport function ghostPartsOf(preview: HeldPreview | undefined): FigureRenderPart[] {\n if (!preview?.geometry || !(preview.moves?.length || preview.added?.length)) return []\n const moving = new Set([...(preview.moves ?? []).map(m => m.body), ...(preview.added ?? [])])\n return (preview.geometry as FigureRenderPart[])\n .filter(body => body && typeof body === 'object' && 'shape' in body && 'pose' in body && moving.has(body.id))\n .map(body => ({ ...body, id: `${PREVIEW}:${body.id}`, material: { token: PREVIEW, transparent: true } }))\n}\n\n/** How thick the lit face is, and how far off the face it floats, so it neither hides nor flickers with it. */\nconst THICKNESS = 2\nconst LIFT = 1\n\n/** The face the part would meet, lit: a thin plate over a flat face, a thin disc over a round one. */\nexport function faceHighlightOf(face: FigureFaceArea | undefined): FigureRenderPart | null {\n if (!face) return null\n /* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */\n const r = [0, 1, 2].map(i => [face.u[i]!, face.normal[i]!, face.v[i]!])\n const t = [0, 1, 2].map(i => face.centre[i]! + face.normal[i]! * (LIFT + THICKNESS / 2))\n const shape: FigureRenderPart['shape'] = face.round\n ? { provider: 'cylinder', dimensions: { radius: face.size.u / 2, length: THICKNESS } }\n : { provider: 'box', dimensions: { width: face.size.u, height: THICKNESS, depth: face.size.v } }\n /* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */\n return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } }\n}\n\nconst mm = (v: number) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(Math.round(v * 10) / 10).toLocaleString()}`\n\n/**\n * What the command would do, in words: the part's own move and turn, what goes with it, the clips it would stop, and\n * why nothing can be shown when the command would be refused.\n */\nexport function previewWordsOf(preview: HeldPreview | undefined, part: string, say: Say): string[] {\n if (!preview) return []\n if (preview.refused) return [say('figure.text.preview-refused', { reason: preview.refused })]\n /* A row: what it would be, read as drawn; its part does not move, it is copied. */\n if (preview.row) return [...rowWordsOf(preview.row, say), ...(preview.clipsWouldGo?.length ? [say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') })] : [])]\n const words: string[] = []\n const own = preview.moves?.find(m => m.body === part)\n if (!own) words.push(say('figure.text.preview-stays'))\n else {\n const axes = (['x', 'y', 'z'] as const).map((axis, i) => [axis, own.translate[i]!] as const).filter(([, v]) => Math.abs(v) >= 0.05)\n if (axes.length) words.push(say('figure.text.preview-moves', { by: axes.map(([axis, v]) => `${axis.toUpperCase()} ${mm(v)}`).join(' · ') }))\n if (own.turn) {\n /* Signed by the right hand about the axis, as the figure's frame has it. */\n const degrees = mm(own.turn.degrees)\n words.push(\n typeof own.turn.axis === 'string'\n ? say('figure.text.preview-turns-named', { axis: own.turn.axis.toUpperCase(), degrees })\n : say('figure.text.preview-turns-slanted', { axis: own.turn.axis.map(v => v.toFixed(2)).join(', '), degrees })\n )\n }\n }\n /* What goes with it: parts fastened to it, each row once, with its count. */\n const others = new Map<string, number>()\n for (const m of preview.moves ?? []) if (m.part !== part) others.set(m.part, (others.get(m.part) ?? 0) + 1)\n if (others.size)\n words.push(say('figure.text.preview-carries', { parts: [...others].map(([name, n]) => (n > 1 ? say('figure.text.preview-copies', { part: name, n: String(n) }) : name)).join(', ') }))\n if (preview.clipsWouldGo?.length) words.push(say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') }))\n if (preview.faceUnknown) words.push(say('figure.text.preview-face-unknown'))\n return words\n}\n\n/** A row in words, as figure-model reads it from what is drawn: how many, how far apart, what is left at the ends, what rides it. */\nexport function rowWordsOf(row: FigureRowReading | undefined, say: Say): string[] {\n if (!row) return []\n const words = [\n row.pitch !== undefined\n ? say('figure.text.row-count-pitch', { part: row.part, count: String(row.count), pitch: String(row.pitch), axis: row.axis.toUpperCase() })\n : say('figure.text.row-count-uneven', { part: row.part, count: String(row.count), axis: row.axis.toUpperCase() })\n ]\n if (!row.margins) words.push(say('figure.text.row-ends-unknown'))\n else if (row.centred) words.push(say('figure.text.row-ends-centred', { mm: String(row.margins.low) }))\n else words.push(say('figure.text.row-ends', { low: String(row.margins.low), high: String(row.margins.high) }))\n if (row.companions.length) words.push(say('figure.text.row-companions', { parts: row.companions.map(c => say('figure.text.preview-copies', { part: c.part, n: String(c.count) })).join(', ') }))\n return words\n}\n"]}
|
|
1
|
+
{"version":3,"file":"command-preview.js","sourceRoot":"","sources":["../../client/modeller/command-preview.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FigureBodyMove, FigureClipLoss, FigureFaceArea, FigureRowReading } from '@hatiolab/figure-model'\n\nimport type { FigureRenderPart } from './renderer.js'\nimport { boxShape, discShape } from './drawn-shapes.js'\n\n/*\n * A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would\n * move, translucent where they would stand; the face they would meet, lit; and in words, how far and how each turns --\n * the turn as figure-model gives it, one axis and one angle in the figure's frame.\n */\n\n/* The material tokens of what a command would do: the bodies where they would stand, and the face they would meet. */\nexport const PREVIEW = '__preview'\nexport const TARGET_FACE = '__target-face'\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\n/** A preview as the editor holds it: bound to the figure, version and command it was worked out for. */\nexport interface HeldPreview {\n context: string\n revision: number\n /** The command, as text: a preview for another command is not this one's. */\n key: string\n geometry?: unknown[]\n moves?: FigureBodyMove[]\n /** Bodies it would draw that are not drawn now: a row's copies. */\n added?: string[]\n /** The row it would make, read on the preview's figure. */\n row?: FigureRowReading\n clipsWouldGo?: FigureClipLoss[]\n face?: FigureFaceArea\n /** Whether a face was asked for and could not be shown. */\n faceUnknown?: boolean\n /** Why the command would be refused; nothing else is shown then. */\n refused?: string\n}\n\n/** The moving bodies where they would stand, drawn translucent. The ones that stay are not drawn twice. */\nexport function ghostPartsOf(preview: HeldPreview | undefined): FigureRenderPart[] {\n if (!preview?.geometry || !(preview.moves?.length || preview.added?.length)) return []\n const moving = new Set([...(preview.moves ?? []).map(m => m.body), ...(preview.added ?? [])])\n return (preview.geometry as FigureRenderPart[])\n .filter(body => body && typeof body === 'object' && 'shape' in body && 'pose' in body && moving.has(body.id))\n .map(body => ({ ...body, id: `${PREVIEW}:${body.id}`, material: { token: PREVIEW, transparent: true } }))\n}\n\n/** How thick the lit face is, and how far off the face it floats, so it neither hides nor flickers with it. */\nconst THICKNESS = 2\nconst LIFT = 1\n\n/** The face the part would meet, lit: a thin plate over a flat face, a thin disc over a round one. */\nexport function faceHighlightOf(face: FigureFaceArea | undefined): FigureRenderPart | null {\n if (!face) return null\n /* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */\n const r = [0, 1, 2].map(i => [face.u[i]!, face.normal[i]!, face.v[i]!])\n const t = [0, 1, 2].map(i => face.centre[i]! + face.normal[i]! * (LIFT + THICKNESS / 2))\n /* A plane touching a curved side is a line (Q3): drawn as a narrow strip, never a plate. */\n const shape: FigureRenderPart['shape'] = face.round\n ? discShape(face.size.u / 2, THICKNESS)\n : boxShape(Math.max(face.size.u, THICKNESS), THICKNESS, Math.max(face.size.v, THICKNESS))\n /* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */\n return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } }\n}\n\nconst mm = (v: number) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(Math.round(v * 10) / 10).toLocaleString()}`\n\n/**\n * What the command would do, in words: the part's own move and turn, what goes with it, the clips it would stop, and\n * why nothing can be shown when the command would be refused.\n */\nexport function previewWordsOf(preview: HeldPreview | undefined, part: string, say: Say): string[] {\n if (!preview) return []\n if (preview.refused) return [say('figure.text.preview-refused', { reason: preview.refused })]\n /* A row: what it would be, read as drawn; its part does not move, it is copied. */\n if (preview.row) return [...rowWordsOf(preview.row, say), ...(preview.clipsWouldGo?.length ? [say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') })] : [])]\n const words: string[] = []\n const own = preview.moves?.find(m => m.body === part)\n if (!own) words.push(say('figure.text.preview-stays'))\n else {\n const axes = (['x', 'y', 'z'] as const).map((axis, i) => [axis, own.translate[i]!] as const).filter(([, v]) => Math.abs(v) >= 0.05)\n if (axes.length) words.push(say('figure.text.preview-moves', { by: axes.map(([axis, v]) => `${axis.toUpperCase()} ${mm(v)}`).join(' · ') }))\n if (own.turn) {\n /* Signed by the right hand about the axis, as the figure's frame has it. */\n const degrees = mm(own.turn.degrees)\n words.push(\n typeof own.turn.axis === 'string'\n ? say('figure.text.preview-turns-named', { axis: own.turn.axis.toUpperCase(), degrees })\n : say('figure.text.preview-turns-slanted', { axis: own.turn.axis.map(v => v.toFixed(2)).join(', '), degrees })\n )\n }\n }\n /* What goes with it: parts fastened to it, each row once, with its count. */\n const others = new Map<string, number>()\n for (const m of preview.moves ?? []) if (m.part !== part) others.set(m.part, (others.get(m.part) ?? 0) + 1)\n if (others.size)\n words.push(say('figure.text.preview-carries', { parts: [...others].map(([name, n]) => (n > 1 ? say('figure.text.preview-copies', { part: name, n: String(n) }) : name)).join(', ') }))\n if (preview.clipsWouldGo?.length) words.push(say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') }))\n if (preview.faceUnknown) words.push(say('figure.text.preview-face-unknown'))\n return words\n}\n\n/** A row in words, as figure-model reads it from what is drawn: how many, how far apart, what is left at the ends, what rides it. */\nexport function rowWordsOf(row: FigureRowReading | undefined, say: Say): string[] {\n if (!row) return []\n const words = [\n row.pitch !== undefined\n ? say('figure.text.row-count-pitch', { part: row.part, count: String(row.count), pitch: String(row.pitch), axis: row.axis.toUpperCase() })\n : say('figure.text.row-count-uneven', { part: row.part, count: String(row.count), axis: row.axis.toUpperCase() })\n ]\n if (!row.margins) words.push(say('figure.text.row-ends-unknown'))\n else if (row.centred) words.push(say('figure.text.row-ends-centred', { mm: String(row.margins.low) }))\n else words.push(say('figure.text.row-ends', { low: String(row.margins.low), high: String(row.margins.high) }))\n if (row.companions.length) words.push(say('figure.text.row-companions', { parts: row.companions.map(c => say('figure.text.preview-copies', { part: c.part, n: String(c.count) })).join(', ') }))\n return words\n}\n"]}
|
|
@@ -0,0 +1,44 @@
|
|
|
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: {
|
|
9
|
+
axis: 'y';
|
|
10
|
+
section: DrawnSection;
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
type DrawnSection = {
|
|
16
|
+
kind: 'rectangle';
|
|
17
|
+
size: {
|
|
18
|
+
x: number;
|
|
19
|
+
z: number;
|
|
20
|
+
};
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'ellipse';
|
|
23
|
+
radius: {
|
|
24
|
+
x: number;
|
|
25
|
+
z: number;
|
|
26
|
+
};
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'polygon';
|
|
29
|
+
points: Array<{
|
|
30
|
+
id: string;
|
|
31
|
+
x: number;
|
|
32
|
+
z: number;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
/** A box: width along X, height along Y, depth along Z. */
|
|
36
|
+
export declare const boxShape: (width: number, height: number, depth: number) => DrawnShape;
|
|
37
|
+
/** A disc of a radius, `height` thick along Y. */
|
|
38
|
+
export declare const discShape: (radius: number, height: number) => DrawnShape;
|
|
39
|
+
/** A section in X-Z, `height` thick along Y. */
|
|
40
|
+
export declare const sectionShape: (points: ReadonlyArray<{
|
|
41
|
+
x: number;
|
|
42
|
+
z: number;
|
|
43
|
+
}>, height: number) => DrawnShape;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const centred = (section, height) => ({
|
|
2
|
+
provider: 'extrude',
|
|
3
|
+
dimensions: { length: height },
|
|
4
|
+
extrusion: { axis: 'y', section, start: -height / 2, end: height / 2 }
|
|
5
|
+
});
|
|
6
|
+
/** A box: width along X, height along Y, depth along Z. */
|
|
7
|
+
export const boxShape = (width, height, depth) => centred({ kind: 'rectangle', size: { x: width, z: depth } }, height);
|
|
8
|
+
/** A disc of a radius, `height` thick along Y. */
|
|
9
|
+
export const discShape = (radius, height) => centred({ kind: 'ellipse', radius: { x: radius, z: radius } }, height);
|
|
10
|
+
/** A section in X-Z, `height` thick along Y. */
|
|
11
|
+
export const sectionShape = (points, height) => centred({ kind: 'polygon', points: points.map((p, i) => ({ id: `p${i + 1}`, x: p.x, z: p.z })) }, height);
|
|
12
|
+
//# sourceMappingURL=drawn-shapes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"drawn-shapes.js","sourceRoot":"","sources":["../../client/modeller/drawn-shapes.ts"],"names":[],"mappings":"AAcA,MAAM,OAAO,GAAG,CAAC,OAAqB,EAAE,MAAc,EAAc,EAAE,CAAC,CAAC;IACtE,QAAQ,EAAE,SAAS;IACnB,UAAU,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE;IAC9B,SAAS,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,MAAM,GAAG,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,EAAE;CACvE,CAAC,CAAA;AAEF,2DAA2D;AAC3D,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAa,EAAE,MAAc,EAAE,KAAa,EAAc,EAAE,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,CAAC,CAAA;AAE1J,kDAAkD;AAClD,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,MAAc,EAAE,MAAc,EAAc,EAAE,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,CAAA;AAE/I,gDAAgD;AAChD,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,MAA+C,EAAE,MAAc,EAAc,EAAE,CAC1G,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,MAAM,CAAC,CAAA","sourcesContent":["/**\n * Shapes the modeller draws of its own -- a lit face, a joint's fan, a slide's line -- written as the shapes figure-model\n * evaluates (ADR-0100): a section pushed along Y, centred. things-scene's one builder draws them as it draws a part.\n */\nexport interface DrawnShape {\n provider: 'extrude'\n dimensions: Record<string, number>\n extrusion: { axis: 'y'; section: DrawnSection; start: number; end: number }\n}\ntype DrawnSection =\n | { kind: 'rectangle'; size: { x: number; z: number } }\n | { kind: 'ellipse'; radius: { x: number; z: number } }\n | { kind: 'polygon'; points: Array<{ id: string; x: number; z: number }> }\n\nconst centred = (section: DrawnSection, height: number): DrawnShape => ({\n provider: 'extrude',\n dimensions: { length: height },\n extrusion: { axis: 'y', section, start: -height / 2, end: height / 2 }\n})\n\n/** A box: width along X, height along Y, depth along Z. */\nexport const boxShape = (width: number, height: number, depth: number): DrawnShape => centred({ kind: 'rectangle', size: { x: width, z: depth } }, height)\n\n/** A disc of a radius, `height` thick along Y. */\nexport const discShape = (radius: number, height: number): DrawnShape => centred({ kind: 'ellipse', radius: { x: radius, z: radius } }, height)\n\n/** A section in X-Z, `height` thick along Y. */\nexport const sectionShape = (points: ReadonlyArray<{ x: number; z: number }>, height: number): DrawnShape =>\n centred({ kind: 'polygon', points: points.map((p, i) => ({ id: `p${i + 1}`, x: p.x, z: p.z })) }, height)\n"]}
|
|
@@ -6,6 +6,7 @@ import type { FigureProductSession } from './product-session.js';
|
|
|
6
6
|
import './canvas.js';
|
|
7
7
|
import './figure-parts.js';
|
|
8
8
|
import './figure-material-panel.js';
|
|
9
|
+
import './figure-section-panel.js';
|
|
9
10
|
import './figure-links.js';
|
|
10
11
|
import './figure-controls.js';
|
|
11
12
|
import './figure-clips.js';
|
|
@@ -133,6 +134,8 @@ export declare class FigureEditor extends LitElement {
|
|
|
133
134
|
private currentInputs;
|
|
134
135
|
/** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
|
|
135
136
|
private hold;
|
|
137
|
+
/** A command in hand, drawn and not run; `null` lets go of it and the figure as it stands is drawn again. */
|
|
138
|
+
private holdAction;
|
|
136
139
|
private changed;
|
|
137
140
|
private save;
|
|
138
141
|
private add;
|