@tscircuit/3d-viewer 0.0.1

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.
@@ -0,0 +1,9 @@
1
+ import type { RGB } from "@jscad/modeling/src/colors"
2
+
3
+ export const M = 0.01
4
+
5
+ export const colors = {
6
+ copper: [0.9, 0.6, 0.2],
7
+ fr4Green: [0x05 / 255, 0xa3 / 255, 0x2e / 255],
8
+ fr4GreenSolderWithMask: [0x00 / 255, 0x98 / 255, 0x13 / 255],
9
+ } satisfies Record<string, RGB>
@@ -0,0 +1,45 @@
1
+ import type { PCBPlatedHole } from "@tscircuit/soup"
2
+ import type { Geom3 } from "@jscad/modeling/src/geometries/types"
3
+ import { cylinder } from "@jscad/modeling/src/primitives"
4
+ import { colorize } from "@jscad/modeling/src/colors"
5
+ import { subtract, union } from "@jscad/modeling/src/operations/booleans"
6
+ import { M, colors } from "./constants"
7
+ import { GeomContext } from "../GeomContext"
8
+
9
+ export const platedHole = (
10
+ plated_hole: PCBPlatedHole,
11
+ ctx: GeomContext
12
+ ): Geom3 => {
13
+ if (!(plated_hole as any).shape) plated_hole.shape = "circle"
14
+ if (plated_hole.shape === "circle") {
15
+ return colorize(
16
+ colors.copper,
17
+ subtract(
18
+ union(
19
+ cylinder({
20
+ center: [plated_hole.x, plated_hole.y, 0],
21
+ radius: plated_hole.hole_diameter / 2,
22
+ height: 1.2,
23
+ }),
24
+ cylinder({
25
+ center: [plated_hole.x, plated_hole.y, 1.2 / 2],
26
+ radius: plated_hole.outer_diameter / 2,
27
+ height: M,
28
+ }),
29
+ cylinder({
30
+ center: [plated_hole.x, plated_hole.y, -1.2 / 2],
31
+ radius: plated_hole.outer_diameter / 2,
32
+ height: M,
33
+ })
34
+ ),
35
+ cylinder({
36
+ center: [plated_hole.x, plated_hole.y, 0],
37
+ radius: plated_hole.hole_diameter / 2 - M,
38
+ height: 1.5,
39
+ })
40
+ )
41
+ )
42
+ } else {
43
+ throw new Error(`Unsupported plated hole shape: ${plated_hole.shape}`)
44
+ }
45
+ }
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ // Based off a tweet and codesandbox:
4
+ // https://mobile.twitter.com/hieuhlc/status/1164369876825169920
5
+
6
+ function useKeyPress(
7
+ targetKey: string,
8
+ onKeyDown: () => void,
9
+ onKeyUp: () => void
10
+ ): void {
11
+ const downHandler = useCallback(
12
+ ({ key }: KeyboardEvent) => {
13
+ if (key === targetKey) onKeyDown()
14
+ },
15
+ [onKeyDown, targetKey]
16
+ )
17
+
18
+ const upHandler = useCallback(
19
+ ({ key }: KeyboardEvent) => {
20
+ if (key === targetKey) onKeyUp()
21
+ },
22
+ [onKeyUp, targetKey]
23
+ )
24
+
25
+ useEffect(() => {
26
+ window.addEventListener('keydown', downHandler)
27
+ window.addEventListener('keyup', upHandler)
28
+ return () => {
29
+ window.removeEventListener('keydown', downHandler)
30
+ window.removeEventListener('keyup', upHandler)
31
+ }
32
+ }, [downHandler, upHandler])
33
+ }
34
+
35
+ const useAnimationFrame = (
36
+ enabled: boolean,
37
+ callback: (time: number, delta: number) => void,
38
+ deps: React.DependencyList
39
+ ): void => {
40
+ const frame = useRef<number>()
41
+ const last = useRef(performance.now())
42
+ const init = useRef(performance.now())
43
+
44
+ const animate = () => {
45
+ const now = performance.now()
46
+ const time = (now - init.current) / 1000
47
+ const delta = (now - last.current) / 1000
48
+ callback(time, delta)
49
+ last.current = now
50
+ frame.current = requestAnimationFrame(animate)
51
+ }
52
+
53
+ useEffect(() => {
54
+ if (!enabled) return
55
+ frame.current = requestAnimationFrame(animate)
56
+ return () => {
57
+ if (frame.current) cancelAnimationFrame(frame.current)
58
+ }
59
+ }, deps)
60
+ }
61
+
62
+ export { useAnimationFrame, useKeyPress }
@@ -0,0 +1,440 @@
1
+ import { Geom2, Geom3 } from "@jscad/modeling/src/geometries/types"
2
+ import {
3
+ cameras,
4
+ controls,
5
+ drawCommands,
6
+ entitiesFromSolids,
7
+ prepareRender,
8
+ } from "@jscad/regl-renderer"
9
+ import { cameraState } from "@jscad/regl-renderer/types/cameras/perspectiveCamera"
10
+ import { controlsState } from "@jscad/regl-renderer/types/controls/orbitControls"
11
+ import * as renderingDefaults from "@jscad/regl-renderer/types/rendering/renderDefaults"
12
+ import * as React from "react"
13
+ import { useDrag, usePinch, useWheel } from "react-use-gesture"
14
+ import { InitializationOptions } from "regl"
15
+
16
+ import { useAnimationFrame, useKeyPress } from "./hooks"
17
+
18
+ interface RendererProps {
19
+ animate?: boolean
20
+ glOptions?: InitializationOptions
21
+ height?: number
22
+ options?: {
23
+ axisOptions?: {
24
+ show?: boolean
25
+ }
26
+ gridOptions?: {
27
+ show?: boolean
28
+ color?: number[]
29
+ subColor?: number[]
30
+ fadeOut?: boolean
31
+ transparent?: boolean
32
+ size?: number[]
33
+ ticks?: number[]
34
+ }
35
+ renderingOptions?: Partial<typeof renderingDefaults>
36
+ viewerOptions?: {
37
+ initialPosition?: number[]
38
+ panSpeed?: number
39
+ rotateSpeed?: number
40
+ zoomSpeed?: number
41
+ }
42
+ }
43
+ solids: Geom2[] | Geom3[]
44
+ width?: number
45
+ }
46
+
47
+ interface RendererState {
48
+ camera?: typeof cameraState
49
+ controls?: typeof controlsState
50
+ element: HTMLDivElement | null
51
+ inputs: {
52
+ shift: "up" | "down"
53
+ mouse: "up" | "down"
54
+ }
55
+ panDelta: number[]
56
+ render?: (content: any) => void
57
+ rotateDelta: number[]
58
+ zoomDelta: number
59
+ }
60
+
61
+ const initialProps = ({
62
+ animate,
63
+ glOptions,
64
+ height,
65
+ options,
66
+ solids,
67
+ width,
68
+ }: RendererProps): RendererProps => {
69
+ return {
70
+ animate: animate || false,
71
+ glOptions,
72
+ height: height || 480,
73
+ options: {
74
+ axisOptions: {
75
+ show: true,
76
+ ...options?.axisOptions,
77
+ },
78
+ gridOptions: {
79
+ show: true,
80
+ color: [0, 0, 0, 1],
81
+ subColor: [0, 0, 1, 0.5],
82
+ fadeOut: false,
83
+ transparent: true,
84
+ size: [144, 144],
85
+ ticks: [12, 1],
86
+ ...options?.gridOptions,
87
+ },
88
+ renderingOptions: {
89
+ background: [0.5, 0.5, 0.5, 1],
90
+ meshColor: [0, 0.6, 1, 1],
91
+ lightColor: [1, 1, 1, 1],
92
+ lightDirection: [0.2, 0.2, 1],
93
+ lightPosition: [100, 200, 100],
94
+ ambientLightAmount: 0.3,
95
+ diffuseLightAmount: 0.89,
96
+ specularLightAmount: 0.16,
97
+ materialShininess: 8.0,
98
+ ...options?.renderingOptions,
99
+ },
100
+ viewerOptions: {
101
+ initialPosition: [45, 45, 45],
102
+ panSpeed: 0.75,
103
+ rotateSpeed: 0.02,
104
+ zoomSpeed: 0.03,
105
+ ...options?.viewerOptions,
106
+ },
107
+ },
108
+ solids: solids || [],
109
+ width: width || 480,
110
+ }
111
+ }
112
+
113
+ const initialState = (options: RendererProps["options"]): RendererState => {
114
+ return {
115
+ camera: {
116
+ ...cameras.perspective.defaults,
117
+ position: options?.viewerOptions?.initialPosition,
118
+ },
119
+ controls: controls.orbit.defaults,
120
+ element: null,
121
+ inputs: { mouse: "up", shift: "up" },
122
+ panDelta: [0, 0],
123
+ rotateDelta: [0, 0],
124
+ zoomDelta: 0,
125
+ }
126
+ }
127
+
128
+ type RendererAction =
129
+ | { type: "SET_CAMERA"; payload: RendererState["camera"] }
130
+ | { type: "SET_CONTROLS"; payload: RendererState["controls"] }
131
+ | { type: "SET_ELEMENT"; payload: RendererState["element"] }
132
+ | { type: "SET_INPUTS"; payload: RendererState["inputs"] }
133
+ | { type: "SET_PAN_DELTA"; payload: RendererState["panDelta"] }
134
+ | { type: "SET_RENDER"; payload: RendererState["render"] }
135
+ | { type: "SET_ROTATE_DELTA"; payload: RendererState["rotateDelta"] }
136
+ | { type: "SET_ZOOM_DELTA"; payload: RendererState["zoomDelta"] }
137
+
138
+ function reducer(state: RendererState, action: RendererAction): RendererState {
139
+ switch (action.type) {
140
+ case "SET_CAMERA": {
141
+ if (!state.camera) return { ...state }
142
+ const updated = cameras.perspective.update({
143
+ ...state.camera,
144
+ ...action.payload,
145
+ })
146
+ return {
147
+ ...state,
148
+ camera: { ...action.payload, ...updated },
149
+ }
150
+ }
151
+ case "SET_CONTROLS": {
152
+ if (!action.payload || !state.camera) return { ...state }
153
+ const updated = controls.orbit.update({
154
+ controls: action.payload,
155
+ camera: state.camera,
156
+ })
157
+ return {
158
+ ...state,
159
+ controls: { ...action.payload, ...updated.controls },
160
+ camera: { ...state.camera, ...updated.camera },
161
+ }
162
+ }
163
+ case "SET_ELEMENT":
164
+ return { ...state, element: action.payload }
165
+ case "SET_INPUTS":
166
+ return { ...state, inputs: action.payload }
167
+ case "SET_PAN_DELTA":
168
+ return { ...state, panDelta: action.payload }
169
+ case "SET_RENDER":
170
+ return { ...state, render: action.payload }
171
+ case "SET_ROTATE_DELTA":
172
+ return { ...state, rotateDelta: action.payload }
173
+ case "SET_ZOOM_DELTA":
174
+ return { ...state, zoomDelta: action.payload }
175
+ }
176
+ }
177
+
178
+ document.addEventListener("gesturestart", (e) => e.preventDefault())
179
+ document.addEventListener("gesturechange", (e) => e.preventDefault())
180
+
181
+ const Renderer = React.forwardRef<HTMLDivElement, RendererProps>(
182
+ (props, forwardRef) => {
183
+ const { animate, glOptions, height, options, solids, width } =
184
+ initialProps(props)
185
+ const [state, dispatch] = React.useReducer(reducer, initialState(options))
186
+ const ref = React.useRef<HTMLDivElement>(null)
187
+
188
+ const content = React.useMemo(() => {
189
+ return {
190
+ rendering: options?.renderingOptions,
191
+ drawCommands: {
192
+ drawGrid: drawCommands.drawGrid,
193
+ drawAxis: drawCommands.drawAxis,
194
+ drawMesh: drawCommands.drawMesh,
195
+ },
196
+ entities: [
197
+ {
198
+ visuals: {
199
+ drawCmd: "drawGrid",
200
+ show: options?.gridOptions?.show,
201
+ color: options?.gridOptions?.color,
202
+ subColor: options?.gridOptions?.subColor,
203
+ fadeOut: options?.gridOptions?.fadeOut,
204
+ transparent: options?.gridOptions?.transparent,
205
+ },
206
+ size: options?.gridOptions?.size,
207
+ ticks: options?.gridOptions?.ticks,
208
+ },
209
+ {
210
+ visuals: {
211
+ drawCmd: "drawAxis",
212
+ show: options?.axisOptions?.show,
213
+ },
214
+ },
215
+ ...entitiesFromSolids({}, ...solids),
216
+ ],
217
+ }
218
+ }, [
219
+ options?.axisOptions?.show,
220
+ options?.gridOptions?.color,
221
+ options?.gridOptions?.fadeOut,
222
+ options?.gridOptions?.show,
223
+ options?.gridOptions?.size,
224
+ options?.gridOptions?.subColor,
225
+ options?.gridOptions?.ticks,
226
+ options?.gridOptions?.transparent,
227
+ options?.renderingOptions,
228
+ solids,
229
+ ])
230
+
231
+ useDrag(
232
+ (event) => {
233
+ dispatch({
234
+ type: "SET_INPUTS",
235
+ payload: { ...state.inputs, mouse: event.down ? "down" : "up" },
236
+ })
237
+ if (
238
+ state.inputs.mouse === "down" &&
239
+ (state.inputs.shift === "down" || event.touches === 3)
240
+ )
241
+ dispatch({
242
+ type: "SET_PAN_DELTA",
243
+ payload: [-event.delta[0], event.delta[1]],
244
+ })
245
+ if (
246
+ state.inputs.mouse === "down" &&
247
+ state.inputs.shift === "up" &&
248
+ event.touches === 1
249
+ )
250
+ dispatch({
251
+ type: "SET_ROTATE_DELTA",
252
+ payload: [event.delta[0], -event.delta[1]],
253
+ })
254
+ },
255
+ { domTarget: ref || forwardRef }
256
+ )
257
+
258
+ usePinch(
259
+ (event) => {
260
+ if (event.touches === 2)
261
+ dispatch({ type: "SET_ZOOM_DELTA", payload: -event.delta[0] })
262
+ },
263
+ { domTarget: ref || forwardRef }
264
+ )
265
+
266
+ useWheel(
267
+ (event) => {
268
+ dispatch({ type: "SET_ZOOM_DELTA", payload: event.delta[1] })
269
+ },
270
+ { domTarget: ref || forwardRef }
271
+ )
272
+
273
+ const onShiftDown = React.useCallback(() => {
274
+ dispatch({
275
+ type: "SET_INPUTS",
276
+ payload: { ...state.inputs, shift: "down" },
277
+ })
278
+ }, [state.inputs])
279
+
280
+ const onShiftUp = React.useCallback(() => {
281
+ dispatch({
282
+ type: "SET_INPUTS",
283
+ payload: { ...state.inputs, shift: "up" },
284
+ })
285
+ }, [state.inputs])
286
+
287
+ useKeyPress("Shift", onShiftDown, onShiftUp)
288
+
289
+ React.useEffect(() => {
290
+ const ref: React.MutableRefObject<HTMLDivElement> =
291
+ forwardRef as React.MutableRefObject<HTMLDivElement>
292
+ if (ref && ref.current)
293
+ dispatch({ type: "SET_ELEMENT", payload: ref.current })
294
+ }, [forwardRef])
295
+
296
+ React.useEffect(() => {
297
+ if (ref && ref.current)
298
+ dispatch({ type: "SET_ELEMENT", payload: ref.current })
299
+ }, [ref])
300
+
301
+ React.useEffect(() => {
302
+ if (!state.camera) return
303
+ if (!height) return
304
+ if (!width) return
305
+ if (
306
+ width === state.camera.viewport[2] &&
307
+ height === state.camera.viewport[3]
308
+ )
309
+ return
310
+ dispatch({
311
+ type: "SET_CAMERA",
312
+ payload: cameras.perspective.setProjection(null, state.camera, {
313
+ height: height,
314
+ width: width,
315
+ }),
316
+ })
317
+ }, [state.camera, state.element, height, width])
318
+
319
+ React.useEffect(() => {
320
+ if (!state.element || !(state.element instanceof HTMLDivElement)) return
321
+ if (!height) return
322
+ if (!width) return
323
+ if (state.element.clientHeight !== height)
324
+ state.element.style.height = `${height}px`
325
+ if (state.element.clientWidth !== width)
326
+ state.element.style.width = `${width}px`
327
+ }, [state.element, height, width])
328
+
329
+ React.useEffect(() => {
330
+ if (state.render) return
331
+ if (!content) return
332
+ if (!state.element) return
333
+ if (!state.camera) return
334
+ dispatch({
335
+ type: "SET_RENDER",
336
+ payload: prepareRender({
337
+ glOptions: { ...glOptions, container: state.element },
338
+ }),
339
+ })
340
+ }, [content, glOptions, state])
341
+
342
+ React.useEffect(() => {
343
+ if (!state.panDelta) return
344
+ if (!state.panDelta[0] && !state.panDelta[1]) return
345
+ if (!state.camera) return
346
+ if (!state.controls) return
347
+ const updated = controls.orbit.pan(
348
+ {
349
+ controls: state.controls,
350
+ camera: state.camera,
351
+ speed: options?.viewerOptions?.panSpeed,
352
+ },
353
+ state.panDelta
354
+ )
355
+ dispatch({
356
+ type: "SET_CONTROLS",
357
+ payload: { ...state.controls, ...updated.controls },
358
+ })
359
+ dispatch({
360
+ type: "SET_CAMERA",
361
+ payload: { ...state.camera, ...updated.camera },
362
+ })
363
+ dispatch({ type: "SET_PAN_DELTA", payload: [0, 0] })
364
+ }, [
365
+ state.camera,
366
+ state.controls,
367
+ options?.viewerOptions?.panSpeed,
368
+ state.panDelta,
369
+ ])
370
+
371
+ React.useEffect(() => {
372
+ if (!state.rotateDelta) return
373
+ if (!state.rotateDelta[0] && !state.rotateDelta[1]) return
374
+ if (!state.camera) return
375
+ if (!state.controls) return
376
+ const updated = controls.orbit.rotate(
377
+ {
378
+ controls: state.controls,
379
+ camera: state.camera,
380
+ speed: options?.viewerOptions?.rotateSpeed,
381
+ },
382
+ state.rotateDelta
383
+ )
384
+ dispatch({
385
+ type: "SET_CONTROLS",
386
+ payload: { ...state.controls, ...updated.controls },
387
+ })
388
+ dispatch({ type: "SET_ROTATE_DELTA", payload: [0, 0] })
389
+ }, [
390
+ state.camera,
391
+ state.controls,
392
+ options?.viewerOptions?.rotateSpeed,
393
+ state.rotateDelta,
394
+ ])
395
+
396
+ React.useEffect(() => {
397
+ if (!state.zoomDelta || !Number.isFinite(state.zoomDelta)) return
398
+ if (!state.camera) return
399
+ if (!state.controls) return
400
+ const updated = controls.orbit.zoom(
401
+ {
402
+ controls: state.controls,
403
+ camera: state.camera,
404
+ speed: options?.viewerOptions?.zoomSpeed,
405
+ },
406
+ state.zoomDelta
407
+ )
408
+ dispatch({
409
+ type: "SET_CONTROLS",
410
+ payload: { ...state.controls, ...updated.controls },
411
+ })
412
+ dispatch({ type: "SET_ZOOM_DELTA", payload: 0 })
413
+ }, [
414
+ state.camera,
415
+ state.controls,
416
+ options?.viewerOptions?.zoomSpeed,
417
+ state.zoomDelta,
418
+ ])
419
+
420
+ const render = React.useCallback(() => {
421
+ if (!state.render) return
422
+ if (!content) return
423
+ state.render({ camera: state.camera, ...content })
424
+ }, [content, state])
425
+
426
+ React.useEffect(() => {
427
+ if (!animate) render()
428
+ }, [animate, render])
429
+
430
+ useAnimationFrame(!!animate, () => render(), [render])
431
+
432
+ if (!forwardRef) return <div ref={ref} style={{ touchAction: "none" }} />
433
+ return <div ref={forwardRef} style={{ touchAction: "none" }} />
434
+ }
435
+ )
436
+
437
+ Renderer.displayName = "Renderer"
438
+
439
+ export type { RendererProps, RendererState, RendererAction }
440
+ export { Renderer, initialProps, initialState }
@@ -0,0 +1,10 @@
1
+ import type { AnySoupElement } from "@tscircuit/soup"
2
+
3
+ export const useConvertChildrenToSoup = (
4
+ children?: any,
5
+ defaultSoup?: AnySoupElement[]
6
+ ) => {
7
+ // TODO convert children if defined
8
+
9
+ return defaultSoup!
10
+ }
@@ -0,0 +1,54 @@
1
+ import { useState, useEffect } from "react"
2
+ import stlSerializer from "@jscad/stl-serializer"
3
+ import { Geom3 } from "@jscad/modeling/src/geometries/types"
4
+
5
+ function blobToBase64Url(blob: Blob): Promise<string> {
6
+ return new Promise((resolve, reject) => {
7
+ const reader = new FileReader()
8
+ reader.onload = () => {
9
+ resolve(reader.result as string)
10
+ }
11
+ reader.onerror = reject
12
+ reader.readAsDataURL(blob)
13
+ })
14
+ }
15
+
16
+ type StlObj = { stlUrl: string; color: number[] }
17
+
18
+ export const useStlsFromGeom = (
19
+ geom: Geom3[] | Geom3
20
+ ): {
21
+ stls: StlObj[]
22
+ loading: boolean
23
+ } => {
24
+ const [stls, setStls] = useState<StlObj[]>([])
25
+ const [loading, setLoading] = useState(true)
26
+
27
+ useEffect(() => {
28
+ const generateStls = async () => {
29
+ setLoading(true)
30
+ const geometries = Array.isArray(geom) ? geom : [geom]
31
+
32
+ const stlPromises = geometries.map(async (g) => {
33
+ const rawData = stlSerializer.serialize({ binary: true }, [g])
34
+ const blobData = new Blob(rawData)
35
+ const stlUrl = await blobToBase64Url(blobData)
36
+ return { stlUrl, color: g.color! }
37
+ })
38
+
39
+ try {
40
+ const generatedStls = await Promise.all(stlPromises)
41
+ setStls(generatedStls)
42
+ } catch (error) {
43
+ console.error("Error generating STLs:", error)
44
+ setStls([])
45
+ } finally {
46
+ setLoading(false)
47
+ }
48
+ }
49
+
50
+ generateStls()
51
+ }, [geom])
52
+
53
+ return { stls, loading }
54
+ }
package/src/index.tsx ADDED
@@ -0,0 +1 @@
1
+ export { CadViewer } from "./CadViewer"
package/src/main.tsx ADDED
@@ -0,0 +1,9 @@
1
+ import React from "react"
2
+ import ReactDOM from "react-dom/client"
3
+ import App from "./App.tsx"
4
+
5
+ ReactDOM.createRoot(document.getElementById("root")!).render(
6
+ <React.StrictMode>
7
+ <App />
8
+ </React.StrictMode>
9
+ )
@@ -0,0 +1,3 @@
1
+ declare module "@jscad/stl-serializer" {
2
+ export function serialize(options: any, objects: any[]): any[]
3
+ }