@pascal-app/editor 1.0.0-beta.4 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +63 -8
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +12 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +23 -6
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
|
3
|
+
import { HalfEdgeMap } from 'three-bvh-csg'
|
|
4
|
+
|
|
5
|
+
const MILLIMETERS_PER_METER = 1000
|
|
6
|
+
const EDGE_CONNECTIVITY_EPSILON_METERS = 1e-5
|
|
7
|
+
const EDGE_INCIDENCE_EPSILON_METERS = 1e-7
|
|
8
|
+
const MAX_EDGE_CHECK_TRIANGLES = 500_000
|
|
9
|
+
const DEGENERATE_CROSS_LENGTH_SQ = 1e-12
|
|
10
|
+
|
|
11
|
+
const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
|
|
12
|
+
EMPTY_POSITION_GEOMETRY.setAttribute(
|
|
13
|
+
'position',
|
|
14
|
+
new THREE.Float32BufferAttribute(new Float32Array(0), 3),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
export type PrintExportDiagnostic = {
|
|
18
|
+
severity: 'error' | 'warning' | 'info'
|
|
19
|
+
code: string
|
|
20
|
+
message: string
|
|
21
|
+
nodeIds?: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PrintArtifactFormat = 'stl' | '3mf'
|
|
25
|
+
|
|
26
|
+
export type PrintExportOptions = {
|
|
27
|
+
scale: number
|
|
28
|
+
compiled?: boolean
|
|
29
|
+
indexedTopology?: boolean
|
|
30
|
+
format?: PrintArtifactFormat
|
|
31
|
+
/** Original Y-up world elevation that becomes print Z=0. Omit to use geometry minimum. */
|
|
32
|
+
sourceBedElevationMeters?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PrintExportBounds = {
|
|
36
|
+
min: { x: number; y: number; z: number }
|
|
37
|
+
max: { x: number; y: number; z: number }
|
|
38
|
+
width: number
|
|
39
|
+
depth: number
|
|
40
|
+
height: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type PrintExportReport = {
|
|
44
|
+
kind: 'print-export-report'
|
|
45
|
+
version: 2
|
|
46
|
+
format: PrintArtifactFormat
|
|
47
|
+
scale: number
|
|
48
|
+
units: 'millimeter'
|
|
49
|
+
orientation: 'z-up'
|
|
50
|
+
status: 'pass' | 'warning' | 'blocked'
|
|
51
|
+
bounds: PrintExportBounds | null
|
|
52
|
+
triangleCount: number
|
|
53
|
+
invalidTriangleCount: number
|
|
54
|
+
degenerateTriangleCount: number
|
|
55
|
+
boundaryEdgeCount: number | null
|
|
56
|
+
nonManifoldEdgeCount: number | null
|
|
57
|
+
connectedComponentCount: number | null
|
|
58
|
+
solidComponentCount: number | null
|
|
59
|
+
invertedWinding: boolean | null
|
|
60
|
+
volumeMm3: number
|
|
61
|
+
minimumFeatureThicknessMm?: number | null
|
|
62
|
+
diagnostics: PrintExportDiagnostic[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type PrintStlExport = {
|
|
66
|
+
buffer: ArrayBuffer
|
|
67
|
+
report: PrintExportReport
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type PrintMeshData = {
|
|
71
|
+
positions: Float64Array<ArrayBuffer>
|
|
72
|
+
indices: Uint32Array<ArrayBuffer>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type BoundsMeasurement = {
|
|
76
|
+
min: THREE.Vector3
|
|
77
|
+
max: THREE.Vector3
|
|
78
|
+
} | null
|
|
79
|
+
|
|
80
|
+
type EdgeTopologyMeasurement = {
|
|
81
|
+
boundaryEdgeCount: number | null
|
|
82
|
+
nonManifoldEdgeCount: number | null
|
|
83
|
+
connectedComponentCount: number | null
|
|
84
|
+
componentIndexByTriangle: Int32Array<ArrayBuffer> | null
|
|
85
|
+
edgeCheckComplete: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ensureMeshPositions(root: THREE.Object3D) {
|
|
89
|
+
root.traverse((object) => {
|
|
90
|
+
const mesh = object as THREE.Mesh
|
|
91
|
+
if (mesh.isMesh && !mesh.geometry?.getAttribute('position')) {
|
|
92
|
+
mesh.geometry = EMPTY_POSITION_GEOMETRY
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isFiniteVector(vector: THREE.Vector3): boolean {
|
|
98
|
+
return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function forEachTriangle(
|
|
102
|
+
root: THREE.Object3D,
|
|
103
|
+
visit: (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3) => void,
|
|
104
|
+
) {
|
|
105
|
+
root.updateMatrixWorld(true)
|
|
106
|
+
|
|
107
|
+
const a = new THREE.Vector3()
|
|
108
|
+
const b = new THREE.Vector3()
|
|
109
|
+
const c = new THREE.Vector3()
|
|
110
|
+
|
|
111
|
+
root.traverse((object) => {
|
|
112
|
+
const mesh = object as THREE.Mesh
|
|
113
|
+
if (!mesh.isMesh) return
|
|
114
|
+
|
|
115
|
+
const position = mesh.geometry.getAttribute('position')
|
|
116
|
+
if (!position) return
|
|
117
|
+
const index = mesh.geometry.index
|
|
118
|
+
const skinnedMesh = mesh as THREE.SkinnedMesh
|
|
119
|
+
|
|
120
|
+
const readVertex = (vertexIndex: number, target: THREE.Vector3) => {
|
|
121
|
+
target.fromBufferAttribute(position, vertexIndex)
|
|
122
|
+
if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, target)
|
|
123
|
+
target.applyMatrix4(mesh.matrixWorld)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const visitIndices = (indexA: number, indexB: number, indexC: number) => {
|
|
127
|
+
readVertex(indexA, a)
|
|
128
|
+
readVertex(indexB, b)
|
|
129
|
+
readVertex(indexC, c)
|
|
130
|
+
visit(a, b, c)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (index) {
|
|
134
|
+
for (let offset = 0; offset + 2 < index.count; offset += 3) {
|
|
135
|
+
visitIndices(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2))
|
|
136
|
+
}
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let offset = 0; offset + 2 < position.count; offset += 3) {
|
|
141
|
+
visitIndices(offset, offset + 1, offset + 2)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function measureBounds(root: THREE.Object3D): BoundsMeasurement {
|
|
147
|
+
const min = new THREE.Vector3(
|
|
148
|
+
Number.POSITIVE_INFINITY,
|
|
149
|
+
Number.POSITIVE_INFINITY,
|
|
150
|
+
Number.POSITIVE_INFINITY,
|
|
151
|
+
)
|
|
152
|
+
const max = new THREE.Vector3(
|
|
153
|
+
Number.NEGATIVE_INFINITY,
|
|
154
|
+
Number.NEGATIVE_INFINITY,
|
|
155
|
+
Number.NEGATIVE_INFINITY,
|
|
156
|
+
)
|
|
157
|
+
let hasFiniteTriangle = false
|
|
158
|
+
|
|
159
|
+
forEachTriangle(root, (a, b, c) => {
|
|
160
|
+
if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return
|
|
161
|
+
min.min(a).min(b).min(c)
|
|
162
|
+
max.max(a).max(b).max(c)
|
|
163
|
+
hasFiniteTriangle = true
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return hasFiniteTriangle ? { min, max } : null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pointKey(point: THREE.Vector3): string {
|
|
170
|
+
return `${Math.round(point.x / EDGE_INCIDENCE_EPSILON_METERS)},${Math.round(
|
|
171
|
+
point.y / EDGE_INCIDENCE_EPSILON_METERS,
|
|
172
|
+
)},${Math.round(point.z / EDGE_INCIDENCE_EPSILON_METERS)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function edgeKey(a: THREE.Vector3, b: THREE.Vector3): string {
|
|
176
|
+
const keyA = pointKey(a)
|
|
177
|
+
const keyB = pointKey(b)
|
|
178
|
+
return keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function indexedNonManifoldEdgeCount(root: THREE.Object3D): number | null {
|
|
182
|
+
let hasGeometry = false
|
|
183
|
+
let nonManifoldEdgeCount = 0
|
|
184
|
+
for (const object of root.children) object.updateMatrixWorld(true)
|
|
185
|
+
|
|
186
|
+
root.traverse((object) => {
|
|
187
|
+
const mesh = object as THREE.Mesh
|
|
188
|
+
if (!mesh.isMesh) return
|
|
189
|
+
const position = mesh.geometry.getAttribute('position')
|
|
190
|
+
if (!position || position.count === 0) return
|
|
191
|
+
const index = mesh.geometry.getIndex()
|
|
192
|
+
if (!index) {
|
|
193
|
+
nonManifoldEdgeCount = Number.NaN
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
hasGeometry = true
|
|
197
|
+
const edges = new Map<string, number>()
|
|
198
|
+
const add = (a: number, b: number) => {
|
|
199
|
+
const key = a < b ? `${a}|${b}` : `${b}|${a}`
|
|
200
|
+
edges.set(key, (edges.get(key) ?? 0) + 1)
|
|
201
|
+
}
|
|
202
|
+
for (let offset = 0; offset + 2 < index.count; offset += 3) {
|
|
203
|
+
const a = index.getX(offset)
|
|
204
|
+
const b = index.getX(offset + 1)
|
|
205
|
+
const c = index.getX(offset + 2)
|
|
206
|
+
add(a, b)
|
|
207
|
+
add(b, c)
|
|
208
|
+
add(c, a)
|
|
209
|
+
}
|
|
210
|
+
for (const uses of edges.values()) {
|
|
211
|
+
if (uses > 2) nonManifoldEdgeCount += 1
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return hasGeometry && Number.isFinite(nonManifoldEdgeCount) ? nonManifoldEdgeCount : null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function analyzeEdgeTopology(
|
|
219
|
+
root: THREE.Object3D,
|
|
220
|
+
useIndexedIncidence: boolean,
|
|
221
|
+
): EdgeTopologyMeasurement {
|
|
222
|
+
const edges = new Map<string, number>()
|
|
223
|
+
const halfEdgePositions: number[] = []
|
|
224
|
+
const topologyTriangleIndices: number[] = []
|
|
225
|
+
const componentParents: number[] = []
|
|
226
|
+
const componentRanks: number[] = []
|
|
227
|
+
let edgeCheckComplete = true
|
|
228
|
+
let triangleCount = 0
|
|
229
|
+
|
|
230
|
+
const findComponent = (triangleIndex: number): number => {
|
|
231
|
+
let root = triangleIndex
|
|
232
|
+
while (componentParents[root] !== root) root = componentParents[root]!
|
|
233
|
+
let current = triangleIndex
|
|
234
|
+
while (componentParents[current] !== root) {
|
|
235
|
+
const parent = componentParents[current]!
|
|
236
|
+
componentParents[current] = root
|
|
237
|
+
current = parent
|
|
238
|
+
}
|
|
239
|
+
return root
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const unionComponents = (first: number, second: number) => {
|
|
243
|
+
let firstRoot = findComponent(first)
|
|
244
|
+
let secondRoot = findComponent(second)
|
|
245
|
+
if (firstRoot === secondRoot) return
|
|
246
|
+
const firstRank = componentRanks[firstRoot] ?? 0
|
|
247
|
+
const secondRank = componentRanks[secondRoot] ?? 0
|
|
248
|
+
if (firstRank < secondRank) [firstRoot, secondRoot] = [secondRoot, firstRoot]
|
|
249
|
+
componentParents[secondRoot] = firstRoot
|
|
250
|
+
if (firstRank === secondRank) componentRanks[firstRoot] = firstRank + 1
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const addConnectedEdge = (a: THREE.Vector3, b: THREE.Vector3, triangleIndex: number) => {
|
|
254
|
+
const key = edgeKey(a, b)
|
|
255
|
+
const encoded = edges.get(key)
|
|
256
|
+
if (encoded === undefined) {
|
|
257
|
+
edges.set(key, triangleIndex * 4 + 1)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const firstTriangle = Math.floor(encoded / 4)
|
|
261
|
+
const uses = encoded % 4
|
|
262
|
+
unionComponents(firstTriangle, triangleIndex)
|
|
263
|
+
edges.set(key, firstTriangle * 4 + Math.min(uses + 1, 3))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
forEachTriangle(root, (a, b, c) => {
|
|
267
|
+
const triangleIndex = triangleCount
|
|
268
|
+
triangleCount += 1
|
|
269
|
+
if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return
|
|
270
|
+
if (edgeCheckComplete && triangleCount > MAX_EDGE_CHECK_TRIANGLES) {
|
|
271
|
+
edges.clear()
|
|
272
|
+
halfEdgePositions.length = 0
|
|
273
|
+
topologyTriangleIndices.length = 0
|
|
274
|
+
componentParents.length = 0
|
|
275
|
+
componentRanks.length = 0
|
|
276
|
+
edgeCheckComplete = false
|
|
277
|
+
}
|
|
278
|
+
if (!edgeCheckComplete) return
|
|
279
|
+
|
|
280
|
+
componentParents[triangleIndex] = triangleIndex
|
|
281
|
+
componentRanks[triangleIndex] = 0
|
|
282
|
+
addConnectedEdge(a, b, triangleIndex)
|
|
283
|
+
addConnectedEdge(b, c, triangleIndex)
|
|
284
|
+
addConnectedEdge(c, a, triangleIndex)
|
|
285
|
+
topologyTriangleIndices.push(triangleIndex)
|
|
286
|
+
halfEdgePositions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if (!edgeCheckComplete) {
|
|
290
|
+
return {
|
|
291
|
+
boundaryEdgeCount: null,
|
|
292
|
+
nonManifoldEdgeCount: null,
|
|
293
|
+
connectedComponentCount: null,
|
|
294
|
+
componentIndexByTriangle: null,
|
|
295
|
+
edgeCheckComplete,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const connectivityGeometry = new THREE.BufferGeometry()
|
|
300
|
+
connectivityGeometry.setAttribute(
|
|
301
|
+
'position',
|
|
302
|
+
new THREE.BufferAttribute(new Float64Array(halfEdgePositions), 3),
|
|
303
|
+
)
|
|
304
|
+
const halfEdges = new HalfEdgeMap() as HalfEdgeMap & {
|
|
305
|
+
data: Int32Array<ArrayBuffer>
|
|
306
|
+
disjointConnections: Map<number, number[]> | null
|
|
307
|
+
matchDisjointEdges: boolean
|
|
308
|
+
degenerateEpsilon: number
|
|
309
|
+
unmatchedEdges: number
|
|
310
|
+
}
|
|
311
|
+
halfEdges.matchDisjointEdges = true
|
|
312
|
+
halfEdges.degenerateEpsilon = EDGE_CONNECTIVITY_EPSILON_METERS
|
|
313
|
+
halfEdges.updateFrom(connectivityGeometry)
|
|
314
|
+
const boundaryEdgeCount = halfEdges.unmatchedEdges
|
|
315
|
+
for (let localEdgeIndex = 0; localEdgeIndex < halfEdges.data.length; localEdgeIndex += 1) {
|
|
316
|
+
const triangleIndex = topologyTriangleIndices[Math.floor(localEdgeIndex / 3)]
|
|
317
|
+
if (triangleIndex === undefined) continue
|
|
318
|
+
const siblingEdgeIndex = halfEdges.data[localEdgeIndex] ?? -1
|
|
319
|
+
if (siblingEdgeIndex >= 0) {
|
|
320
|
+
const siblingTriangleIndex = topologyTriangleIndices[Math.floor(siblingEdgeIndex / 3)]
|
|
321
|
+
if (siblingTriangleIndex !== undefined) unionComponents(triangleIndex, siblingTriangleIndex)
|
|
322
|
+
}
|
|
323
|
+
for (const disjointEdgeIndex of halfEdges.disjointConnections?.get(localEdgeIndex) ?? []) {
|
|
324
|
+
const disjointTriangleIndex = topologyTriangleIndices[Math.floor(disjointEdgeIndex / 3)]
|
|
325
|
+
if (disjointTriangleIndex !== undefined) unionComponents(triangleIndex, disjointTriangleIndex)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
connectivityGeometry.dispose()
|
|
329
|
+
|
|
330
|
+
const componentIndexByTriangle = new Int32Array(triangleCount)
|
|
331
|
+
componentIndexByTriangle.fill(-1)
|
|
332
|
+
const componentIndexByRoot = new Map<number, number>()
|
|
333
|
+
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
|
|
334
|
+
if (componentParents[triangleIndex] === undefined) continue
|
|
335
|
+
const root = findComponent(triangleIndex)
|
|
336
|
+
let componentIndex = componentIndexByRoot.get(root)
|
|
337
|
+
if (componentIndex === undefined) {
|
|
338
|
+
componentIndex = componentIndexByRoot.size
|
|
339
|
+
componentIndexByRoot.set(root, componentIndex)
|
|
340
|
+
}
|
|
341
|
+
componentIndexByTriangle[triangleIndex] = componentIndex
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let nonManifoldEdgeCount = useIndexedIncidence ? indexedNonManifoldEdgeCount(root) : null
|
|
345
|
+
if (nonManifoldEdgeCount === null) {
|
|
346
|
+
nonManifoldEdgeCount = 0
|
|
347
|
+
for (const encoded of edges.values()) {
|
|
348
|
+
if (encoded % 4 > 2) nonManifoldEdgeCount += 1
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
boundaryEdgeCount,
|
|
354
|
+
nonManifoldEdgeCount,
|
|
355
|
+
connectedComponentCount: componentIndexByRoot.size,
|
|
356
|
+
componentIndexByTriangle,
|
|
357
|
+
edgeCheckComplete,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function analyzePrintScene(
|
|
362
|
+
root: THREE.Object3D,
|
|
363
|
+
scale: number,
|
|
364
|
+
edgeTopology: EdgeTopologyMeasurement,
|
|
365
|
+
compiled: boolean,
|
|
366
|
+
format: PrintArtifactFormat,
|
|
367
|
+
): PrintExportReport {
|
|
368
|
+
const min = new THREE.Vector3(
|
|
369
|
+
Number.POSITIVE_INFINITY,
|
|
370
|
+
Number.POSITIVE_INFINITY,
|
|
371
|
+
Number.POSITIVE_INFINITY,
|
|
372
|
+
)
|
|
373
|
+
const max = new THREE.Vector3(
|
|
374
|
+
Number.NEGATIVE_INFINITY,
|
|
375
|
+
Number.NEGATIVE_INFINITY,
|
|
376
|
+
Number.NEGATIVE_INFINITY,
|
|
377
|
+
)
|
|
378
|
+
const ab = new THREE.Vector3()
|
|
379
|
+
const ac = new THREE.Vector3()
|
|
380
|
+
const areaCross = new THREE.Vector3()
|
|
381
|
+
const volumeCross = new THREE.Vector3()
|
|
382
|
+
let triangleCount = 0
|
|
383
|
+
let invalidTriangleCount = 0
|
|
384
|
+
let degenerateTriangleCount = 0
|
|
385
|
+
let signedVolumeMm3 = 0
|
|
386
|
+
let hasFiniteTriangle = false
|
|
387
|
+
const componentSignedVolumesMm3 =
|
|
388
|
+
edgeTopology.connectedComponentCount === null
|
|
389
|
+
? null
|
|
390
|
+
: Array.from({ length: edgeTopology.connectedComponentCount }, () => 0)
|
|
391
|
+
|
|
392
|
+
forEachTriangle(root, (a, b, c) => {
|
|
393
|
+
const triangleIndex = triangleCount
|
|
394
|
+
triangleCount += 1
|
|
395
|
+
if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) {
|
|
396
|
+
invalidTriangleCount += 1
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
min.min(a).min(b).min(c)
|
|
401
|
+
max.max(a).max(b).max(c)
|
|
402
|
+
hasFiniteTriangle = true
|
|
403
|
+
|
|
404
|
+
ab.subVectors(b, a)
|
|
405
|
+
ac.subVectors(c, a)
|
|
406
|
+
areaCross.crossVectors(ab, ac)
|
|
407
|
+
if (areaCross.lengthSq() <= DEGENERATE_CROSS_LENGTH_SQ) {
|
|
408
|
+
degenerateTriangleCount += 1
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
volumeCross.crossVectors(b, c)
|
|
412
|
+
const triangleVolumeMm3 = a.dot(volumeCross) / 6
|
|
413
|
+
signedVolumeMm3 += triangleVolumeMm3
|
|
414
|
+
const componentIndex = edgeTopology.componentIndexByTriangle?.[triangleIndex] ?? -1
|
|
415
|
+
if (componentSignedVolumesMm3 && componentIndex >= 0) {
|
|
416
|
+
componentSignedVolumesMm3[componentIndex] =
|
|
417
|
+
(componentSignedVolumesMm3[componentIndex] ?? 0) + triangleVolumeMm3
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
const { boundaryEdgeCount, nonManifoldEdgeCount, connectedComponentCount, edgeCheckComplete } =
|
|
422
|
+
edgeTopology
|
|
423
|
+
const hasClosedTopology =
|
|
424
|
+
edgeCheckComplete &&
|
|
425
|
+
boundaryEdgeCount === 0 &&
|
|
426
|
+
nonManifoldEdgeCount === 0 &&
|
|
427
|
+
invalidTriangleCount === 0 &&
|
|
428
|
+
degenerateTriangleCount === 0
|
|
429
|
+
const solidComponentCount =
|
|
430
|
+
hasClosedTopology && componentSignedVolumesMm3
|
|
431
|
+
? componentSignedVolumesMm3.filter((volume) => volume > 1e-6).length
|
|
432
|
+
: null
|
|
433
|
+
const inwardComponentCount =
|
|
434
|
+
hasClosedTopology && componentSignedVolumesMm3
|
|
435
|
+
? componentSignedVolumesMm3.filter((volume) => volume < -1e-6).length
|
|
436
|
+
: null
|
|
437
|
+
const invertedWinding = hasClosedTopology && triangleCount > 0 ? signedVolumeMm3 < -1e-6 : null
|
|
438
|
+
|
|
439
|
+
const bounds = hasFiniteTriangle
|
|
440
|
+
? {
|
|
441
|
+
min: { x: min.x, y: min.y, z: min.z },
|
|
442
|
+
max: { x: max.x, y: max.y, z: max.z },
|
|
443
|
+
width: max.x - min.x,
|
|
444
|
+
depth: max.y - min.y,
|
|
445
|
+
height: max.z - min.z,
|
|
446
|
+
}
|
|
447
|
+
: null
|
|
448
|
+
|
|
449
|
+
const diagnostics: PrintExportDiagnostic[] = []
|
|
450
|
+
if (triangleCount === 0) {
|
|
451
|
+
diagnostics.push({
|
|
452
|
+
severity: 'error',
|
|
453
|
+
code: 'no_triangles',
|
|
454
|
+
message: 'No printable triangles remain after applying the export scope.',
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
if (invalidTriangleCount > 0) {
|
|
458
|
+
diagnostics.push({
|
|
459
|
+
severity: 'error',
|
|
460
|
+
code: 'non_finite_geometry',
|
|
461
|
+
message: `${invalidTriangleCount.toLocaleString()} triangle${
|
|
462
|
+
invalidTriangleCount === 1 ? '' : 's'
|
|
463
|
+
} contain non-finite coordinates.`,
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
if (degenerateTriangleCount > 0) {
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
severity: 'error',
|
|
469
|
+
code: 'degenerate_triangles',
|
|
470
|
+
message: `${degenerateTriangleCount.toLocaleString()} zero-area or near-zero-area triangle${
|
|
471
|
+
degenerateTriangleCount === 1 ? '' : 's'
|
|
472
|
+
} prevent a print-ready artifact.`,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
if (boundaryEdgeCount && boundaryEdgeCount > 0) {
|
|
476
|
+
diagnostics.push({
|
|
477
|
+
severity: 'error',
|
|
478
|
+
code: 'open_boundaries',
|
|
479
|
+
message: `${boundaryEdgeCount.toLocaleString()} boundary edge${
|
|
480
|
+
boundaryEdgeCount === 1 ? '' : 's'
|
|
481
|
+
} leave the exported surface open.`,
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
if (nonManifoldEdgeCount && nonManifoldEdgeCount > 0) {
|
|
485
|
+
diagnostics.push({
|
|
486
|
+
severity: 'error',
|
|
487
|
+
code: 'non_manifold_edges',
|
|
488
|
+
message: `${nonManifoldEdgeCount.toLocaleString()} edge${
|
|
489
|
+
nonManifoldEdgeCount === 1 ? '' : 's'
|
|
490
|
+
} are shared by more than two triangles and must be repaired.`,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
if (!edgeCheckComplete) {
|
|
494
|
+
diagnostics.push({
|
|
495
|
+
severity: 'warning',
|
|
496
|
+
code: 'edge_check_skipped',
|
|
497
|
+
message: `Edge and connected-component checks were skipped above ${MAX_EDGE_CHECK_TRIANGLES.toLocaleString()} triangles.`,
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
if (solidComponentCount !== null && solidComponentCount > 1) {
|
|
501
|
+
diagnostics.push({
|
|
502
|
+
severity: compiled ? 'error' : 'warning',
|
|
503
|
+
code: 'disconnected_solids',
|
|
504
|
+
message: `${solidComponentCount.toLocaleString()} disconnected outward solid components remain in this ${compiled ? 'compiled part' : 'export'}. ${
|
|
505
|
+
compiled
|
|
506
|
+
? 'Each printable level must be one physically connected solid.'
|
|
507
|
+
: 'Use structure compilation or split them into separate printable parts.'
|
|
508
|
+
}`,
|
|
509
|
+
})
|
|
510
|
+
} else if (
|
|
511
|
+
connectedComponentCount !== null &&
|
|
512
|
+
connectedComponentCount > 1 &&
|
|
513
|
+
inwardComponentCount !== null &&
|
|
514
|
+
inwardComponentCount > 0
|
|
515
|
+
) {
|
|
516
|
+
diagnostics.push({
|
|
517
|
+
severity: 'warning',
|
|
518
|
+
code: 'inward_surface_components',
|
|
519
|
+
message: `${connectedComponentCount.toLocaleString()} connected surface shells include ${inwardComponentCount.toLocaleString()} inward-oriented shell${
|
|
520
|
+
inwardComponentCount === 1 ? '' : 's'
|
|
521
|
+
}. These may be sealed cavities; inspect the sliced layers before printing.`,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
if (invertedWinding) {
|
|
525
|
+
diagnostics.push({
|
|
526
|
+
severity: 'error',
|
|
527
|
+
code: 'inverted_winding',
|
|
528
|
+
message: 'The closed surface has globally inverted face winding and must be reoriented.',
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
if (triangleCount > 0 && Math.abs(signedVolumeMm3) <= 1e-6) {
|
|
532
|
+
diagnostics.push({
|
|
533
|
+
severity: 'error',
|
|
534
|
+
code: 'zero_volume',
|
|
535
|
+
message: 'The exported surfaces enclose no measurable signed volume.',
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
diagnostics.push(
|
|
539
|
+
compiled
|
|
540
|
+
? {
|
|
541
|
+
severity: 'info',
|
|
542
|
+
code: 'compiler_limits',
|
|
543
|
+
message:
|
|
544
|
+
'The shell was boolean-unioned, but self-intersections and minimum wall thickness are not checked yet.',
|
|
545
|
+
}
|
|
546
|
+
: {
|
|
547
|
+
severity: 'info',
|
|
548
|
+
code: 'compiler_pending',
|
|
549
|
+
message:
|
|
550
|
+
'Boolean union, shell intersections, and minimum wall thickness are not checked yet.',
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
const status = diagnostics.some((diagnostic) => diagnostic.severity === 'error')
|
|
555
|
+
? 'blocked'
|
|
556
|
+
: diagnostics.some((diagnostic) => diagnostic.severity === 'warning')
|
|
557
|
+
? 'warning'
|
|
558
|
+
: 'pass'
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
kind: 'print-export-report',
|
|
562
|
+
version: 2,
|
|
563
|
+
format,
|
|
564
|
+
scale,
|
|
565
|
+
units: 'millimeter',
|
|
566
|
+
orientation: 'z-up',
|
|
567
|
+
status,
|
|
568
|
+
bounds,
|
|
569
|
+
triangleCount,
|
|
570
|
+
invalidTriangleCount,
|
|
571
|
+
degenerateTriangleCount,
|
|
572
|
+
boundaryEdgeCount,
|
|
573
|
+
nonManifoldEdgeCount,
|
|
574
|
+
connectedComponentCount,
|
|
575
|
+
solidComponentCount,
|
|
576
|
+
invertedWinding,
|
|
577
|
+
volumeMm3: Math.abs(signedVolumeMm3),
|
|
578
|
+
diagnostics,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function prepareSceneForPrint(
|
|
583
|
+
source: THREE.Object3D,
|
|
584
|
+
options: PrintExportOptions,
|
|
585
|
+
): { scene: THREE.Object3D; report: PrintExportReport } {
|
|
586
|
+
if (!Number.isFinite(options.scale) || options.scale <= 0) {
|
|
587
|
+
throw new RangeError('Print scale must be a positive finite denominator')
|
|
588
|
+
}
|
|
589
|
+
if (
|
|
590
|
+
options.sourceBedElevationMeters !== undefined &&
|
|
591
|
+
!Number.isFinite(options.sourceBedElevationMeters)
|
|
592
|
+
) {
|
|
593
|
+
throw new RangeError('Print bed elevation must be finite')
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
ensureMeshPositions(source)
|
|
597
|
+
|
|
598
|
+
const physicalScale = MILLIMETERS_PER_METER / options.scale
|
|
599
|
+
// Connectivity is invariant under print scale and orientation. Checking it
|
|
600
|
+
// in model-space meters avoids scale-dependent ray tolerances and π/2 drift.
|
|
601
|
+
const edgeTopology = analyzeEdgeTopology(source, options.indexedTopology ?? false)
|
|
602
|
+
|
|
603
|
+
const scene = new THREE.Group()
|
|
604
|
+
scene.name = 'print-export'
|
|
605
|
+
scene.add(source)
|
|
606
|
+
scene.rotation.x = Math.PI / 2
|
|
607
|
+
scene.scale.setScalar(physicalScale)
|
|
608
|
+
scene.updateMatrixWorld(true)
|
|
609
|
+
|
|
610
|
+
const initialBounds = measureBounds(scene)
|
|
611
|
+
if (initialBounds) {
|
|
612
|
+
const bedElevation =
|
|
613
|
+
options.sourceBedElevationMeters === undefined
|
|
614
|
+
? initialBounds.min.z
|
|
615
|
+
: options.sourceBedElevationMeters * physicalScale
|
|
616
|
+
scene.position.set(
|
|
617
|
+
-(initialBounds.min.x + initialBounds.max.x) / 2,
|
|
618
|
+
-(initialBounds.min.y + initialBounds.max.y) / 2,
|
|
619
|
+
-bedElevation,
|
|
620
|
+
)
|
|
621
|
+
scene.updateMatrixWorld(true)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
scene,
|
|
626
|
+
report: analyzePrintScene(
|
|
627
|
+
scene,
|
|
628
|
+
options.scale,
|
|
629
|
+
edgeTopology,
|
|
630
|
+
options.compiled ?? false,
|
|
631
|
+
options.format ?? 'stl',
|
|
632
|
+
),
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function extractPreparedPrintMesh(root: THREE.Object3D): PrintMeshData {
|
|
637
|
+
root.updateMatrixWorld(true)
|
|
638
|
+
|
|
639
|
+
const positions: number[] = []
|
|
640
|
+
const indices: number[] = []
|
|
641
|
+
const point = new THREE.Vector3()
|
|
642
|
+
|
|
643
|
+
root.traverse((object) => {
|
|
644
|
+
const mesh = object as THREE.Mesh
|
|
645
|
+
if (!mesh.isMesh) return
|
|
646
|
+
|
|
647
|
+
const position = mesh.geometry.getAttribute('position')
|
|
648
|
+
if (!position) return
|
|
649
|
+
const index = mesh.geometry.getIndex()
|
|
650
|
+
const skinnedMesh = mesh as THREE.SkinnedMesh
|
|
651
|
+
const outputIndexByPosition = new Map<string, number>()
|
|
652
|
+
|
|
653
|
+
const outputIndexFor = (vertexIndex: number): number => {
|
|
654
|
+
point.fromBufferAttribute(position, vertexIndex)
|
|
655
|
+
if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, point)
|
|
656
|
+
point.applyMatrix4(mesh.matrixWorld)
|
|
657
|
+
if (!isFiniteVector(point)) {
|
|
658
|
+
throw new RangeError(
|
|
659
|
+
'Print geometry contains non-finite coordinates and cannot be encoded.',
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const x = Object.is(point.x, -0) ? 0 : point.x
|
|
664
|
+
const y = Object.is(point.y, -0) ? 0 : point.y
|
|
665
|
+
const z = Object.is(point.z, -0) ? 0 : point.z
|
|
666
|
+
const key = `${x},${y},${z}`
|
|
667
|
+
const existing = outputIndexByPosition.get(key)
|
|
668
|
+
if (existing !== undefined) return existing
|
|
669
|
+
|
|
670
|
+
const next = positions.length / 3
|
|
671
|
+
positions.push(x, y, z)
|
|
672
|
+
outputIndexByPosition.set(key, next)
|
|
673
|
+
return next
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const appendTriangle = (a: number, b: number, c: number) => {
|
|
677
|
+
indices.push(outputIndexFor(a), outputIndexFor(b), outputIndexFor(c))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (index) {
|
|
681
|
+
for (let offset = 0; offset + 2 < index.count; offset += 3) {
|
|
682
|
+
appendTriangle(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2))
|
|
683
|
+
}
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
for (let offset = 0; offset + 2 < position.count; offset += 3) {
|
|
688
|
+
appendTriangle(offset, offset + 1, offset + 2)
|
|
689
|
+
}
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
positions: new Float64Array(positions),
|
|
694
|
+
indices: new Uint32Array(indices),
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export function encodePreparedPrintSceneToStl(scene: THREE.Object3D): ArrayBuffer {
|
|
699
|
+
const exporter = new STLExporter()
|
|
700
|
+
const output = exporter.parse(scene, { binary: true }) as ArrayBuffer | DataView
|
|
701
|
+
return output instanceof DataView
|
|
702
|
+
? (output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer)
|
|
703
|
+
: output
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function exportSceneToPrintStl(
|
|
707
|
+
source: THREE.Object3D,
|
|
708
|
+
options: PrintExportOptions,
|
|
709
|
+
): PrintStlExport {
|
|
710
|
+
const { scene, report } = prepareSceneForPrint(source, options)
|
|
711
|
+
return { buffer: encodePreparedPrintSceneToStl(scene), report }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function mergePrintExportDiagnostics(
|
|
715
|
+
report: PrintExportReport,
|
|
716
|
+
diagnostics: PrintExportDiagnostic[],
|
|
717
|
+
omitCodes: ReadonlySet<string> = new Set(),
|
|
718
|
+
): PrintExportReport {
|
|
719
|
+
const merged = [
|
|
720
|
+
...report.diagnostics.filter((diagnostic) => !omitCodes.has(diagnostic.code)),
|
|
721
|
+
...diagnostics,
|
|
722
|
+
]
|
|
723
|
+
const status = merged.some((diagnostic) => diagnostic.severity === 'error')
|
|
724
|
+
? 'blocked'
|
|
725
|
+
: merged.some((diagnostic) => diagnostic.severity === 'warning')
|
|
726
|
+
? 'warning'
|
|
727
|
+
: 'pass'
|
|
728
|
+
return { ...report, status, diagnostics: merged }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export function isPrintExportReport(value: unknown): value is PrintExportReport {
|
|
732
|
+
if (!value || typeof value !== 'object') return false
|
|
733
|
+
const report = value as Partial<PrintExportReport>
|
|
734
|
+
return report.kind === 'print-export-report' && report.version === 2
|
|
735
|
+
}
|