import { Coords2d } from 'lang/std/sketch' import { BoxGeometry, BufferGeometry, CatmullRomCurve3, ConeGeometry, CurvePath, EllipseCurve, ExtrudeGeometry, Group, LineCurve3, LineBasicMaterial, LineDashedMaterial, Line, Mesh, MeshBasicMaterial, NormalBufferAttributes, Points, PointsMaterial, Shape, SphereGeometry, Texture, Vector2, Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer' import { PathToNode, Sketch, getTangentialArcToInfo } from 'lang/wasm' import { CIRCLE_CENTER_HANDLE, CIRCLE_SEGMENT, CIRCLE_SEGMENT_BODY, CIRCLE_SEGMENT_DASH, CIRCLE_THREE_POINT_HANDLE1, CIRCLE_THREE_POINT_HANDLE2, CIRCLE_THREE_POINT_HANDLE3, CIRCLE_THREE_POINT_SEGMENT, CIRCLE_THREE_POINT_SEGMENT_BODY, CIRCLE_THREE_POINT_SEGMENT_DASH, EXTRA_SEGMENT_HANDLE, EXTRA_SEGMENT_OFFSET_PX, HIDE_HOVER_SEGMENT_LENGTH, HIDE_SEGMENT_LENGTH, PROFILE_START, SEGMENT_WIDTH_PX, STRAIGHT_SEGMENT, STRAIGHT_SEGMENT_BODY, STRAIGHT_SEGMENT_DASH, TANGENTIAL_ARC_TO_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT_BODY, TANGENTIAL_ARC_TO__SEGMENT_DASH, getParentGroup, } from './sceneEntities' import { getTangentPointFromPreviousArc } from 'lib/utils2d' import { ARROWHEAD, DRAFT_POINT, SceneInfra, SEGMENT_LENGTH_LABEL, SEGMENT_LENGTH_LABEL_OFFSET_PX, SEGMENT_LENGTH_LABEL_TEXT, } from './sceneInfra' import { Themes, getThemeColorForThreeJs } from 'lib/theme' import { normaliseAngle, roundOff } from 'lib/utils' import { SegmentOverlay, SegmentOverlayPayload, SegmentOverlays, } from 'machines/modelingMachine' import { SegmentInputs } from 'lang/std/stdTypes' import { err } from 'lib/trap' import { sceneInfra } from 'lib/singletons' import { Selections } from 'lib/selections' import { calculate_circle_from_3_points } from 'wasm-lib/pkg/wasm_lib' import { commandBarActor } from 'machines/commandBarMachine' interface CreateSegmentArgs { input: SegmentInputs prevSegment: Sketch['paths'][number] id: string pathToNode: PathToNode isDraftSegment?: boolean scale?: number callExpName: string texture: Texture theme: Themes isSelected?: boolean sceneInfra: SceneInfra selection?: Selections } interface UpdateSegmentArgs { input: SegmentInputs prevSegment: Sketch['paths'][number] group: Group sceneInfra: SceneInfra scale?: number } interface CreateSegmentResult { group: Group updateOverlaysCallback: () => SegmentOverlayPayload | null } export interface SegmentUtils { /** * the init is responsible for adding all of the correct entities to the group with important details like `mesh.name = ...` * as these act like handles later * * It's **Not** responsible for doing all calculations to size and position the entities as this would be duplicated in the update function * Which should instead be called at the end of the init function */ init: (args: CreateSegmentArgs) => CreateSegmentResult | Error /** * The update function is responsible for updating the group with the correct size and position of the entities * It should be called at the end of the init function and return a callback that can be used to update the overlay * * It returns a callback for updating the overlays, this is so the overlays do not have to update at the same pace threeJs does * This is useful for performance reasons */ update: ( args: UpdateSegmentArgs ) => CreateSegmentResult['updateOverlaysCallback'] | Error } class StraightSegment implements SegmentUtils { init: SegmentUtils['init'] = ({ input, id, pathToNode, isDraftSegment, scale = 1, callExpName, texture, theme, isSelected = false, sceneInfra, prevSegment, selection, }) => { if (input.type !== 'straight-segment') return new Error('Invalid segment type') const { from, to } = input const baseColor = callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme) const color = isSelected ? 0x0000ff : baseColor const meshType = isDraftSegment ? STRAIGHT_SEGMENT_DASH : STRAIGHT_SEGMENT_BODY const segmentGroup = new Group() const shape = new Shape() const line = new LineCurve3( new Vector3(from[0], from[1], 0), new Vector3(to[0], to[1], 0) ) const geometry = new ExtrudeGeometry(shape, { steps: 2, bevelEnabled: false, extrudePath: line, }) const body = new MeshBasicMaterial({ color }) const mesh = new Mesh(geometry, body) mesh.userData.type = meshType mesh.name = meshType segmentGroup.name = STRAIGHT_SEGMENT segmentGroup.userData = { type: STRAIGHT_SEGMENT, draft: isDraftSegment, id, from, to, pathToNode, isSelected, callExpName, baseColor, selection, } // All segment types get an extra segment handle, // Which is a little plus sign that appears at the origin of the segment // and can be dragged to insert a new segment const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme) // Segment decorators that only apply to non-close segments if (callExpName !== 'close') { // an arrowhead that appears at the end of the segment const arrowGroup = createArrowhead(scale, theme, color) // A length indicator that appears at the midpoint of the segment const lengthIndicatorGroup = createLengthIndicator({ from, to, scale, }) segmentGroup.add(arrowGroup) segmentGroup.add(lengthIndicatorGroup) } segmentGroup.add(mesh, extraSegmentGroup) let updateOverlaysCallback = this.update({ prevSegment, input, group: segmentGroup, scale, sceneInfra, }) if (err(updateOverlaysCallback)) return updateOverlaysCallback return { group: segmentGroup, updateOverlaysCallback, } } update: SegmentUtils['update'] = ({ input, group, scale = 1, sceneInfra, }) => { if (input.type !== 'straight-segment') return new Error('Invalid segment type') const { from, to } = input group.userData.from = from group.userData.to = to const shape = new Shape() shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case) shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group const length = Math.sqrt( Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2) ) const pxLength = length / scale const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH const hoveredParent = sceneInfra.hoveredObject && getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT]) let isHandlesVisible = !shouldHideIdle if (hoveredParent && hoveredParent?.uuid === group?.uuid) { isHandlesVisible = !shouldHideHover } if (arrowGroup) { arrowGroup.position.set(to[0], to[1], 0) const dir = new Vector3() .subVectors( new Vector3(to[0], to[1], 0), new Vector3(from[0], from[1], 0) ) .normalize() arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) arrowGroup.scale.set(scale, scale, scale) arrowGroup.visible = isHandlesVisible } const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) if (extraSegmentGroup) { const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1]) .normalize() .multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale) extraSegmentGroup.position.set( from[0] + offsetFromBase.x, from[1] + offsetFromBase.y, 0 ) extraSegmentGroup.scale.set(scale, scale, scale) extraSegmentGroup.visible = isHandlesVisible } if (labelGroup) { const labelWrapper = labelGroup.getObjectByName( SEGMENT_LENGTH_LABEL_TEXT ) as CSS2DObject const labelWrapperElem = labelWrapper.element as HTMLDivElement const label = labelWrapperElem.children[0] as HTMLParagraphElement label.innerText = `${roundOff(length)}` label.classList.add(SEGMENT_LENGTH_LABEL_TEXT) const slope = (to[1] - from[1]) / (to[0] - from[0]) let slopeAngle = ((Math.atan(slope) * 180) / Math.PI) * -1 label.style.setProperty('--degree', `${slopeAngle}deg`) label.style.setProperty('--x', `0px`) label.style.setProperty('--y', `0px`) labelWrapper.position.set((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, 0) labelGroup.visible = isHandlesVisible } const straightSegmentBody = group.children.find( (child) => child.userData.type === STRAIGHT_SEGMENT_BODY ) as Mesh if (straightSegmentBody) { const line = new LineCurve3( new Vector3(from[0], from[1], 0), new Vector3(to[0], to[1], 0) ) straightSegmentBody.geometry = new ExtrudeGeometry(shape, { steps: 2, bevelEnabled: false, extrudePath: line, }) } const straightSegmentBodyDashed = group.children.find( (child) => child.userData.type === STRAIGHT_SEGMENT_DASH ) as Mesh if (straightSegmentBodyDashed) { straightSegmentBodyDashed.geometry = dashedStraight( from, to, shape, scale ) } return () => sceneInfra.updateOverlayDetails({ handle: arrowGroup, group, isHandlesVisible, from, to, hasThreeDotMenu: true, }) } } class TangentialArcToSegment implements SegmentUtils { init: SegmentUtils['init'] = ({ prevSegment, input, id, pathToNode, isDraftSegment, scale = 1, texture, theme, isSelected, sceneInfra, }) => { if (input.type !== 'straight-segment') return new Error('Invalid segment type') const { from, to } = input const meshName = isDraftSegment ? TANGENTIAL_ARC_TO__SEGMENT_DASH : TANGENTIAL_ARC_TO_SEGMENT_BODY const group = new Group() const geometry = createArcGeometry({ center: [0, 0], radius: 1, startAngle: 0, endAngle: 1, ccw: true, isDashed: isDraftSegment, scale, }) const baseColor = getThemeColorForThreeJs(theme) const color = isSelected ? 0x0000ff : baseColor const body = new MeshBasicMaterial({ color }) const mesh = new Mesh(geometry, body) const arrowGroup = createArrowhead(scale, theme, color) const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme) group.name = TANGENTIAL_ARC_TO_SEGMENT mesh.userData.type = meshName mesh.name = meshName group.userData = { type: TANGENTIAL_ARC_TO_SEGMENT, draft: isDraftSegment, id, from, to, prevSegment, pathToNode, isSelected, baseColor, } group.add(mesh, arrowGroup, extraSegmentGroup) const updateOverlaysCallback = this.update({ prevSegment, input, group, scale, sceneInfra, }) if (err(updateOverlaysCallback)) return updateOverlaysCallback return { group, updateOverlaysCallback, } } update: SegmentUtils['update'] = ({ prevSegment, input, group, scale = 1, sceneInfra, }) => { if (input.type !== 'straight-segment') return new Error('Invalid segment type') const { from, to } = input group.userData.from = from group.userData.to = to group.userData.prevSegment = prevSegment const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) const previousPoint = prevSegment?.type === 'TangentialArcTo' ? getTangentPointFromPreviousArc( prevSegment.center, prevSegment.ccw, prevSegment.to ) : prevSegment.from const arcInfo = getTangentialArcToInfo({ arcStartPoint: from, arcEndPoint: to, tanPreviousPoint: previousPoint, obtuse: true, }) const pxLength = arcInfo.arcLength / scale const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH const hoveredParent = sceneInfra?.hoveredObject && getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT]) let isHandlesVisible = !shouldHideIdle if (hoveredParent && hoveredParent?.uuid === group?.uuid) { isHandlesVisible = !shouldHideHover } if (arrowGroup) { arrowGroup.position.set(to[0], to[1], 0) const arrowheadAngle = arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1) arrowGroup.quaternion.setFromUnitVectors( new Vector3(0, 1, 0), new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0) ) arrowGroup.scale.set(scale, scale, scale) arrowGroup.visible = isHandlesVisible } if (extraSegmentGroup) { const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale const extraSegmentAngleDelta = (EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2 const extraSegmentAngle = arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta const extraSegmentOffset = new Vector2( Math.cos(extraSegmentAngle) * arcInfo.radius, Math.sin(extraSegmentAngle) * arcInfo.radius ) extraSegmentGroup.position.set( arcInfo.center[0] + extraSegmentOffset.x, arcInfo.center[1] + extraSegmentOffset.y, 0 ) extraSegmentGroup.scale.set(scale, scale, scale) extraSegmentGroup.visible = isHandlesVisible } const tangentialArcToSegmentBody = group.children.find( (child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY ) as Mesh if (tangentialArcToSegmentBody) { const newGeo = createArcGeometry({ ...arcInfo, scale }) tangentialArcToSegmentBody.geometry = newGeo } const tangentialArcToSegmentBodyDashed = group.getObjectByName( TANGENTIAL_ARC_TO__SEGMENT_DASH ) if (tangentialArcToSegmentBodyDashed instanceof Mesh) { tangentialArcToSegmentBodyDashed.geometry = createArcGeometry({ ...arcInfo, isDashed: true, scale, }) } const angle = normaliseAngle( (arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90) ) return () => sceneInfra.updateOverlayDetails({ handle: arrowGroup, group, isHandlesVisible, from, to, angle, hasThreeDotMenu: true, }) } } class CircleSegment implements SegmentUtils { init: SegmentUtils['init'] = ({ prevSegment, input, id, pathToNode, isDraftSegment, scale = 1, theme, isSelected, sceneInfra, }) => { if (input.type !== 'arc-segment') { return new Error('Invalid segment type') } const { from, center, radius } = input const baseColor = getThemeColorForThreeJs(theme) const color = isSelected ? 0x0000ff : baseColor const group = new Group() const geometry = createArcGeometry({ center, radius, startAngle: 0, endAngle: Math.PI * 2, ccw: true, isDashed: isDraftSegment, scale, }) const mat = new MeshBasicMaterial({ color }) const arcMesh = new Mesh(geometry, mat) const meshType = isDraftSegment ? CIRCLE_SEGMENT_DASH : CIRCLE_SEGMENT_BODY const arrowGroup = createArrowhead(scale, theme, color) const circleCenterGroup = createCircleCenterHandle(scale, theme, color) // A radius indicator that appears from the center to the perimeter const radiusIndicatorGroup = createLengthIndicator({ from: center, to: [center[0] + radius, center[1]], scale, }) arcMesh.userData.type = meshType arcMesh.name = meshType group.userData = { type: CIRCLE_SEGMENT, draft: isDraftSegment, id, from, radius, center, ccw: true, prevSegment, pathToNode, isSelected, baseColor, } group.name = CIRCLE_SEGMENT group.add(arcMesh, arrowGroup, circleCenterGroup, radiusIndicatorGroup) const updateOverlaysCallback = this.update({ prevSegment, input, group, scale, sceneInfra, }) if (err(updateOverlaysCallback)) return updateOverlaysCallback return { group, updateOverlaysCallback, } } update: SegmentUtils['update'] = ({ prevSegment, input, group, scale = 1, sceneInfra, }) => { if (input.type !== 'arc-segment') { return new Error('Invalid segment type') } const { from, center, radius } = input group.userData.from = from // group.userData.to = to group.userData.center = center group.userData.radius = radius group.userData.prevSegment = prevSegment const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const radiusLengthIndicator = group.getObjectByName( SEGMENT_LENGTH_LABEL ) as Group const circleCenterHandle = group.getObjectByName( CIRCLE_CENTER_HANDLE ) as Group const pxLength = (2 * radius * Math.PI) / scale const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH const hoveredParent = sceneInfra.hoveredObject && getParentGroup(sceneInfra.hoveredObject, [CIRCLE_SEGMENT]) let isHandlesVisible = !shouldHideIdle if (hoveredParent && hoveredParent?.uuid === group?.uuid) { isHandlesVisible = !shouldHideHover } if (arrowGroup) { // The arrowhead is placed at the perimeter of the circle, // pointing up and to the right const arrowPoint = { x: center[0] + Math.cos(Math.PI / 4) * radius, y: center[1] + Math.sin(Math.PI / 4) * radius, } arrowGroup.position.set(arrowPoint.x, arrowPoint.y, 0) const arrowheadAngle = Math.PI / 4 arrowGroup.quaternion.setFromUnitVectors( new Vector3(0, 1, 0), new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0) ) arrowGroup.scale.set(scale, scale, scale) arrowGroup.visible = isHandlesVisible } if (radiusLengthIndicator) { // The radius indicator is placed at the midpoint of the radius, // at a 45 degree CCW angle from the positive X-axis const indicatorPoint = { x: center[0] + (Math.cos(Math.PI / 4) * radius) / 2, y: center[1] + (Math.sin(Math.PI / 4) * radius) / 2, } const labelWrapper = radiusLengthIndicator.getObjectByName( SEGMENT_LENGTH_LABEL_TEXT ) as CSS2DObject const labelWrapperElem = labelWrapper.element as HTMLDivElement const label = labelWrapperElem.children[0] as HTMLParagraphElement label.innerText = `${roundOff(radius)}` label.classList.add(SEGMENT_LENGTH_LABEL_TEXT) const isPlaneBackFace = center[0] > indicatorPoint.x label.style.setProperty( '--degree', `${isPlaneBackFace ? '45' : '-45'}deg` ) label.style.setProperty('--x', `0px`) label.style.setProperty('--y', `0px`) labelWrapper.position.set(indicatorPoint.x, indicatorPoint.y, 0) radiusLengthIndicator.visible = isHandlesVisible } if (circleCenterHandle) { circleCenterHandle.position.set(center[0], center[1], 0) circleCenterHandle.scale.set(scale, scale, scale) circleCenterHandle.visible = isHandlesVisible } const circleSegmentBody = group.children.find( (child) => child.userData.type === CIRCLE_SEGMENT_BODY ) as Mesh if (circleSegmentBody) { const newGeo = createArcGeometry({ radius, center, startAngle: 0, endAngle: Math.PI * 2, ccw: true, scale, }) circleSegmentBody.geometry = newGeo } const circleSegmentBodyDashed = group.getObjectByName(CIRCLE_SEGMENT_DASH) if (circleSegmentBodyDashed instanceof Mesh) { // consider throttling the whole updateTangentialArcToSegment // if there are more perf considerations going forward circleSegmentBodyDashed.geometry = createArcGeometry({ center, radius, ccw: true, // make the start end where the handle is startAngle: Math.PI * 0.25, endAngle: Math.PI * 2.25, isDashed: true, scale, }) } return () => sceneInfra.updateOverlayDetails({ handle: arrowGroup, group, isHandlesVisible, from: from, to: [center[0], center[1]], angle: Math.PI / 4, hasThreeDotMenu: true, }) } } class CircleThreePointSegment implements SegmentUtils { init: SegmentUtils['init'] = ({ input, id, pathToNode, isDraftSegment, scale = 1, theme, isSelected = false, sceneInfra, prevSegment, }) => { if (input.type !== 'circle-three-point-segment') { return new Error('Invalid segment type') } const { p1, p2, p3 } = input const { center_x, center_y, radius } = calculate_circle_from_3_points( p1[0], p1[1], p2[0], p2[1], p3[0], p3[1] ) const center: [number, number] = [center_x, center_y] const baseColor = getThemeColorForThreeJs(theme) const color = isSelected ? 0x0000ff : baseColor const group = new Group() const geometry = createArcGeometry({ center, radius, startAngle: 0, endAngle: Math.PI * 2, ccw: true, isDashed: isDraftSegment, scale, }) const mat = new MeshBasicMaterial({ color }) const arcMesh = new Mesh(geometry, mat) const meshType = isDraftSegment ? CIRCLE_THREE_POINT_SEGMENT_DASH : CIRCLE_THREE_POINT_SEGMENT_BODY const handle1 = createCircleThreePointHandle( scale, theme, CIRCLE_THREE_POINT_HANDLE1, color ) const handle2 = createCircleThreePointHandle( scale, theme, CIRCLE_THREE_POINT_HANDLE2, color ) const handle3 = createCircleThreePointHandle( scale, theme, CIRCLE_THREE_POINT_HANDLE3, color ) arcMesh.userData.type = meshType arcMesh.name = meshType group.userData = { type: CIRCLE_THREE_POINT_SEGMENT, draft: isDraftSegment, id, p1, p2, p3, ccw: true, prevSegment, pathToNode, isSelected, baseColor, } group.name = CIRCLE_THREE_POINT_SEGMENT group.add(arcMesh, handle1, handle2, handle3) const updateOverlaysCallback = this.update({ prevSegment, input, group, scale, sceneInfra, }) if (err(updateOverlaysCallback)) return updateOverlaysCallback return { group, updateOverlaysCallback, } } update: SegmentUtils['update'] = ({ input, group, scale = 1, sceneInfra, }) => { if (input.type !== 'circle-three-point-segment') { return new Error('Invalid segment type') } const { p1, p2, p3 } = input group.userData.p1 = p1 group.userData.p2 = p2 group.userData.p3 = p3 const { center_x, center_y, radius } = calculate_circle_from_3_points( p1[0], p1[1], p2[0], p2[1], p3[0], p3[1] ) const center: [number, number] = [center_x, center_y] const points = [p1, p2, p3] const handles = [ CIRCLE_THREE_POINT_HANDLE1, CIRCLE_THREE_POINT_HANDLE2, CIRCLE_THREE_POINT_HANDLE3, ].map((handle) => group.getObjectByName(handle) as Group) handles.forEach((handle, i) => { const point = points[i] if (handle && point) { handle.position.set(point[0], point[1], 0) handle.scale.set(scale, scale, scale) handle.visible = true } }) const pxLength = (2 * radius * Math.PI) / scale const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH const hoveredParent = sceneInfra.hoveredObject && getParentGroup(sceneInfra.hoveredObject, [CIRCLE_SEGMENT]) let isHandlesVisible = !shouldHideIdle if (hoveredParent && hoveredParent?.uuid === group?.uuid) { isHandlesVisible = !shouldHideHover } const circleSegmentBody = group.children.find( (child) => child.userData.type === CIRCLE_THREE_POINT_SEGMENT_BODY ) as Mesh if (circleSegmentBody) { const newGeo = createArcGeometry({ radius, center, startAngle: 0, endAngle: Math.PI * 2, ccw: true, scale, }) circleSegmentBody.geometry = newGeo } const circleSegmentBodyDashed = group.getObjectByName( CIRCLE_THREE_POINT_SEGMENT_DASH ) if (circleSegmentBodyDashed instanceof Mesh) { // consider throttling the whole updateTangentialArcToSegment // if there are more perf considerations going forward circleSegmentBodyDashed.geometry = createArcGeometry({ center, radius, ccw: true, // make the start end where the handle is startAngle: Math.PI * 0.25, endAngle: Math.PI * 2.25, isDashed: true, scale, }) } return () => { const overlays: SegmentOverlays = {} const points = [p1, p2, p3] const overlayDetails = handles.map((handle, index) => { const currentPoint = points[index] const angle = Math.atan2( currentPoint[1] - center[1], currentPoint[0] - center[0] ) return sceneInfra.updateOverlayDetails({ handle, group, isHandlesVisible, from: [0, 0], to: [center[0], center[1]], angle: angle, hasThreeDotMenu: index === 0, }) }) const segmentOverlays: SegmentOverlay[] = [] overlayDetails.forEach((payload, index) => { if (payload?.type === 'set-one') { overlays[payload.pathToNodeString] = payload.seg segmentOverlays.push({ ...payload.seg[0], filterValue: index === 0 ? 'p1' : index === 1 ? 'p2' : 'p3', }) } }) const segmentOverlayPayload: SegmentOverlayPayload = { type: 'set-one', pathToNodeString: overlayDetails[0]?.type === 'set-one' ? overlayDetails[0].pathToNodeString : '', seg: segmentOverlays, } return segmentOverlayPayload } } } export function createProfileStartHandle({ from, isDraft = false, scale = 1, theme, isSelected, size = 12, ...rest }: { from: Coords2d scale?: number theme: Themes isSelected?: boolean size?: number } & ( | { isDraft: true } | { isDraft: false; id: string; pathToNode: PathToNode } )) { const group = new Group() const geometry = new BoxGeometry(size, size, size) // in pixels scaled later const baseColor = getThemeColorForThreeJs(theme) const color = isSelected ? 0x0000ff : baseColor const body = new MeshBasicMaterial({ color }) const mesh = new Mesh(geometry, body) group.add(mesh) group.userData = { type: PROFILE_START, from, isSelected, baseColor, ...rest, } group.name = isDraft ? DRAFT_POINT : PROFILE_START group.position.set(from[0], from[1], 0) group.scale.set(scale, scale, scale) return group } function createArrowhead(scale = 1, theme: Themes, color?: number): Group { const baseColor = getThemeColorForThreeJs(theme) const arrowMaterial = new MeshBasicMaterial({ color: color || baseColor, }) // specify the size of the geometry in pixels (i.e. cone height = 20px, cone radius = 4.5px) // we'll scale the group to the correct size later to match these sizes in screen space const arrowheadMesh = new Mesh(new ConeGeometry(4.5, 20, 12), arrowMaterial) arrowheadMesh.position.set(0, -9, 0) const sphereMesh = new Mesh(new SphereGeometry(4, 12, 12), arrowMaterial) const arrowGroup = new Group() arrowGroup.userData.type = ARROWHEAD arrowGroup.name = ARROWHEAD arrowGroup.add(arrowheadMesh, sphereMesh) arrowGroup.lookAt(new Vector3(0, 1, 0)) arrowGroup.scale.set(scale, scale, scale) return arrowGroup } function createCircleCenterHandle( scale = 1, theme: Themes, color?: number ): Group { const circleCenterGroup = new Group() const geometry = new BoxGeometry(12, 12, 12) // in pixels scaled later const baseColor = getThemeColorForThreeJs(theme) const body = new MeshBasicMaterial({ color }) const mesh = new Mesh(geometry, body) circleCenterGroup.add(mesh) circleCenterGroup.userData = { type: CIRCLE_CENTER_HANDLE, baseColor, } circleCenterGroup.name = CIRCLE_CENTER_HANDLE circleCenterGroup.scale.set(scale, scale, scale) return circleCenterGroup } function createCircleThreePointHandle( scale = 1, theme: Themes, name: `circle-three-point-handle${'1' | '2' | '3'}`, color?: number ): Group { const circleCenterGroup = new Group() const geometry = new BoxGeometry(12, 12, 12) // in pixels scaled later const baseColor = getThemeColorForThreeJs(theme) const body = new MeshBasicMaterial({ color }) const mesh = new Mesh(geometry, body) circleCenterGroup.add(mesh) circleCenterGroup.userData = { type: name, baseColor, } circleCenterGroup.name = name circleCenterGroup.scale.set(scale, scale, scale) return circleCenterGroup } function createExtraSegmentHandle( scale: number, texture: Texture, theme: Themes ): Group { const particleMaterial = new PointsMaterial({ size: 12, // in pixels map: texture, transparent: true, opacity: 0, depthTest: false, }) const mat = new MeshBasicMaterial({ transparent: true, color: getThemeColorForThreeJs(theme), opacity: 0, }) const particleGeometry = new BufferGeometry().setFromPoints([ new Vector3(0, 0, 0), ]) const sphereMesh = new Mesh(new SphereGeometry(6, 12, 12), mat) // sphere radius in pixels const particle = new Points(particleGeometry, particleMaterial) particle.userData.ignoreColorChange = true particle.userData.type = EXTRA_SEGMENT_HANDLE const extraSegmentGroup = new Group() extraSegmentGroup.userData.type = EXTRA_SEGMENT_HANDLE extraSegmentGroup.name = EXTRA_SEGMENT_HANDLE extraSegmentGroup.add(sphereMesh) extraSegmentGroup.add(particle) extraSegmentGroup.scale.set(scale, scale, scale) return extraSegmentGroup } /** * Creates a group containing a CSS2DObject with the length of the segment */ function createLengthIndicator({ from, to, scale, length = 0.1, }: { from: Coords2d to: Coords2d scale: number length?: number }) { const lengthIndicatorGroup = new Group() lengthIndicatorGroup.name = SEGMENT_LENGTH_LABEL // Make the elements const lengthIndicatorText = document.createElement('p') lengthIndicatorText.classList.add(SEGMENT_LENGTH_LABEL_TEXT) lengthIndicatorText.innerText = roundOff(length).toString() const lengthIndicatorWrapper = document.createElement('div') // Double click workflow lengthIndicatorWrapper.ondblclick = () => { const selection = lengthIndicatorGroup.parent?.userData.selection if (!selection) { console.error('Unable to dimension segment when clicking the label.') return } sceneInfra.modelingSend({ type: 'Set selection', data: { selectionType: 'singleCodeCursor', selection: selection.graphSelections[0], }, }) // Command Bar commandBarActor.send({ type: 'Find and select command', data: { name: 'Constrain length', groupId: 'modeling', argDefaultValues: { selection, }, }, }) } // Style the elements lengthIndicatorWrapper.style.position = 'absolute' lengthIndicatorWrapper.style.pointerEvents = 'auto' lengthIndicatorWrapper.appendChild(lengthIndicatorText) const cssObject = new CSS2DObject(lengthIndicatorWrapper) cssObject.name = SEGMENT_LENGTH_LABEL_TEXT // Position the elements based on the line's heading const offsetFromMidpoint = new Vector2(to[0] - from[0], to[1] - from[1]) .normalize() .rotateAround(new Vector2(0, 0), -Math.PI / 2) .multiplyScalar(SEGMENT_LENGTH_LABEL_OFFSET_PX * scale) lengthIndicatorText.style.setProperty('--x', `${offsetFromMidpoint.x}px`) lengthIndicatorText.style.setProperty('--y', `${offsetFromMidpoint.y}px`) lengthIndicatorGroup.add(cssObject) return lengthIndicatorGroup } export function createArcGeometry({ center, radius, startAngle, endAngle, ccw, isDashed = false, scale = 1, }: { center: Coords2d radius: number startAngle: number endAngle: number ccw: boolean isDashed?: boolean scale?: number }): BufferGeometry { const dashSizePx = 18 * scale const gapSizePx = 18 * scale const arcStart = new EllipseCurve( center[0], center[1], radius, radius, startAngle, endAngle, !ccw, 0 ) const arcEnd = new EllipseCurve( center[0], center[1], radius, radius, endAngle, startAngle, ccw, 0 ) const shape = new Shape() shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) // The width of the line if (!isDashed) { const points = arcStart.getPoints(50) const path = new CurvePath() path.add(new CatmullRomCurve3(points.map((p) => new Vector3(p.x, p.y, 0)))) return new ExtrudeGeometry(shape, { steps: 100, bevelEnabled: false, extrudePath: path, }) } const length = arcStart.getLength() const totalDashes = length / (dashSizePx + gapSizePx) // rounding makes the dashes jittery since the new dash is suddenly appears instead of growing into place const dashesAtEachEnd = Math.min(100, totalDashes / 2) // Assuming we want 50 dashes total, 25 at each end const dashGeometries = [] // Function to create a dash at a specific t value (0 to 1 along the curve) const createDashAt = (t: number, curve: EllipseCurve) => { const startVec = curve.getPoint(t) const endVec = curve.getPoint(Math.min(0.5, t + dashSizePx / length)) const midVec = curve.getPoint(Math.min(0.5, t + dashSizePx / length / 2)) const dashCurve = new CurvePath() dashCurve.add( new CatmullRomCurve3([ new Vector3(startVec.x, startVec.y, 0), new Vector3(midVec.x, midVec.y, 0), new Vector3(endVec.x, endVec.y, 0), ]) ) return new ExtrudeGeometry(shape, { steps: 3, bevelEnabled: false, extrudePath: dashCurve, }) } // Create dashes at the start of the arc for (let i = 0; i < dashesAtEachEnd; i++) { const t = i / totalDashes dashGeometries.push(createDashAt(t, arcStart)) dashGeometries.push(createDashAt(t, arcEnd)) } // fill in the remaining arc const remainingArcLength = length - dashesAtEachEnd * 2 * (dashSizePx + gapSizePx) if (remainingArcLength > 0) { const remainingArcStartT = dashesAtEachEnd / totalDashes const remainingArcEndT = 1 - remainingArcStartT const centerVec = new Vector2(center[0], center[1]) const remainingArcStartVec = arcStart.getPoint(remainingArcStartT) const remainingArcEndVec = arcStart.getPoint(remainingArcEndT) const remainingArcCurve = new EllipseCurve( arcStart.aX, arcStart.aY, arcStart.xRadius, arcStart.yRadius, new Vector2().subVectors(centerVec, remainingArcStartVec).angle() + Math.PI, new Vector2().subVectors(centerVec, remainingArcEndVec).angle() + Math.PI, !ccw ) const remainingArcPoints = remainingArcCurve.getPoints(50) const remainingArcPath = new CurvePath() remainingArcPath.add( new CatmullRomCurve3( remainingArcPoints.map((p) => new Vector3(p.x, p.y, 0)) ) ) const remainingArcGeometry = new ExtrudeGeometry(shape, { steps: 50, bevelEnabled: false, extrudePath: remainingArcPath, }) dashGeometries.push(remainingArcGeometry) } const geo = dashGeometries.length ? mergeGeometries(dashGeometries) : new BufferGeometry() geo.userData.type = 'dashed' return geo } // (lee) The above is much more complex than necessary. // I've derived the new code from: // https://threejs.org/docs/#api/en/extras/curves/EllipseCurve // I'm not sure why it wasn't done like this in the first place? // I don't touch the code above because it may break something else. export function createCircleGeometry({ center, radius, color, isDashed = false, scale = 1, }: { center: Coords2d radius: number color: number isDashed?: boolean scale?: number }): Line { const circle = new EllipseCurve( center[0], center[1], radius, radius, 0, Math.PI * 2, true, scale ) const points = circle.getPoints(75) // just enough points to not see edges. const geometry = new BufferGeometry().setFromPoints(points) const material = !isDashed ? new LineBasicMaterial({ color }) : new LineDashedMaterial({ color, scale, dashSize: 6, gapSize: 6, }) const line = new Line(geometry, material) line.computeLineDistances() return line } export function dashedStraight( from: Coords2d, to: Coords2d, shape: Shape, scale = 1 ): BufferGeometry { const dashSize = 18 * scale const gapSize = 18 * scale // TODO: gapSize is not respected const dashLine = new LineCurve3( new Vector3(from[0], from[1], 0), new Vector3(to[0], to[1], 0) ) const length = dashLine.getLength() const numberOfPoints = (length / (dashSize + gapSize)) * 2 const startOfLine = new Vector3(from[0], from[1], 0) const endOfLine = new Vector3(to[0], to[1], 0) const dashGeometries = [] const dashComponent = (xOrY: number, pointIndex: number) => ((to[xOrY] - from[xOrY]) / numberOfPoints) * pointIndex + from[xOrY] for (let i = 0; i < numberOfPoints; i += 2) { const dashStart = new Vector3(dashComponent(0, i), dashComponent(1, i), 0) let dashEnd = new Vector3( dashComponent(0, i + 1), dashComponent(1, i + 1), 0 ) if (startOfLine.distanceTo(dashEnd) > startOfLine.distanceTo(endOfLine)) dashEnd = endOfLine if (dashEnd) { const dashCurve = new LineCurve3(dashStart, dashEnd) const dashGeometry = new ExtrudeGeometry(shape, { steps: 1, bevelEnabled: false, extrudePath: dashCurve, }) dashGeometries.push(dashGeometry) } } const geo = dashGeometries.length ? mergeGeometries(dashGeometries) : new BufferGeometry() geo.userData.type = 'dashed' return geo } export const segmentUtils = { straight: new StraightSegment(), tangentialArcTo: new TangentialArcToSegment(), circle: new CircleSegment(), circleThreePoint: new CircleThreePointSegment(), } as const